bambuddy/scripts/update_archive_quantities.py
maziggy 693466eafc 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
2026-01-16 07:33:44 +01:00

155 lines
4.4 KiB
Python
Executable file

#!/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()