update_archive_quantities.py 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. #!/usr/bin/env python3
  2. """Update archive quantities from 3MF files.
  3. This script updates the quantity field on existing archives by parsing
  4. their 3MF files to count the number of printable objects.
  5. Run this once after upgrading to add proper parts tracking to your projects.
  6. Usage:
  7. # From the bambuddy directory:
  8. python scripts/update_archive_quantities.py
  9. # Or with docker:
  10. docker exec -it bambuddy python scripts/update_archive_quantities.py
  11. # Dry run (show what would be updated without changing anything):
  12. python scripts/update_archive_quantities.py --dry-run
  13. """
  14. import argparse
  15. import asyncio
  16. import sys
  17. import zipfile
  18. from pathlib import Path
  19. from xml.etree import ElementTree as ET
  20. # Add parent directory to path for imports
  21. sys.path.insert(0, str(Path(__file__).parent.parent))
  22. from sqlalchemy import select
  23. from backend.app.core.config import settings
  24. from backend.app.core.database import async_session
  25. from backend.app.models.archive import PrintArchive
  26. def extract_object_count_from_3mf(file_path: Path) -> int | None:
  27. """Extract the number of printable objects from a 3MF file.
  28. Returns the count of non-skipped objects, or None if parsing fails.
  29. """
  30. try:
  31. with zipfile.ZipFile(file_path, "r") as zf:
  32. if "Metadata/slice_info.config" not in zf.namelist():
  33. return None
  34. content = zf.read("Metadata/slice_info.config").decode()
  35. root = ET.fromstring(content)
  36. # Find the plate (use first plate)
  37. plate = root.find(".//plate")
  38. if plate is None:
  39. return None
  40. # Count non-skipped objects
  41. count = 0
  42. for obj in plate.findall("object"):
  43. skipped = obj.get("skipped", "false")
  44. if skipped.lower() != "true":
  45. count += 1
  46. return count if count > 0 else None
  47. except Exception as e:
  48. print(f" Error parsing {file_path.name}: {e}")
  49. return None
  50. async def update_archive_quantities(dry_run: bool = False):
  51. """Update quantity field on archives based on 3MF object count."""
  52. print("=" * 60)
  53. print("Archive Quantity Updater")
  54. print("=" * 60)
  55. print()
  56. if dry_run:
  57. print("DRY RUN MODE - No changes will be made")
  58. print()
  59. async with async_session() as db:
  60. # Get all archives with quantity=1 (the default)
  61. result = await db.execute(select(PrintArchive).where(PrintArchive.quantity == 1))
  62. archives = result.scalars().all()
  63. print(f"Found {len(archives)} archives with quantity=1")
  64. print()
  65. updated = 0
  66. skipped = 0
  67. errors = 0
  68. for archive in archives:
  69. # Skip if no file path
  70. if not archive.file_path:
  71. skipped += 1
  72. continue
  73. file_path = settings.base_dir / archive.file_path
  74. # Skip if file doesn't exist
  75. if not file_path.exists():
  76. print(f" [{archive.id}] File not found: {archive.file_path}")
  77. skipped += 1
  78. continue
  79. # Extract object count
  80. object_count = extract_object_count_from_3mf(file_path)
  81. if object_count is None:
  82. skipped += 1
  83. continue
  84. if object_count == 1:
  85. # No change needed
  86. skipped += 1
  87. continue
  88. # Update the archive
  89. print(f" [{archive.id}] {archive.print_name}: 1 -> {object_count} parts")
  90. if not dry_run:
  91. archive.quantity = object_count
  92. updated += 1
  93. else:
  94. updated += 1
  95. if not dry_run:
  96. await db.commit()
  97. print()
  98. print("-" * 60)
  99. print(f"Updated: {updated}")
  100. print(f"Skipped: {skipped} (no change needed or file not found)")
  101. print(f"Errors: {errors}")
  102. print()
  103. if dry_run and updated > 0:
  104. print("Run without --dry-run to apply these changes.")
  105. def main():
  106. parser = argparse.ArgumentParser(description="Update archive quantities from 3MF files")
  107. parser.add_argument(
  108. "--dry-run",
  109. action="store_true",
  110. help="Show what would be updated without making changes",
  111. )
  112. args = parser.parse_args()
  113. asyncio.run(update_archive_quantities(dry_run=args.dry_run))
  114. if __name__ == "__main__":
  115. main()