mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
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
155 lines
4.4 KiB
Python
Executable file
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()
|