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:
maziggy 2026-01-16 07:33:44 +01:00
parent c514e1f2af
commit 693466eafc
15 changed files with 805 additions and 239 deletions

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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."""

View file

@ -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)

View file

@ -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

View file

@ -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,
)

View file

@ -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."""

View file

@ -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

View file

@ -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;

View file

@ -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>

View file

@ -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>

View 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

View file

@ -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>