mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02: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:
Executable
+155
@@ -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()
|
||||
Reference in New Issue
Block a user