mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
Add project parts tracking separate from plates
Track individual parts/objects separately from print plates in projects. Useful for multi-part builds like Voron where 25 plates produce 150 parts. Backend: - Add target_parts_count field to Project model - Calculate parts_progress_percent and remaining_parts in stats - Auto-detect quantity from 3MF printable objects when archiving - Sum archive quantities for completed_count (parts) - Use archive_count for plates progress Frontend: - Add "Target Parts" input in project create/edit modal - Show separate progress bars for plates vs parts - Stats footer displays both plates and parts count - Header badge shows parts progress when target set Scripts: - Add update_archive_quantities.py to migrate existing archives Tests: - Add 5 integration tests for parts tracking - Add 3 unit tests for 3MF object extraction Closes #85
This commit is contained in:
parent
c514e1f2af
commit
693466eafc
15 changed files with 805 additions and 239 deletions
24
CHANGELOG.md
24
CHANGELOG.md
|
|
@ -2,6 +2,30 @@
|
|||
|
||||
All notable changes to Bambuddy will be documented in this file.
|
||||
|
||||
## [0.1.6b12] - 2026-01-16
|
||||
|
||||
### Added
|
||||
- **Project parts tracking** - Track individual parts/objects separately from print plates:
|
||||
- New "Target Parts" field in project create/edit modal alongside "Target Plates"
|
||||
- Separate progress bars for plates (print jobs) vs parts (objects printed)
|
||||
- Parts count auto-detected from 3MF files when archiving prints
|
||||
- Project cards show both plates and parts counts in footer stats
|
||||
- Example: Voron build with 25 plates producing 150 parts total
|
||||
- Closes [#85](https://github.com/maziggy/bambuddy/issues/85)
|
||||
- **Archive quantity auto-detection** - Automatically set parts count when archiving:
|
||||
- Extracts printable object count from 3MF `slice_info.config`
|
||||
- Respects skipped objects (not counted)
|
||||
- Falls back to 1 if extraction fails
|
||||
- **Migration script for existing archives** - Update quantities on existing archives:
|
||||
- Run `python scripts/update_archive_quantities.py` to update existing archives
|
||||
- Use `--dry-run` flag to preview changes without applying them
|
||||
- Parses 3MF files to extract correct object counts
|
||||
|
||||
### Changed
|
||||
- Project stats now correctly distinguish between plates (archive count) and parts (sum of quantities)
|
||||
- Project list `completed_count` now represents parts printed, not print jobs
|
||||
- Progress calculations: plates use `archive_count/target_count`, parts use `completed_count/target_parts_count`
|
||||
|
||||
## [0.1.6b11] - 2026-01-13
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -78,8 +78,8 @@
|
|||
|
||||
### 📁 Projects
|
||||
- Group related prints (e.g., "Voron Build")
|
||||
- Track progress with target counts
|
||||
- Quantity tracking for batch prints
|
||||
- Track plates (print jobs) and parts separately
|
||||
- Auto-detect parts count from 3MF files
|
||||
- Color-coded project badges
|
||||
- Bulk assign archives via multi-select toolbar
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ logger = logging.getLogger(__name__)
|
|||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
|
||||
async def compute_project_stats(db: AsyncSession, project_id: int, target_count: int | None = None) -> ProjectStats:
|
||||
async def compute_project_stats(
|
||||
db: AsyncSession, project_id: int, target_count: int | None = None, target_parts_count: int | None = None
|
||||
) -> ProjectStats:
|
||||
"""Compute statistics for a project."""
|
||||
# Count total archives (distinct print jobs)
|
||||
total_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id))
|
||||
|
|
@ -49,14 +51,6 @@ async def compute_project_stats(db: AsyncSession, project_id: int, target_count:
|
|||
)
|
||||
total_items = total_items_result.scalar() or 0
|
||||
|
||||
# Count completed archives (number of print jobs) - includes "archived" as successful
|
||||
completed_result = await db.execute(
|
||||
select(func.count(PrintArchive.id)).where(
|
||||
PrintArchive.project_id == project_id, PrintArchive.status.in_(["completed", "archived"])
|
||||
)
|
||||
)
|
||||
completed_prints = completed_result.scalar() or 0
|
||||
|
||||
# Count failed archives (number of print jobs) - includes all failure states
|
||||
failed_result = await db.execute(
|
||||
select(func.count(PrintArchive.id)).where(
|
||||
|
|
@ -94,12 +88,28 @@ async def compute_project_stats(db: AsyncSession, project_id: int, target_count:
|
|||
)
|
||||
in_progress_prints = in_progress_result.scalar() or 0
|
||||
|
||||
# Calculate progress
|
||||
# Sum completed items (parts) - sum of quantities for successful prints
|
||||
completed_items_result = await db.execute(
|
||||
select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
|
||||
PrintArchive.project_id == project_id,
|
||||
PrintArchive.status.in_(["completed", "archived"]),
|
||||
)
|
||||
)
|
||||
completed_items = int(completed_items_result.scalar() or 0)
|
||||
|
||||
# Calculate progress for plates (target_count vs total_archives)
|
||||
progress_percent = None
|
||||
remaining_prints = None
|
||||
if target_count and target_count > 0:
|
||||
progress_percent = round((completed_prints / target_count) * 100, 1)
|
||||
remaining_prints = max(0, target_count - completed_prints)
|
||||
progress_percent = round((total_archives / target_count) * 100, 1)
|
||||
remaining_prints = max(0, target_count - total_archives)
|
||||
|
||||
# Calculate progress for parts (target_parts_count vs completed_items)
|
||||
parts_progress_percent = None
|
||||
remaining_parts = None
|
||||
if target_parts_count and target_parts_count > 0:
|
||||
parts_progress_percent = round((completed_items / target_parts_count) * 100, 1)
|
||||
remaining_parts = max(0, target_parts_count - completed_items)
|
||||
|
||||
# BOM stats
|
||||
bom_result = await db.execute(
|
||||
|
|
@ -115,17 +125,19 @@ async def compute_project_stats(db: AsyncSession, project_id: int, target_count:
|
|||
return ProjectStats(
|
||||
total_archives=total_archives,
|
||||
total_items=int(total_items),
|
||||
completed_prints=int(completed_prints),
|
||||
completed_prints=completed_items, # Now reflects sum of quantities for completed prints
|
||||
failed_prints=int(failed_prints),
|
||||
queued_prints=queued_prints,
|
||||
in_progress_prints=in_progress_prints,
|
||||
total_print_time_hours=round((sums.total_time or 0) / 3600, 2),
|
||||
total_filament_grams=round(sums.total_filament or 0, 2),
|
||||
progress_percent=progress_percent,
|
||||
parts_progress_percent=parts_progress_percent,
|
||||
estimated_cost=round((sums.total_filament_cost or 0), 2),
|
||||
total_energy_kwh=round((sums.total_energy or 0), 3),
|
||||
total_energy_cost=round((sums.total_energy_cost or 0), 2),
|
||||
remaining_prints=remaining_prints,
|
||||
remaining_parts=remaining_parts,
|
||||
bom_total_items=bom_stats.total or 0,
|
||||
bom_completed_items=int(bom_stats.completed or 0),
|
||||
)
|
||||
|
|
@ -170,27 +182,28 @@ async def list_projects(
|
|||
)
|
||||
queue_count = queue_count_result.scalar() or 0
|
||||
|
||||
# Count completed archives - includes "archived" as successful
|
||||
# Sum completed parts (quantities) - includes "archived" as successful
|
||||
completed_result = await db.execute(
|
||||
select(func.count(PrintArchive.id)).where(
|
||||
select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
|
||||
PrintArchive.project_id == project.id,
|
||||
PrintArchive.status.in_(["completed", "archived"]),
|
||||
)
|
||||
)
|
||||
completed_count = int(completed_result.scalar() or 0)
|
||||
|
||||
# Count failed archives - includes all failure states
|
||||
# Sum failed parts (quantities) - includes all failure states
|
||||
failed_result = await db.execute(
|
||||
select(func.count(PrintArchive.id)).where(
|
||||
select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
|
||||
PrintArchive.project_id == project.id,
|
||||
PrintArchive.status.in_(["failed", "aborted", "cancelled", "stopped"]),
|
||||
)
|
||||
)
|
||||
failed_count = int(failed_result.scalar() or 0)
|
||||
|
||||
# Plates progress: archive_count / target_count
|
||||
progress_percent = None
|
||||
if project.target_count and project.target_count > 0:
|
||||
progress_percent = round((completed_count / project.target_count) * 100, 1)
|
||||
progress_percent = round((archive_count / project.target_count) * 100, 1)
|
||||
|
||||
# Get archive previews (up to 6 most recent)
|
||||
archives_result = await db.execute(
|
||||
|
|
@ -220,6 +233,7 @@ async def list_projects(
|
|||
color=project.color,
|
||||
status=project.status,
|
||||
target_count=project.target_count,
|
||||
target_parts_count=project.target_parts_count,
|
||||
created_at=project.created_at,
|
||||
archive_count=archive_count,
|
||||
total_items=total_items,
|
||||
|
|
@ -254,6 +268,7 @@ async def create_project(
|
|||
description=data.description,
|
||||
color=data.color,
|
||||
target_count=data.target_count,
|
||||
target_parts_count=data.target_parts_count,
|
||||
notes=data.notes,
|
||||
tags=data.tags,
|
||||
due_date=data.due_date,
|
||||
|
|
@ -265,7 +280,7 @@ async def create_project(
|
|||
await db.flush()
|
||||
await db.refresh(project)
|
||||
|
||||
stats = await compute_project_stats(db, project.id, project.target_count)
|
||||
stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
|
||||
|
||||
return ProjectResponse(
|
||||
id=project.id,
|
||||
|
|
@ -274,6 +289,7 @@ async def create_project(
|
|||
color=project.color,
|
||||
status=project.status,
|
||||
target_count=project.target_count,
|
||||
target_parts_count=project.target_parts_count,
|
||||
notes=project.notes,
|
||||
attachments=project.attachments,
|
||||
tags=project.tags,
|
||||
|
|
@ -351,6 +367,7 @@ async def create_project_from_template(
|
|||
description=template.description,
|
||||
color=template.color,
|
||||
target_count=template.target_count,
|
||||
target_parts_count=template.target_parts_count,
|
||||
notes=template.notes,
|
||||
tags=template.tags,
|
||||
priority=template.priority,
|
||||
|
|
@ -382,7 +399,7 @@ async def create_project_from_template(
|
|||
await db.flush()
|
||||
await db.refresh(project)
|
||||
|
||||
stats = await compute_project_stats(db, project.id, project.target_count)
|
||||
stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
|
||||
|
||||
return ProjectResponse(
|
||||
id=project.id,
|
||||
|
|
@ -391,6 +408,7 @@ async def create_project_from_template(
|
|||
color=project.color,
|
||||
status=project.status,
|
||||
target_count=project.target_count,
|
||||
target_parts_count=project.target_parts_count,
|
||||
notes=project.notes,
|
||||
attachments=project.attachments,
|
||||
tags=project.tags,
|
||||
|
|
@ -463,7 +481,7 @@ async def get_project(
|
|||
# Get children
|
||||
children = await get_child_previews(db, project.id)
|
||||
|
||||
stats = await compute_project_stats(db, project.id, project.target_count)
|
||||
stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
|
||||
|
||||
return ProjectResponse(
|
||||
id=project.id,
|
||||
|
|
@ -472,6 +490,7 @@ async def get_project(
|
|||
color=project.color,
|
||||
status=project.status,
|
||||
target_count=project.target_count,
|
||||
target_parts_count=project.target_parts_count,
|
||||
notes=project.notes,
|
||||
attachments=project.attachments,
|
||||
tags=project.tags,
|
||||
|
|
@ -515,6 +534,8 @@ async def update_project(
|
|||
project.status = data.status
|
||||
if data.target_count is not None:
|
||||
project.target_count = data.target_count
|
||||
if data.target_parts_count is not None:
|
||||
project.target_parts_count = data.target_parts_count
|
||||
if data.notes is not None:
|
||||
project.notes = data.notes
|
||||
if data.tags is not None:
|
||||
|
|
@ -551,7 +572,7 @@ async def update_project(
|
|||
# Get children
|
||||
children = await get_child_previews(db, project.id)
|
||||
|
||||
stats = await compute_project_stats(db, project.id, project.target_count)
|
||||
stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
|
||||
|
||||
return ProjectResponse(
|
||||
id=project.id,
|
||||
|
|
@ -560,6 +581,7 @@ async def update_project(
|
|||
color=project.color,
|
||||
status=project.status,
|
||||
target_count=project.target_count,
|
||||
target_parts_count=project.target_parts_count,
|
||||
notes=project.notes,
|
||||
attachments=project.attachments,
|
||||
tags=project.tags,
|
||||
|
|
@ -1143,6 +1165,7 @@ async def create_template_from_project(
|
|||
description=source.description,
|
||||
color=source.color,
|
||||
target_count=source.target_count,
|
||||
target_parts_count=source.target_parts_count,
|
||||
notes=source.notes,
|
||||
tags=source.tags,
|
||||
priority=source.priority,
|
||||
|
|
@ -1174,7 +1197,7 @@ async def create_template_from_project(
|
|||
await db.flush()
|
||||
await db.refresh(template)
|
||||
|
||||
stats = await compute_project_stats(db, template.id, template.target_count)
|
||||
stats = await compute_project_stats(db, template.id, template.target_count, template.target_parts_count)
|
||||
|
||||
return ProjectResponse(
|
||||
id=template.id,
|
||||
|
|
@ -1183,6 +1206,7 @@ async def create_template_from_project(
|
|||
color=template.color,
|
||||
status=template.status,
|
||||
target_count=template.target_count,
|
||||
target_parts_count=template.target_parts_count,
|
||||
notes=template.notes,
|
||||
attachments=template.attachments,
|
||||
tags=template.tags,
|
||||
|
|
|
|||
|
|
@ -399,6 +399,12 @@ async def run_migrations(conn):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration: Add target_parts_count column to projects for tracking total parts needed
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE projects ADD COLUMN target_parts_count INTEGER"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def seed_notification_templates():
|
||||
"""Seed default notification templates if they don't exist."""
|
||||
|
|
|
|||
|
|
@ -16,7 +16,12 @@ class Project(Base):
|
|||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
color: Mapped[str | None] = mapped_column(String(20), nullable=True) # Hex color for UI
|
||||
status: Mapped[str] = mapped_column(String(20), default="active") # active, completed, archived
|
||||
target_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # Optional target number of prints
|
||||
target_count: Mapped[int | None] = mapped_column(
|
||||
Integer, nullable=True
|
||||
) # Optional target number of prints (plates)
|
||||
target_parts_count: Mapped[int | None] = mapped_column(
|
||||
Integer, nullable=True
|
||||
) # Optional target number of parts/objects
|
||||
|
||||
# Phase 2: Rich text notes (HTML from WYSIWYG editor)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ class ProjectCreate(BaseModel):
|
|||
description: str | None = None
|
||||
color: str | None = None
|
||||
target_count: int | None = None
|
||||
target_parts_count: int | None = None
|
||||
notes: str | None = None
|
||||
tags: str | None = None
|
||||
due_date: datetime | None = None
|
||||
|
|
@ -26,6 +27,7 @@ class ProjectUpdate(BaseModel):
|
|||
color: str | None = None
|
||||
status: str | None = None # active, completed, archived
|
||||
target_count: int | None = None
|
||||
target_parts_count: int | None = None
|
||||
notes: str | None = None
|
||||
tags: str | None = None
|
||||
due_date: datetime | None = None
|
||||
|
|
@ -45,12 +47,14 @@ class ProjectStats(BaseModel):
|
|||
in_progress_prints: int = 0
|
||||
total_print_time_hours: float = 0.0
|
||||
total_filament_grams: float = 0.0
|
||||
progress_percent: float | None = None # Based on target_count
|
||||
progress_percent: float | None = None # Based on target_count (plates)
|
||||
parts_progress_percent: float | None = None # Based on target_parts_count
|
||||
# Cost tracking (Phase 6)
|
||||
estimated_cost: float = 0.0 # Based on filament cost
|
||||
total_energy_kwh: float = 0.0
|
||||
total_energy_cost: float = 0.0
|
||||
remaining_prints: int | None = None # target_count - completed_prints
|
||||
remaining_prints: int | None = None # target_count - total_archives
|
||||
remaining_parts: int | None = None # target_parts_count - completed_prints
|
||||
# BOM stats (Phase 7)
|
||||
bom_total_items: int = 0
|
||||
bom_completed_items: int = 0
|
||||
|
|
@ -75,6 +79,7 @@ class ProjectResponse(BaseModel):
|
|||
color: str | None
|
||||
status: str
|
||||
target_count: int | None
|
||||
target_parts_count: int | None = None
|
||||
notes: str | None = None
|
||||
attachments: list | None = None
|
||||
tags: str | None = None
|
||||
|
|
@ -114,6 +119,7 @@ class ProjectListResponse(BaseModel):
|
|||
color: str | None
|
||||
status: str
|
||||
target_count: int | None
|
||||
target_parts_count: int | None = None
|
||||
created_at: datetime
|
||||
# Quick stats
|
||||
archive_count: int = 0 # Number of print jobs
|
||||
|
|
|
|||
|
|
@ -834,6 +834,14 @@ class ArchiveService:
|
|||
default_cost_per_kg = 25.0
|
||||
cost = round((filament_grams / 1000) * default_cost_per_kg, 2)
|
||||
|
||||
# Calculate quantity from printable objects count
|
||||
# printable_objects is a dict of {identify_id: name} for non-skipped objects
|
||||
quantity = 1 # Default to 1
|
||||
printable_objects = metadata.get("printable_objects")
|
||||
if printable_objects and isinstance(printable_objects, dict):
|
||||
quantity = len(printable_objects)
|
||||
logger.debug(f"Auto-detected {quantity} parts from 3MF printable objects")
|
||||
|
||||
# Create archive record
|
||||
archive = PrintArchive(
|
||||
printer_id=printer_id,
|
||||
|
|
@ -858,6 +866,7 @@ class ArchiveService:
|
|||
started_at=started_at,
|
||||
completed_at=completed_at,
|
||||
cost=cost,
|
||||
quantity=quantity,
|
||||
extra_data=metadata,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -113,6 +113,155 @@ class TestProjectsAPI:
|
|||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestProjectPartsTracking:
|
||||
"""Tests for project parts tracking feature."""
|
||||
|
||||
@pytest.fixture
|
||||
async def project_factory(self, db_session):
|
||||
"""Factory to create test projects."""
|
||||
|
||||
async def _create_project(**kwargs):
|
||||
from backend.app.models.project import Project
|
||||
|
||||
defaults = {
|
||||
"name": "Parts Test Project",
|
||||
"description": "Test project",
|
||||
"color": "#FF0000",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
|
||||
project = Project(**defaults)
|
||||
db_session.add(project)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(project)
|
||||
return project
|
||||
|
||||
return _create_project
|
||||
|
||||
@pytest.fixture
|
||||
async def archive_factory(self, db_session):
|
||||
"""Factory to create test archives."""
|
||||
|
||||
async def _create_archive(**kwargs):
|
||||
from backend.app.models.archive import PrintArchive
|
||||
|
||||
defaults = {
|
||||
"filename": "test.3mf",
|
||||
"file_path": "test/test.3mf",
|
||||
"file_size": 1000,
|
||||
"print_name": "Test Print",
|
||||
"status": "completed",
|
||||
"quantity": 1,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
|
||||
archive = PrintArchive(**defaults)
|
||||
db_session.add(archive)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(archive)
|
||||
return archive
|
||||
|
||||
return _create_archive
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_project_with_target_parts_count(self, async_client: AsyncClient):
|
||||
"""Verify project can be created with target_parts_count."""
|
||||
data = {
|
||||
"name": "Parts Project",
|
||||
"target_count": 10, # 10 plates
|
||||
"target_parts_count": 50, # 50 parts total
|
||||
}
|
||||
response = await async_client.post("/api/v1/projects/", json=data)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["target_count"] == 10
|
||||
assert result["target_parts_count"] == 50
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_project_target_parts_count(self, async_client: AsyncClient, project_factory, db_session):
|
||||
"""Verify target_parts_count can be updated."""
|
||||
project = await project_factory()
|
||||
response = await async_client.patch(
|
||||
f"/api/v1/projects/{project.id}",
|
||||
json={"target_parts_count": 100},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["target_parts_count"] == 100
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_project_parts_progress_calculation(
|
||||
self, async_client: AsyncClient, project_factory, archive_factory, db_session
|
||||
):
|
||||
"""Verify parts progress is calculated from archive quantities."""
|
||||
# Create project with target of 20 parts
|
||||
project = await project_factory(target_parts_count=20)
|
||||
|
||||
# Create archives with different quantities
|
||||
await archive_factory(project_id=project.id, quantity=3, status="completed") # 3 parts
|
||||
await archive_factory(project_id=project.id, quantity=5, status="completed") # 5 parts
|
||||
await archive_factory(project_id=project.id, quantity=2, status="completed") # 2 parts
|
||||
# Total: 10 parts completed out of 20 = 50%
|
||||
|
||||
response = await async_client.get(f"/api/v1/projects/{project.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check stats
|
||||
assert data["stats"]["completed_prints"] == 10 # Sum of quantities
|
||||
assert data["stats"]["parts_progress_percent"] == 50.0 # 10/20 = 50%
|
||||
assert data["stats"]["remaining_parts"] == 10 # 20 - 10 = 10
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_project_list_shows_parts_count(
|
||||
self, async_client: AsyncClient, project_factory, archive_factory, db_session
|
||||
):
|
||||
"""Verify project list returns correct completed_count (parts sum)."""
|
||||
project = await project_factory(name="List Parts Project", target_parts_count=100)
|
||||
|
||||
# Create archives with quantities
|
||||
await archive_factory(project_id=project.id, quantity=4, status="completed")
|
||||
await archive_factory(project_id=project.id, quantity=6, status="completed")
|
||||
# Total: 10 parts, 2 plates
|
||||
|
||||
response = await async_client.get("/api/v1/projects/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Find our project
|
||||
our_project = next((p for p in data if p["name"] == "List Parts Project"), None)
|
||||
assert our_project is not None
|
||||
assert our_project["archive_count"] == 2 # 2 plates
|
||||
assert our_project["completed_count"] == 10 # 10 parts (sum of quantities)
|
||||
assert our_project["target_parts_count"] == 100
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_plates_vs_parts_progress(
|
||||
self, async_client: AsyncClient, project_factory, archive_factory, db_session
|
||||
):
|
||||
"""Verify plates and parts progress are calculated separately."""
|
||||
# Project needs 5 plates producing 25 parts total (5 parts per plate)
|
||||
project = await project_factory(target_count=5, target_parts_count=25)
|
||||
|
||||
# Complete 2 plates, each with 5 parts
|
||||
await archive_factory(project_id=project.id, quantity=5, status="completed")
|
||||
await archive_factory(project_id=project.id, quantity=5, status="completed")
|
||||
# Plates: 2/5 = 40%, Parts: 10/25 = 40%
|
||||
|
||||
response = await async_client.get(f"/api/v1/projects/{project.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["stats"]["total_archives"] == 2 # 2 plates
|
||||
assert data["stats"]["completed_prints"] == 10 # 10 parts
|
||||
assert data["stats"]["progress_percent"] == 40.0 # plates: 2/5
|
||||
assert data["stats"]["parts_progress_percent"] == 40.0 # parts: 10/25
|
||||
|
||||
|
||||
class TestProjectArchivesAPI:
|
||||
"""Tests for project-archive relationships."""
|
||||
|
||||
|
|
|
|||
|
|
@ -214,3 +214,81 @@ class TestArchiveThumbnails:
|
|||
]
|
||||
for path in expected_thumbnail_paths:
|
||||
assert "png" in path.lower()
|
||||
|
||||
|
||||
class TestPrintableObjectsExtraction:
|
||||
"""Tests for extracting printable objects count from 3MF files."""
|
||||
|
||||
def test_extract_printable_objects_from_slice_info(self):
|
||||
"""Test parsing printable objects from slice_info.config XML."""
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
# Example slice_info.config content with 4 objects
|
||||
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
<plate plate_idx="1">
|
||||
<metadata key="prediction" value="3600" />
|
||||
<metadata key="weight" value="50.5" />
|
||||
<object identify_id="1" name="Part_A" skipped="false" />
|
||||
<object identify_id="2" name="Part_B" skipped="false" />
|
||||
<object identify_id="3" name="Part_C" skipped="false" />
|
||||
<object identify_id="4" name="Part_D" skipped="true" />
|
||||
</plate>
|
||||
</config>
|
||||
"""
|
||||
root = ET.fromstring(slice_info_xml)
|
||||
plate = root.find(".//plate")
|
||||
|
||||
# Count non-skipped objects (should be 3, not 4)
|
||||
count = 0
|
||||
for obj in plate.findall("object"):
|
||||
skipped = obj.get("skipped", "false")
|
||||
if skipped.lower() != "true":
|
||||
count += 1
|
||||
|
||||
assert count == 3 # 3 objects (Part_D is skipped)
|
||||
|
||||
def test_extract_printable_objects_empty_plate(self):
|
||||
"""Test handling plate with no objects."""
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
<plate plate_idx="1">
|
||||
<metadata key="prediction" value="0" />
|
||||
</plate>
|
||||
</config>
|
||||
"""
|
||||
root = ET.fromstring(slice_info_xml)
|
||||
plate = root.find(".//plate")
|
||||
|
||||
count = 0
|
||||
for obj in plate.findall("object"):
|
||||
skipped = obj.get("skipped", "false")
|
||||
if skipped.lower() != "true":
|
||||
count += 1
|
||||
|
||||
assert count == 0
|
||||
|
||||
def test_extract_printable_objects_all_skipped(self):
|
||||
"""Test handling plate where all objects are skipped."""
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
<plate plate_idx="1">
|
||||
<object identify_id="1" name="Part_A" skipped="true" />
|
||||
<object identify_id="2" name="Part_B" skipped="true" />
|
||||
</plate>
|
||||
</config>
|
||||
"""
|
||||
root = ET.fromstring(slice_info_xml)
|
||||
plate = root.find(".//plate")
|
||||
|
||||
count = 0
|
||||
for obj in plate.findall("object"):
|
||||
skipped = obj.get("skipped", "false")
|
||||
if skipped.lower() != "true":
|
||||
count += 1
|
||||
|
||||
assert count == 0 # All objects skipped
|
||||
|
|
|
|||
|
|
@ -341,17 +341,19 @@ export interface SimilarArchive {
|
|||
export interface ProjectStats {
|
||||
total_archives: number;
|
||||
total_items: number; // Sum of quantities (total items printed)
|
||||
completed_prints: number;
|
||||
completed_prints: number; // Sum of quantities for completed prints (parts)
|
||||
failed_prints: number;
|
||||
queued_prints: number;
|
||||
in_progress_prints: number;
|
||||
total_print_time_hours: number;
|
||||
total_filament_grams: number;
|
||||
progress_percent: number | null;
|
||||
progress_percent: number | null; // Plates progress (total_archives / target_count)
|
||||
parts_progress_percent: number | null; // Parts progress (completed_prints / target_parts_count)
|
||||
estimated_cost: number;
|
||||
total_energy_kwh: number;
|
||||
total_energy_cost: number;
|
||||
remaining_prints: number | null;
|
||||
remaining_prints: number | null; // Remaining plates
|
||||
remaining_parts: number | null; // Remaining parts
|
||||
bom_total_items: number;
|
||||
bom_completed_items: number;
|
||||
}
|
||||
|
|
@ -370,7 +372,8 @@ export interface Project {
|
|||
description: string | null;
|
||||
color: string | null;
|
||||
status: string; // active, completed, archived
|
||||
target_count: number | null;
|
||||
target_count: number | null; // Target number of plates/print jobs
|
||||
target_parts_count: number | null; // Target number of parts/objects
|
||||
notes: string | null;
|
||||
attachments: ProjectAttachment[] | null;
|
||||
tags: string | null;
|
||||
|
|
@ -409,14 +412,15 @@ export interface ProjectListItem {
|
|||
description: string | null;
|
||||
color: string | null;
|
||||
status: string;
|
||||
target_count: number | null;
|
||||
target_count: number | null; // Target number of plates/print jobs
|
||||
target_parts_count: number | null; // Target number of parts/objects
|
||||
created_at: string;
|
||||
archive_count: number; // Number of print jobs
|
||||
archive_count: number; // Number of print jobs (plates)
|
||||
total_items: number; // Sum of quantities (total items printed, including failed)
|
||||
completed_count: number; // Sum of quantities for completed prints only
|
||||
completed_count: number; // Sum of quantities for completed prints only (parts)
|
||||
failed_count: number; // Sum of quantities for failed prints
|
||||
queue_count: number;
|
||||
progress_percent: number | null;
|
||||
progress_percent: number | null; // Plates progress
|
||||
archives: ArchivePreview[];
|
||||
}
|
||||
|
||||
|
|
@ -425,6 +429,7 @@ export interface ProjectCreate {
|
|||
description?: string;
|
||||
color?: string;
|
||||
target_count?: number;
|
||||
target_parts_count?: number;
|
||||
notes?: string;
|
||||
tags?: string;
|
||||
due_date?: string;
|
||||
|
|
@ -439,6 +444,7 @@ export interface ProjectUpdate {
|
|||
color?: string;
|
||||
status?: string;
|
||||
target_count?: number;
|
||||
target_parts_count?: number;
|
||||
notes?: string;
|
||||
tags?: string;
|
||||
due_date?: string;
|
||||
|
|
|
|||
|
|
@ -436,7 +436,10 @@ export function ProjectDetailPage() {
|
|||
}
|
||||
|
||||
const stats = project.stats;
|
||||
const progressPercent = stats?.progress_percent ?? 0;
|
||||
// Plates progress: total_archives / target_count
|
||||
const platesProgressPercent = stats?.progress_percent ?? 0;
|
||||
// Parts progress: completed_prints / target_parts_count
|
||||
const partsProgressPercent = stats?.parts_progress_percent ?? 0;
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 space-y-8">
|
||||
|
|
@ -478,35 +481,70 @@ export function ProjectDetailPage() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar (if target set) */}
|
||||
{project.target_count && (
|
||||
{/* Progress bars (if targets set) */}
|
||||
{(project.target_count || project.target_parts_count) && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-bambu-gray">Progress</span>
|
||||
<span className="text-sm font-medium text-white">
|
||||
{stats?.completed_prints || 0} / {project.target_count} completed
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.min(progressPercent, 100)}%`,
|
||||
backgroundColor: progressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-xs text-bambu-gray/70">
|
||||
{progressPercent.toFixed(0)}% complete
|
||||
</span>
|
||||
{project.target_count - (stats?.completed_prints || 0) > 0 && (
|
||||
<span className="text-xs text-bambu-gray/70">
|
||||
{project.target_count - (stats?.completed_prints || 0)} remaining
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="p-4 space-y-4">
|
||||
{/* Plates progress */}
|
||||
{project.target_count && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-bambu-gray">Plates Progress</span>
|
||||
<span className="text-sm font-medium text-white">
|
||||
{stats?.total_archives || 0} / {project.target_count} print jobs
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.min(platesProgressPercent, 100)}%`,
|
||||
backgroundColor: platesProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-xs text-bambu-gray/70">
|
||||
{platesProgressPercent.toFixed(0)}% complete
|
||||
</span>
|
||||
{stats?.remaining_prints != null && stats.remaining_prints > 0 && (
|
||||
<span className="text-xs text-bambu-gray/70">
|
||||
{stats.remaining_prints} remaining
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Parts progress */}
|
||||
{project.target_parts_count && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-bambu-gray">Parts Progress</span>
|
||||
<span className="text-sm font-medium text-white">
|
||||
{stats?.completed_prints || 0} / {project.target_parts_count} parts
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.min(partsProgressPercent, 100)}%`,
|
||||
backgroundColor: partsProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-xs text-bambu-gray/70">
|
||||
{partsProgressPercent.toFixed(0)}% complete
|
||||
</span>
|
||||
{stats?.remaining_parts != null && stats.remaining_parts > 0 && (
|
||||
<span className="text-xs text-bambu-gray/70">
|
||||
{stats.remaining_parts} remaining
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
|
@ -522,13 +560,11 @@ export function ProjectDetailPage() {
|
|||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-bambu-gray">Print Jobs</p>
|
||||
<p className="text-xl font-semibold text-white">{stats.completed_prints} <span className="text-sm font-normal text-bambu-gray">successful</span></p>
|
||||
<p className="text-xl font-semibold text-white">{stats.total_archives} <span className="text-sm font-normal text-bambu-gray">total</span></p>
|
||||
{stats.failed_prints > 0 && (
|
||||
<p className="text-sm text-red-400">{stats.failed_prints} failed</p>
|
||||
)}
|
||||
{stats.total_archives - stats.completed_prints - stats.failed_prints > 0 && (
|
||||
<p className="text-sm text-yellow-400">{stats.total_archives - stats.completed_prints - stats.failed_prints} in progress</p>
|
||||
)}
|
||||
<p className="text-sm text-bambu-gray">{stats.completed_prints} parts printed</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
Archive,
|
||||
ListTodo,
|
||||
Package,
|
||||
Layers,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
|
|
@ -46,6 +47,7 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
|
|||
const [description, setDescription] = useState(project?.description || '');
|
||||
const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
|
||||
const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || '');
|
||||
const [targetPartsCount, setTargetPartsCount] = useState(project?.target_parts_count?.toString() || '');
|
||||
const [status, setStatus] = useState(project?.status || 'active');
|
||||
const [tags, setTags] = useState((project as ProjectListItem & { tags?: string })?.tags || '');
|
||||
const [dueDate, setDueDate] = useState((project as ProjectListItem & { due_date?: string })?.due_date?.split('T')[0] || '');
|
||||
|
|
@ -58,6 +60,7 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
|
|||
description: description.trim() || undefined,
|
||||
color,
|
||||
target_count: targetCount ? parseInt(targetCount, 10) : undefined,
|
||||
target_parts_count: targetPartsCount ? parseInt(targetPartsCount, 10) : undefined,
|
||||
tags: tags.trim() || undefined,
|
||||
due_date: dueDate || undefined,
|
||||
priority,
|
||||
|
|
@ -121,18 +124,36 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-1">
|
||||
Target Print Count (optional)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={targetCount}
|
||||
onChange={(e) => setTargetCount(e.target.value)}
|
||||
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
|
||||
placeholder="e.g., 50 parts to print"
|
||||
min="1"
|
||||
/>
|
||||
{/* Target Counts - Plates and Parts side by side */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-1">
|
||||
Target Plates
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={targetCount}
|
||||
onChange={(e) => setTargetCount(e.target.value)}
|
||||
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
|
||||
placeholder="e.g., 25"
|
||||
min="1"
|
||||
/>
|
||||
<p className="text-xs text-bambu-gray mt-1">Number of print jobs</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-1">
|
||||
Target Parts
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={targetPartsCount}
|
||||
onChange={(e) => setTargetPartsCount(e.target.value)}
|
||||
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
|
||||
placeholder="e.g., 150"
|
||||
min="1"
|
||||
/>
|
||||
<p className="text-xs text-bambu-gray mt-1">Total objects needed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
|
|
@ -224,7 +245,14 @@ interface ProjectCardProps {
|
|||
}
|
||||
|
||||
function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
|
||||
const progressPercent = project.progress_percent ?? 0;
|
||||
// Plates progress: archive_count / target_count
|
||||
const platesProgressPercent = project.target_count
|
||||
? Math.round((project.archive_count / project.target_count) * 100)
|
||||
: 0;
|
||||
// Parts progress: completed_count / target_parts_count
|
||||
const partsProgressPercent = project.target_parts_count
|
||||
? Math.round((project.completed_count / project.target_parts_count) * 100)
|
||||
: 0;
|
||||
const isCompleted = project.status === 'completed';
|
||||
const isArchived = project.status === 'archived';
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
|
|
@ -262,17 +290,25 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
|
|||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-white truncate">{project.name}</h3>
|
||||
{project.target_count ? (
|
||||
{project.target_parts_count ? (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
|
||||
progressPercent >= 100
|
||||
partsProgressPercent >= 100
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: 'bg-bambu-dark text-bambu-gray'
|
||||
}`}>
|
||||
{project.completed_count}/{project.target_count} completed
|
||||
{project.completed_count}/{project.target_parts_count} parts
|
||||
</span>
|
||||
) : project.target_count ? (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
|
||||
platesProgressPercent >= 100
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: 'bg-bambu-dark text-bambu-gray'
|
||||
}`}>
|
||||
{project.archive_count}/{project.target_count} plates
|
||||
</span>
|
||||
) : project.completed_count > 0 ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium bg-bambu-dark text-bambu-gray">
|
||||
{project.completed_count} completed
|
||||
{project.completed_count} parts
|
||||
</span>
|
||||
) : null}
|
||||
{isCompleted && (
|
||||
|
|
@ -372,33 +408,61 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
|
|||
|
||||
{/* Progress section - show for all projects */}
|
||||
<div className="mb-4">
|
||||
{project.target_count ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-xs mb-2">
|
||||
<span className="text-bambu-gray">Progress</span>
|
||||
<span className={progressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
|
||||
{project.completed_count} / {project.target_count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2.5 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm">
|
||||
<div
|
||||
className="h-full transition-all duration-500 ease-out rounded-full relative"
|
||||
style={{
|
||||
width: `${Math.min(progressPercent, 100)}%`,
|
||||
background: progressPercent >= 100
|
||||
? 'linear-gradient(90deg, #22c55e, #4ade80)'
|
||||
: `linear-gradient(90deg, ${project.color || '#6b7280'}, ${project.color || '#6b7280'}cc)`,
|
||||
boxShadow: `0 0 8px ${progressPercent >= 100 ? '#22c55e' : project.color || '#6b7280'}60`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-bambu-gray/60 mt-1">
|
||||
<span>
|
||||
{project.failed_count > 0 && `${project.failed_count} failed`}
|
||||
</span>
|
||||
<span>{progressPercent.toFixed(0)}% complete</span>
|
||||
</div>
|
||||
</>
|
||||
{(project.target_count || project.target_parts_count) ? (
|
||||
<div className="space-y-3">
|
||||
{/* Plates progress */}
|
||||
{project.target_count && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-bambu-gray">Plates</span>
|
||||
<span className={platesProgressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
|
||||
{project.archive_count} / {project.target_count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm">
|
||||
<div
|
||||
className="h-full transition-all duration-500 ease-out rounded-full relative"
|
||||
style={{
|
||||
width: `${Math.min(platesProgressPercent, 100)}%`,
|
||||
background: platesProgressPercent >= 100
|
||||
? 'linear-gradient(90deg, #22c55e, #4ade80)'
|
||||
: `linear-gradient(90deg, ${project.color || '#6b7280'}, ${project.color || '#6b7280'}cc)`,
|
||||
boxShadow: `0 0 8px ${platesProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280'}60`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Parts progress */}
|
||||
{project.target_parts_count && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-bambu-gray">Parts</span>
|
||||
<span className={partsProgressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
|
||||
{project.completed_count} / {project.target_parts_count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm">
|
||||
<div
|
||||
className="h-full transition-all duration-500 ease-out rounded-full relative"
|
||||
style={{
|
||||
width: `${Math.min(partsProgressPercent, 100)}%`,
|
||||
background: partsProgressPercent >= 100
|
||||
? 'linear-gradient(90deg, #22c55e, #4ade80)'
|
||||
: `linear-gradient(90deg, ${project.color || '#6b7280'}, ${project.color || '#6b7280'}cc)`,
|
||||
boxShadow: `0 0 8px ${partsProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280'}60`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Failed count */}
|
||||
{project.failed_count > 0 && (
|
||||
<div className="text-xs text-red-400">
|
||||
{project.failed_count} failed
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : project.completed_count > 0 || project.failed_count > 0 ? (
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
{project.completed_count > 0 && (
|
||||
|
|
@ -467,18 +531,22 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
|
|||
{/* Stats footer */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-4 text-xs text-bambu-gray">
|
||||
<div className="flex items-center gap-1.5" title="Completed prints">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-bambu-green" />
|
||||
<span>{project.completed_count}</span>
|
||||
<div className="flex items-center gap-1.5" title="Print jobs (plates)">
|
||||
<Layers className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>{project.archive_count} plates</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5" title="Parts printed">
|
||||
<Package className="w-3.5 h-3.5 text-bambu-green" />
|
||||
<span>{project.completed_count} parts</span>
|
||||
</div>
|
||||
{project.failed_count > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-red-400" title="Failed prints">
|
||||
<div className="flex items-center gap-1.5 text-red-400" title="Failed parts">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
<span>{project.failed_count}</span>
|
||||
</div>
|
||||
)}
|
||||
{project.queue_count > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-blue-400" title="In queue">
|
||||
<div className="flex items-center gap-1.5 text-yellow-400" title="In queue">
|
||||
<ListTodo className="w-3.5 h-3.5" />
|
||||
<span>{project.queue_count}</span>
|
||||
</div>
|
||||
|
|
|
|||
155
scripts/update_archive_quantities.py
Executable file
155
scripts/update_archive_quantities.py
Executable file
|
|
@ -0,0 +1,155 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Update archive quantities from 3MF files.
|
||||
|
||||
This script updates the quantity field on existing archives by parsing
|
||||
their 3MF files to count the number of printable objects.
|
||||
|
||||
Run this once after upgrading to add proper parts tracking to your projects.
|
||||
|
||||
Usage:
|
||||
# From the bambuddy directory:
|
||||
python scripts/update_archive_quantities.py
|
||||
|
||||
# Or with docker:
|
||||
docker exec -it bambuddy python scripts/update_archive_quantities.py
|
||||
|
||||
# Dry run (show what would be updated without changing anything):
|
||||
python scripts/update_archive_quantities.py --dry-run
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.core.database import async_session
|
||||
from backend.app.models.archive import PrintArchive
|
||||
|
||||
|
||||
def extract_object_count_from_3mf(file_path: Path) -> int | None:
|
||||
"""Extract the number of printable objects from a 3MF file.
|
||||
|
||||
Returns the count of non-skipped objects, or None if parsing fails.
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
if "Metadata/slice_info.config" not in zf.namelist():
|
||||
return None
|
||||
|
||||
content = zf.read("Metadata/slice_info.config").decode()
|
||||
root = ET.fromstring(content)
|
||||
|
||||
# Find the plate (use first plate)
|
||||
plate = root.find(".//plate")
|
||||
if plate is None:
|
||||
return None
|
||||
|
||||
# Count non-skipped objects
|
||||
count = 0
|
||||
for obj in plate.findall("object"):
|
||||
skipped = obj.get("skipped", "false")
|
||||
if skipped.lower() != "true":
|
||||
count += 1
|
||||
|
||||
return count if count > 0 else None
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error parsing {file_path.name}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def update_archive_quantities(dry_run: bool = False):
|
||||
"""Update quantity field on archives based on 3MF object count."""
|
||||
|
||||
print("=" * 60)
|
||||
print("Archive Quantity Updater")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
if dry_run:
|
||||
print("DRY RUN MODE - No changes will be made")
|
||||
print()
|
||||
|
||||
async with async_session() as db:
|
||||
# Get all archives with quantity=1 (the default)
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.quantity == 1))
|
||||
archives = result.scalars().all()
|
||||
|
||||
print(f"Found {len(archives)} archives with quantity=1")
|
||||
print()
|
||||
|
||||
updated = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
|
||||
for archive in archives:
|
||||
# Skip if no file path
|
||||
if not archive.file_path:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
|
||||
# Skip if file doesn't exist
|
||||
if not file_path.exists():
|
||||
print(f" [{archive.id}] File not found: {archive.file_path}")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Extract object count
|
||||
object_count = extract_object_count_from_3mf(file_path)
|
||||
|
||||
if object_count is None:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if object_count == 1:
|
||||
# No change needed
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Update the archive
|
||||
print(f" [{archive.id}] {archive.print_name}: 1 -> {object_count} parts")
|
||||
|
||||
if not dry_run:
|
||||
archive.quantity = object_count
|
||||
updated += 1
|
||||
else:
|
||||
updated += 1
|
||||
|
||||
if not dry_run:
|
||||
await db.commit()
|
||||
|
||||
print()
|
||||
print("-" * 60)
|
||||
print(f"Updated: {updated}")
|
||||
print(f"Skipped: {skipped} (no change needed or file not found)")
|
||||
print(f"Errors: {errors}")
|
||||
print()
|
||||
|
||||
if dry_run and updated > 0:
|
||||
print("Run without --dry-run to apply these changes.")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Update archive quantities from 3MF files")
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be updated without making changes",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
asyncio.run(update_archive_quantities(dry_run=args.dry_run))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -23,7 +23,7 @@
|
|||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-COShNpeg.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-Br9mMbRU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DAZKHkJ3.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue