From af7874546a639d3237b193aa3a04e53cdbbf3d50 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 28 Jul 2026 12:46:31 +0200 Subject: [PATCH] feat(projects): per-file print progress and complete-sets tracking (#1897) Projects made of many distinct files that each need N prints (e.g. 13 plates x 10 sets = 130 prints) only had aggregate progress. Finding out "how many times have I printed plate_7?" meant reading the Activity Timeline line by line, unusable at 130 events. Projects now take an optional Copies per File target. Every printable file in the project's linked folders shows an X / N badge with a mini progress bar (gray not started, amber in progress, green done), and the progress card gains a Complete Sets bar - the minimum per-file count, i.e. how many finished assemblies can be shipped right now. Without the target, printable files show a plain printed-count badge. Counting matches the aggregate project stats: completed runs only, served by a new /projects/{id}/file-progress endpoint. Runs attribute to a file via a new library_file_id stamp on queue-dispatched archives, falling back to content hash and then filename for historical rows. Also fixed: files queued from a project-linked File Manager folder now inherit that project, so their prints count toward project statistics - previously only prints started from the project page were attributed. Test-harness fix along the way: the test suite's get_db override never committed, unlike production get_db, so endpoints relying on the request-scoped commit silently lost their writes in tests. The override now mirrors production commit/rollback semantics. --- CHANGELOG.md | 1 + backend/app/api/routes/library.py | 13 + backend/app/api/routes/projects.py | 90 +++++++ backend/app/core/database.py | 11 + backend/app/models/archive.py | 6 + backend/app/models/project.py | 3 + backend/app/schemas/project.py | 13 + backend/app/services/archive.py | 4 + backend/app/services/print_scheduler.py | 1 + backend/tests/conftest.py | 11 +- .../tests/integration/test_projects_api.py | 224 ++++++++++++++++++ .../unit/test_scheduler_cleanup_library.py | 11 +- frontend/src/__tests__/mocks/handlers.ts | 2 + .../pages/ProjectDetailPage.test.tsx | 82 +++++++ frontend/src/api/client.ts | 15 ++ frontend/src/i18n/locales/de.ts | 8 + frontend/src/i18n/locales/en.ts | 8 + frontend/src/i18n/locales/es.ts | 8 + frontend/src/i18n/locales/fr.ts | 8 + frontend/src/i18n/locales/it.ts | 8 + frontend/src/i18n/locales/ja.ts | 8 + frontend/src/i18n/locales/ko.ts | 8 + frontend/src/i18n/locales/pt-BR.ts | 8 + frontend/src/i18n/locales/ru.ts | 8 + frontend/src/i18n/locales/tr.ts | 8 + frontend/src/i18n/locales/zh-CN.ts | 8 + frontend/src/i18n/locales/zh-TW.ts | 8 + frontend/src/pages/ProjectDetailPage.tsx | 81 ++++++- frontend/src/pages/ProjectsPage.tsx | 19 ++ .../{index-CO7zRvej.js => index-DLc2EliX.js} | 164 ++++++------- static/index.html | 2 +- 31 files changed, 763 insertions(+), 86 deletions(-) rename static/assets/{index-CO7zRvej.js => index-DLc2EliX.js} (83%) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0944eebe..34a4ba168 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [1.2.6b1] - Unreleased ### Added +- **Per-file print progress inside a project (#1897, reporter @FedericoPuntelli)** — Projects that consist of many distinct files each needing N prints (e.g. 13 plates × 10 sets = 130 prints) only had aggregate progress; finding out "how many times have I printed plate_7?" meant reading the Activity Timeline line by line. Projects now take an optional **Copies per File** target: each printable file in the project's linked folders shows an **X / N** badge with a mini progress bar (gray not started, amber in progress, green target reached), and the progress card gains a **Complete Sets** bar — the minimum per-file count, i.e. how many finished assemblies you can ship right now. Without the new target, files simply show a printed-count badge (3×). Counting matches the aggregate stats: only completed runs, attributed to a file by a new `library_file_id` stamp on queue-dispatched archives, with content-hash and filename fallbacks covering historical prints. Also fixed along the way: **files queued from a project-linked File Manager folder now attribute their prints to that project** — previously only prints started from the project page counted toward project statistics. Translated in all locales; wiki updated. Covered by backend and frontend tests. - **Users can now delete empty folders in the File Manager (#1781, reporter @cadtoolbox)** — Library folders have no ownership tracking, so folder deletion was gated entirely behind `library:delete_all` — a regular user with `library:delete_own` could create folders and delete their own files, but the emptied folder sat there until an admin removed it. Users with `library:delete_own` can now delete folders that are truly empty: no subfolders, no files — including trashed ones, since deleting a folder would silently drop another user's trash-restorable files. External folders (operator-configured mounts) and folders linked to a project or archive still require `library:delete_all`, even when empty. The folder tree's Delete entry enables accordingly, with a "You can only delete empty folders" tooltip on non-empty ones; the bulk-delete API applies the same rule. Translated in all locales; wiki updated. Covered by backend and frontend tests. - **AI failure detection is now visible on the printer cards (#1546, reporter @Jeff-GebhartCA)** — Previously the live Obico classification (safe / warning / failure, smoothed score) was only visible under Settings → Failure Detection, so tracking how detection matched an ongoing print meant flipping between the Printers screen and Settings. Each printer card's badge row now shows an AI badge whenever detection is enabled for that printer, like the other health badges: gray **Idle** while no print is being watched, then green **Safe**, amber **Warning**, or red **Failure** while a print is actively monitored. The tooltip carries the current score, and clicking opens a modal (like the HMS error badge) with the live status, score, frames analyzed, and the detection service's last error — plus a shortcut to the full settings. Toggling detection on or off updates the cards immediately. Printers excluded from monitoring and setups without failure detection show nothing. Served by a new lightweight `/obico/printer-status` endpoint readable with printer permissions alone (the existing settings-gated endpoint is unchanged and keeps configuration private). Translated in all locales; wiki updated. Covered by backend and frontend tests. - **Bark is now a notification provider (#1495)** — [Bark](https://github.com/Finb/Bark) is the open-source, account-free iOS push app (self-hostable via bark-server), popular especially with Chinese-speaking users. Configure it with just the device key from the app; the server URL defaults to the official `api.day.app` relay and accepts a self-hosted instance. Optional settings: notification **Group**, **Sound**, and iOS **Interruption Level** — Time Sensitive breaks through scheduled summaries, Critical bypasses Silent mode and Focus (useful for print-failure alerts), Passive delivers silently. Send failures wrapped in an HTTP 200 body by bark-server are detected and reported properly. Translated in all locales; wiki updated. Covered by backend and frontend tests. diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 1c2e6cc2f..28bf46b81 100644 --- a/backend/app/api/routes/library.py +++ b/backend/app/api/routes/library.py @@ -2659,6 +2659,17 @@ async def add_files_to_queue( result = await db.execute(LibraryFile.active().where(LibraryFile.id.in_(request.file_ids))) files = {f.id: f for f in result.scalars().all()} + # Project attribution (#1897): a file queued from a project-linked folder + # inherits that project, so the resulting archive counts toward the + # project's progress. A file's own project link wins over its folder's. + folder_ids = {f.folder_id for f in files.values() if f.folder_id is not None} + folder_projects: dict[int, int | None] = {} + if folder_ids: + folder_result = await db.execute( + select(LibraryFolder.id, LibraryFolder.project_id).where(LibraryFolder.id.in_(folder_ids)) + ) + folder_projects = dict(folder_result.all()) + # Get max position for queue ordering pos_result = await db.execute(select(func.coalesce(func.max(PrintQueueItem.position), 0))) max_position = pos_result.scalar() or 0 @@ -2696,6 +2707,8 @@ async def add_files_to_queue( queue_item = PrintQueueItem( printer_id=None, # Unassigned library_file_id=file_id, + project_id=lib_file.project_id + or (folder_projects.get(lib_file.folder_id) if lib_file.folder_id is not None else None), position=max_position, status="pending", ) diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py index 9cac1dcac..2ca64b14e 100644 --- a/backend/app/api/routes/projects.py +++ b/backend/app/api/routes/projects.py @@ -34,6 +34,7 @@ from backend.app.schemas.project import ( BOMItemUpdate, ProjectChildPreview, ProjectCreate, + ProjectFileProgress, ProjectImport, ProjectListResponse, ProjectResponse, @@ -262,6 +263,7 @@ async def list_projects( status=project.status, target_count=project.target_count, target_parts_count=project.target_parts_count, + target_sets=project.target_sets, budget=project.budget, tags=project.tags, due_date=project.due_date, @@ -304,6 +306,7 @@ async def create_project( color=data.color, target_count=data.target_count, target_parts_count=data.target_parts_count, + target_sets=data.target_sets, notes=data.notes, tags=data.tags, due_date=data.due_date, @@ -326,6 +329,7 @@ async def create_project( status=project.status, target_count=project.target_count, target_parts_count=project.target_parts_count, + target_sets=project.target_sets, notes=project.notes, attachments=project.attachments, url=project.url, @@ -374,6 +378,7 @@ async def list_templates( status=project.status, target_count=project.target_count, target_parts_count=project.target_parts_count, + target_sets=project.target_sets, budget=project.budget, tags=project.tags, due_date=project.due_date, @@ -415,6 +420,7 @@ async def create_project_from_template( color=template.color, target_count=template.target_count, target_parts_count=template.target_parts_count, + target_sets=template.target_sets, notes=template.notes, tags=template.tags, priority=template.priority, @@ -457,6 +463,7 @@ async def create_project_from_template( status=project.status, target_count=project.target_count, target_parts_count=project.target_parts_count, + target_sets=project.target_sets, notes=project.notes, attachments=project.attachments, url=project.url, @@ -542,6 +549,7 @@ async def get_project( status=project.status, target_count=project.target_count, target_parts_count=project.target_parts_count, + target_sets=project.target_sets, notes=project.notes, attachments=project.attachments, url=project.url, @@ -590,6 +598,10 @@ async def update_project( project.target_count = data.target_count if data.target_parts_count is not None: project.target_parts_count = data.target_parts_count + # Sent-but-null clears the copies-per-file target (#1897); omitted leaves it + # alone (same #2536 semantics as tags/due_date below). + if "target_sets" in data.model_fields_set: + project.target_sets = data.target_sets if data.notes is not None: project.notes = data.notes # Sent-but-null clears the field; omitted leaves it alone. Guarding on @@ -642,6 +654,7 @@ async def update_project( status=project.status, target_count=project.target_count, target_parts_count=project.target_parts_count, + target_sets=project.target_sets, notes=project.notes, attachments=project.attachments, url=project.url, @@ -740,6 +753,76 @@ async def list_project_queue( return items +@router.get("/{project_id}/file-progress", response_model=list[ProjectFileProgress]) +async def get_project_file_progress( + project_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ), +): + """Completed-run counts per library file inside a project (#1897). + + Counts completed ``PrintLogEntry`` rows (same source as the aggregate + project stats) of archives attributed to this project, and maps each run to + one of the project's library files — the files living in folders linked to + the project, the same set the project detail page renders. + + A run is attributed to exactly one file, by the strongest available match: + 1. ``archive.library_file_id`` (stamped at queue dispatch since #1897), + 2. content hash (covers historical rows), + 3. filename (covers hash drift, e.g. re-sliced uploads of the same name). + Files with no completed runs are omitted — the frontend treats absence as 0. + """ + result = await db.execute(select(Project.id).where(Project.id == project_id)) + if result.scalar_one_or_none() is None: + raise HTTPException(status_code=404, detail="Project not found") + + files_result = await db.execute( + select(LibraryFile.id, LibraryFile.file_hash, LibraryFile.filename) + .join(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id) + .where(LibraryFolder.project_id == project_id, LibraryFile.deleted_at.is_(None)) + ) + file_rows = files_result.all() + if not file_rows: + return [] + + # First match wins within each tier, so iteration order (file id) is stable + # when duplicates share a hash or filename. + by_id = {fid for fid, _, _ in file_rows} + by_hash: dict[str, int] = {} + by_name: dict[str, int] = {} + for fid, fhash, fname in file_rows: + if fhash and fhash not in by_hash: + by_hash[fhash] = fid + if fname not in by_name: + by_name[fname] = fid + + runs_result = await db.execute( + select( + PrintArchive.library_file_id, + PrintArchive.content_hash, + PrintArchive.filename, + func.count(PrintLogEntry.id), + ) + .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id) + .where(PrintArchive.project_id == project_id, PrintLogEntry.status == "completed") + .group_by(PrintArchive.library_file_id, PrintArchive.content_hash, PrintArchive.filename) + ) + + counts: dict[int, int] = {} + for lib_file_id, content_hash, filename, run_count in runs_result.all(): + if lib_file_id in by_id: + fid = lib_file_id + elif content_hash and content_hash in by_hash: + fid = by_hash[content_hash] + elif filename in by_name: + fid = by_name[filename] + else: + continue + counts[fid] = counts.get(fid, 0) + run_count + + return [ProjectFileProgress(file_id=fid, completed_count=n) for fid, n in sorted(counts.items())] + + @router.post("/{project_id}/add-archives") async def add_archives_to_project( project_id: int, @@ -1402,6 +1485,7 @@ async def create_template_from_project( color=source.color, target_count=source.target_count, target_parts_count=source.target_parts_count, + target_sets=source.target_sets, notes=source.notes, tags=source.tags, priority=source.priority, @@ -1444,6 +1528,7 @@ async def create_template_from_project( status=template.status, target_count=template.target_count, target_parts_count=template.target_parts_count, + target_sets=template.target_sets, notes=template.notes, attachments=template.attachments, url=template.url, @@ -1653,6 +1738,7 @@ async def export_project( "status": project.status, "target_count": project.target_count, "target_parts_count": project.target_parts_count, + "target_sets": project.target_sets, "notes": project.notes, "tags": project.tags, "due_date": project.due_date.isoformat() if project.due_date else None, @@ -1704,6 +1790,7 @@ async def import_project( status=data.status, target_count=data.target_count, target_parts_count=data.target_parts_count, + target_sets=data.target_sets, notes=data.notes, tags=data.tags, due_date=data.due_date, @@ -1766,6 +1853,7 @@ async def import_project( status=project.status, target_count=project.target_count, target_parts_count=project.target_parts_count, + target_sets=project.target_sets, notes=project.notes, attachments=project.attachments, url=project.url, @@ -1829,6 +1917,7 @@ async def import_project_file( status=data.get("status", "active"), target_count=data.get("target_count"), target_parts_count=data.get("target_parts_count"), + target_sets=data.get("target_sets"), notes=data.get("notes"), tags=data.get("tags"), due_date=datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None, @@ -1957,6 +2046,7 @@ async def import_project_file( status=project.status, target_count=project.target_count, target_parts_count=project.target_parts_count, + target_sets=project.target_sets, notes=project.notes, attachments=project.attachments, url=project.url, diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 7c0f336da..c5f092c13 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -3787,6 +3787,17 @@ async def run_migrations(conn): # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``. await _migrate_rename_user_print_template_names(conn) + # Migration: per-file print progress inside a project (#1897). + # - print_archives.library_file_id: which library file a queued run was + # dispatched from; nullable, no FK constraint added to existing tables + # (SQLite can't ADD CONSTRAINT; the application uses SET NULL semantics + # via the ORM on fresh installs and tolerates dangling ids by matching + # hash/filename as fallback anyway). + # - projects.target_sets: optional copies-per-file target. INTEGER is + # spelled identically on SQLite and Postgres — no dialect branch. + await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN library_file_id INTEGER") + await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_sets INTEGER") + _USER_PRINT_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = ( ("user_print_start", "User Print Started", "User Print Started Email"), diff --git a/backend/app/models/archive.py b/backend/app/models/archive.py index 462d23c90..099cd21db 100644 --- a/backend/app/models/archive.py +++ b/backend/app/models/archive.py @@ -12,6 +12,12 @@ class PrintArchive(Base): id: Mapped[int] = mapped_column(primary_key=True) printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True) project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True) + # Which library file this run was dispatched from (#1897). Set by the queue + # scheduler when it archives a library-file print; older rows are matched by + # content_hash/filename instead. SET NULL so deleting a file keeps history. + library_file_id: Mapped[int | None] = mapped_column( + ForeignKey("library_files.id", ondelete="SET NULL"), nullable=True + ) # File info filename: Mapped[str] = mapped_column(String(255)) diff --git a/backend/app/models/project.py b/backend/app/models/project.py index 7e59f4365..f86867079 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -30,6 +30,9 @@ class Project(Base): target_parts_count: Mapped[int | None] = mapped_column( Integer, nullable=True ) # Optional target number of parts/objects + # Optional copies-per-file target (#1897): every printable file in the + # project's linked folders should be printed this many times ("sets"). + target_sets: Mapped[int | None] = mapped_column(Integer, nullable=True) # Phase 2: Rich text notes (HTML from WYSIWYG editor) notes: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index 2730e655f..0bbbe9ad6 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -26,6 +26,7 @@ class ProjectCreate(BaseModel): color: str | None = None target_count: int | None = None target_parts_count: int | None = None + target_sets: int | None = None # Copies-per-file target (#1897) notes: str | None = None tags: str | None = None due_date: datetime | None = None @@ -49,6 +50,7 @@ class ProjectUpdate(BaseModel): status: str | None = None # active, completed, archived target_count: int | None = None target_parts_count: int | None = None + target_sets: int | None = None # Copies-per-file target (#1897) notes: str | None = None tags: str | None = None due_date: datetime | None = None @@ -108,6 +110,7 @@ class ProjectResponse(BaseModel): status: str target_count: int | None target_parts_count: int | None = None + target_sets: int | None = None # Copies-per-file target (#1897) notes: str | None = None attachments: list | None = None tags: str | None = None @@ -129,6 +132,13 @@ class ProjectResponse(BaseModel): from_attributes = True +class ProjectFileProgress(BaseModel): + """Completed-run count for one library file inside a project (#1897).""" + + file_id: int + completed_count: int + + class ArchivePreview(BaseModel): """Minimal archive data for project preview.""" @@ -150,6 +160,7 @@ class ProjectListResponse(BaseModel): status: str target_count: int | None target_parts_count: int | None = None + target_sets: int | None = None # Copies-per-file target (#1897); the shared edit dialog needs it budget: float | None = None # The edit dialog is shared with the project detail page and seeds its fields # from whichever project object it is handed, so the list payload has to carry @@ -276,6 +287,7 @@ class ProjectExport(BaseModel): status: str target_count: int | None target_parts_count: int | None + target_sets: int | None = None notes: str | None tags: str | None due_date: datetime | None @@ -294,6 +306,7 @@ class ProjectImport(BaseModel): status: str = "active" target_count: int | None = None target_parts_count: int | None = None + target_sets: int | None = None notes: str | None = None tags: str | None = None due_date: datetime | None = None diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index fdd6880d2..1b36c0078 100644 --- a/backend/app/services/archive.py +++ b/backend/app/services/archive.py @@ -1143,6 +1143,7 @@ class ArchiveService: subtask_id: str | None = None, prefer_filename_for_name: bool = False, plate_id: int | None = None, + library_file_id: int | None = None, ) -> PrintArchive | None: """Archive a 3MF file with metadata. @@ -1155,6 +1156,8 @@ class ArchiveService: stored with UUID names) project_id: Project to associate this archive with (optional, set when triggered from the project view) + library_file_id: Library file this run was dispatched from (optional, + set by the queue scheduler — powers per-file project progress, #1897) subtask_id: MQTT-provided task identifier (optional). Used to match an existing archive across a backend restart mid-print so the original row can be resumed instead of cancelled (#972). @@ -1314,6 +1317,7 @@ class ArchiveService: extra_data=metadata, created_by_id=created_by_id, project_id=project_id, + library_file_id=library_file_id, subtask_id=subtask_id, plate_id=plate_id, ) diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 9403f15ff..209837540 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -3122,6 +3122,7 @@ class PrintScheduler: original_filename=filename, created_by_id=item.created_by_id, project_id=item.project_id, + library_file_id=item.library_file_id, # per-file project progress (#1897) plate_id=item.plate_id, # selected plate → Print History (#2603) ) if archive: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index e38ee9124..82dd62fa8 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -203,8 +203,17 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N test_async_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) async def override_get_db(): + # Mirror production get_db (core/database.py): commit on success, + # rollback on error. Endpoints that rely on the request-scoped + # implicit commit (e.g. create_project, which only flushes) would + # otherwise silently lose their writes in tests (#1897). async with test_async_session() as session: - yield session + try: + yield session + await session.commit() + except BaseException: + await session.rollback() + raise app.dependency_overrides[get_db] = override_get_db diff --git a/backend/tests/integration/test_projects_api.py b/backend/tests/integration/test_projects_api.py index b5dae483a..bc0bdc751 100644 --- a/backend/tests/integration/test_projects_api.py +++ b/backend/tests/integration/test_projects_api.py @@ -1393,3 +1393,227 @@ class TestProjectListEditableFields: result = response.json() assert result["tags"] is None assert result["due_date"] is None + + +class TestProjectFileProgress: + """Per-file print progress inside a project (#1897). + + Covers GET /projects/{id}/file-progress (attribution: library_file_id → + content hash → filename, completed runs only, project-scoped), the + target_sets field round-trip, and the add-to-queue project inheritance + that feeds the attribution chain. + """ + + @pytest.fixture + async def project_factory(self, db_session): + _counter = [0] + + async def _create_project(**kwargs): + from backend.app.models.project import Project + + _counter[0] += 1 + defaults = {"name": f"Progress Project {_counter[0]}"} + 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 folder_factory(self, db_session): + _counter = [0] + + async def _create_folder(**kwargs): + from backend.app.models.library import LibraryFolder + + _counter[0] += 1 + defaults = {"name": f"ProgressFolder {_counter[0]}"} + defaults.update(kwargs) + folder = LibraryFolder(**defaults) + db_session.add(folder) + await db_session.commit() + await db_session.refresh(folder) + return folder + + return _create_folder + + @pytest.fixture + async def file_factory(self, db_session): + _counter = [0] + + async def _create_file(**kwargs): + from backend.app.models.library import LibraryFile + + _counter[0] += 1 + counter = _counter[0] + defaults = { + "filename": f"plate_{counter}.gcode.3mf", + "file_path": f"library/plate_{counter}.gcode.3mf", + "file_size": 1024, + "file_type": "3mf", + } + defaults.update(kwargs) + lib_file = LibraryFile(**defaults) + db_session.add(lib_file) + await db_session.commit() + await db_session.refresh(lib_file) + return lib_file + + return _create_file + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_counts_by_library_file_id( + self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory + ): + """Runs stamped with library_file_id count toward that file even when + the archive's filename differs (rename after dispatch).""" + project = await project_factory() + folder = await folder_factory(project_id=project.id) + file_a = await file_factory(folder_id=folder.id) + file_b = await file_factory(folder_id=folder.id) + printer = await printer_factory() + + for _ in range(2): + await archive_factory( + printer.id, + project_id=project.id, + library_file_id=file_a.id, + filename="renamed_on_dispatch.gcode.3mf", + ) + await archive_factory(printer.id, project_id=project.id, library_file_id=file_b.id) + + response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress") + assert response.status_code == 200 + counts = {row["file_id"]: row["completed_count"] for row in response.json()} + assert counts == {file_a.id: 2, file_b.id: 1} + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_hash_and_filename_fallback( + self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory + ): + """Historical archives without library_file_id match by content hash, + then by filename.""" + project = await project_factory() + folder = await folder_factory(project_id=project.id) + hashed_file = await file_factory(folder_id=folder.id, file_hash="a" * 64) + named_file = await file_factory(folder_id=folder.id, filename="unique_name.gcode.3mf") + printer = await printer_factory() + + # Hash match despite a different filename + await archive_factory( + printer.id, project_id=project.id, content_hash="a" * 64, filename="printer_copy.gcode.3mf" + ) + # Filename match with no hash on either side + await archive_factory(printer.id, project_id=project.id, filename="unique_name.gcode.3mf") + + response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress") + counts = {row["file_id"]: row["completed_count"] for row in response.json()} + assert counts == {hashed_file.id: 1, named_file.id: 1} + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_only_completed_runs_count( + self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory + ): + """Failed runs and never-printed archives do not advance the count.""" + project = await project_factory() + folder = await folder_factory(project_id=project.id) + lib_file = await file_factory(folder_id=folder.id) + printer = await printer_factory() + + await archive_factory(printer.id, project_id=project.id, library_file_id=lib_file.id) + await archive_factory( + printer.id, project_id=project.id, library_file_id=lib_file.id, status="failed", run_status="failed" + ) + await archive_factory(printer.id, project_id=project.id, library_file_id=lib_file.id, with_run=False) + + response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress") + counts = {row["file_id"]: row["completed_count"] for row in response.json()} + assert counts == {lib_file.id: 1} + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_scoped_to_project( + self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory + ): + """Runs of the same file outside the project (no project / another + project) are excluded.""" + project = await project_factory() + other_project = await project_factory() + folder = await folder_factory(project_id=project.id) + lib_file = await file_factory(folder_id=folder.id) + printer = await printer_factory() + + await archive_factory(printer.id, project_id=None, library_file_id=lib_file.id) + await archive_factory(printer.id, project_id=other_project.id, library_file_id=lib_file.id) + + response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress") + assert response.json() == [] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_unknown_project_404(self, async_client: AsyncClient): + response = await async_client.get("/api/v1/projects/999999/file-progress") + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_target_sets_roundtrip(self, async_client: AsyncClient): + """target_sets survives create, update, and explicit-null clearing.""" + create = await async_client.post("/api/v1/projects/", json={"name": "Sets Project", "target_sets": 10}) + assert create.status_code == 200 + project = create.json() + assert project["target_sets"] == 10 + + update = await async_client.patch(f"/api/v1/projects/{project['id']}", json={"target_sets": 4}) + assert update.status_code == 200, update.json() + assert update.json()["target_sets"] == 4 + + cleared = await async_client.patch(f"/api/v1/projects/{project['id']}", json={"target_sets": None}) + assert cleared.json()["target_sets"] is None + + untouched = await async_client.patch(f"/api/v1/projects/{project['id']}", json={"name": "Renamed"}) + assert untouched.json()["target_sets"] is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_add_to_queue_inherits_folder_project( + self, async_client: AsyncClient, project_factory, folder_factory, file_factory, db_session, tmp_path + ): + """Queueing a file from a project-linked folder attributes the queue + item (and thus the later archive) to that project; a root file stays + unattributed.""" + from sqlalchemy import select + + from backend.app.models.print_queue import PrintQueueItem + + project = await project_factory() + folder = await folder_factory(project_id=project.id) + + on_disk = tmp_path / "linked.gcode.3mf" + on_disk.write_bytes(b"fake sliced content") + linked_file = await file_factory(folder_id=folder.id, file_path=str(on_disk)) + + root_disk = tmp_path / "root.gcode.3mf" + root_disk.write_bytes(b"fake sliced content") + root_file = await file_factory(folder_id=None, file_path=str(root_disk)) + + response = await async_client.post( + "/api/v1/library/files/add-to-queue", json={"file_ids": [linked_file.id, root_file.id]} + ) + assert response.status_code == 200 + assert len(response.json()["added"]) == 2 + + result = await db_session.execute( + select(PrintQueueItem.library_file_id, PrintQueueItem.project_id).where( + PrintQueueItem.library_file_id.in_([linked_file.id, root_file.id]) + ) + ) + projects_by_file = dict(result.all()) + assert projects_by_file[linked_file.id] == project.id + assert projects_by_file[root_file.id] is None diff --git a/backend/tests/unit/test_scheduler_cleanup_library.py b/backend/tests/unit/test_scheduler_cleanup_library.py index 74ae45e4b..1ab41e8ed 100644 --- a/backend/tests/unit/test_scheduler_cleanup_library.py +++ b/backend/tests/unit/test_scheduler_cleanup_library.py @@ -111,7 +111,15 @@ async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effe scheduler = PrintScheduler() async def archive_print( - self, *, printer_id, source_file, original_filename, created_by_id=None, project_id=None, plate_id=None + self, + *, + printer_id, + source_file, + original_filename, + created_by_id=None, + project_id=None, + plate_id=None, + library_file_id=None, ): if archive_failure: raise RuntimeError("archive copy failed") @@ -132,6 +140,7 @@ async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effe print_time_seconds=120, status="completed", project_id=project_id, + library_file_id=library_file_id, created_by_id=created_by_id, ) self.db.add(archive) diff --git a/frontend/src/__tests__/mocks/handlers.ts b/frontend/src/__tests__/mocks/handlers.ts index f0ed3064a..01babb0ec 100644 --- a/frontend/src/__tests__/mocks/handlers.ts +++ b/frontend/src/__tests__/mocks/handlers.ts @@ -538,6 +538,8 @@ export const handlers = [ http.get('/api/v1/obico/printer-status', () => HttpResponse.json({ enabled: false, monitored_printers: null, per_printer: {}, last_error: null }) ), + // Per-file project print progress (#1897) — empty means "no completed runs" + http.get('/api/v1/projects/:id/file-progress', () => HttpResponse.json([])), http.get('/api/v1/printers/:id/current-print-user', () => HttpResponse.json(null)), http.get('/api/v1/settings/check-ffmpeg', () => HttpResponse.json({ available: false, version: null }) diff --git a/frontend/src/__tests__/pages/ProjectDetailPage.test.tsx b/frontend/src/__tests__/pages/ProjectDetailPage.test.tsx index 80557fdd0..2c613cb35 100644 --- a/frontend/src/__tests__/pages/ProjectDetailPage.test.tsx +++ b/frontend/src/__tests__/pages/ProjectDetailPage.test.tsx @@ -222,4 +222,86 @@ describe('ProjectDetailPage', () => { }); }); }); + describe('per-file print progress (#1897)', () => { + it('shows X / N badges and the Complete Sets bar when target_sets is set', async () => { + server.use( + http.get('/api/v1/projects/:id', () => { + return HttpResponse.json({ ...mockProject, target_sets: 10 }); + }), + http.get('/api/v1/library/files', () => { + return HttpResponse.json([ + makeFile({ id: 5, filename: 'plate_1.gcode.3mf', file_type: '3mf' }), + makeFile({ id: 6, filename: 'plate_2.gcode.3mf', file_type: '3mf' }), + ]); + }), + http.get('/api/v1/projects/:id/file-progress', () => { + return HttpResponse.json([{ file_id: 5, completed_count: 3 }]); + }), + ); + + render(); + + // Per-file badges: 3 / 10 for plate_1, 0 / 10 for the never-printed plate_2 + await waitFor(() => { + expect(screen.getByTitle('3 of 10 completed prints')).toBeInTheDocument(); + }); + expect(screen.getByTitle('0 of 10 completed prints')).toBeInTheDocument(); + + // Complete sets = min across printable files = 0 + expect(screen.getByText('Complete Sets')).toBeInTheDocument(); + expect( + screen.getByText((_, element) => element?.tagName === 'SPAN' && element.textContent === '0 / 10 sets') + ).toBeInTheDocument(); + }); + + it('counts a complete set once every printable file reached the target', async () => { + server.use( + http.get('/api/v1/projects/:id', () => { + return HttpResponse.json({ ...mockProject, target_sets: 2 }); + }), + http.get('/api/v1/library/files', () => { + return HttpResponse.json([ + makeFile({ id: 5, filename: 'plate_1.gcode.3mf', file_type: '3mf' }), + // STL is not printable and must not drag the set count to 0 + makeFile({ id: 8, filename: 'source.stl', file_type: 'stl' }), + makeFile({ id: 6, filename: 'plate_2.gcode.3mf', file_type: '3mf' }), + ]); + }), + http.get('/api/v1/projects/:id/file-progress', () => { + // plate_1 overshot the target; capped at 2 for the set count + return HttpResponse.json([ + { file_id: 5, completed_count: 3 }, + { file_id: 6, completed_count: 2 }, + ]); + }), + ); + + render(); + + await waitFor(() => { + expect( + screen.getByText((_, element) => element?.tagName === 'SPAN' && element.textContent === '2 / 2 sets') + ).toBeInTheDocument(); + }); + }); + + it('shows a plain printed-count badge when no target_sets is set', async () => { + server.use( + http.get('/api/v1/library/files', () => { + return HttpResponse.json([makeFile({ id: 5, filename: 'plate_1.gcode.3mf', file_type: '3mf' })]); + }), + http.get('/api/v1/projects/:id/file-progress', () => { + return HttpResponse.json([{ file_id: 5, completed_count: 4 }]); + }), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('4\u00d7')).toBeInTheDocument(); + }); + // No sets bar without a target + expect(screen.queryByText('Complete Sets')).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index da1a56c7f..b86eed819 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -911,6 +911,7 @@ export interface Project { status: string; // active, completed, archived target_count: number | null; // Target number of plates/print jobs target_parts_count: number | null; // Target number of parts/objects + target_sets: number | null; // Copies-per-file target (#1897) notes: string | null; attachments: ProjectAttachment[] | null; tags: string | null; @@ -936,6 +937,12 @@ export interface ProjectAttachment { uploaded_at: string; } +// Completed-run count for one library file inside a project (#1897) +export interface ProjectFileProgress { + file_id: number; + completed_count: number; +} + export interface ArchivePreview { id: number; print_name: string | null; @@ -953,6 +960,7 @@ export interface ProjectListItem { status: string; target_count: number | null; // Target number of plates/print jobs target_parts_count: number | null; // Target number of parts/objects + target_sets: number | null; // #1897 — the shared edit dialog seeds itself from this budget: number | null; tags: string | null; // #2536 — the shared edit dialog seeds itself from this due_date: string | null; // #2536 @@ -975,6 +983,7 @@ export interface ProjectCreate { color?: string; target_count?: number; target_parts_count?: number; + target_sets?: number; notes?: string; tags?: string; due_date?: string; @@ -991,6 +1000,7 @@ export interface ProjectUpdate { status?: string; target_count?: number; target_parts_count?: number; + target_sets?: number | null; // #1897 — explicit null clears the copies-per-file target notes?: string; tags?: string | null; // #2536 — explicit null clears the tags due_date?: string | null; // #2536 — explicit null clears the due date @@ -1062,6 +1072,7 @@ export interface ProjectExport { status: string; target_count: number | null; target_parts_count: number | null; + target_sets: number | null; // #1897 notes: string | null; tags: string | null; due_date: string | null; @@ -1078,6 +1089,7 @@ export interface ProjectImport { status?: string; target_count?: number; target_parts_count?: number; + target_sets?: number; // #1897 notes?: string; tags?: string; due_date?: string; @@ -5947,6 +5959,9 @@ export const api = { request<{ message: string }>(`/projects/${id}`, { method: 'DELETE' }), getProjectArchives: (id: number, limit = 100, offset = 0) => request(`/projects/${id}/archives?limit=${limit}&offset=${offset}`), + // Completed-run counts per library file (#1897); files with 0 runs are omitted + getProjectFileProgress: (id: number) => + request(`/projects/${id}/file-progress`), addArchivesToProject: (projectId: number, archiveIds: number[]) => request<{ message: string }>(`/projects/${projectId}/add-archives`, { method: 'POST', diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index e2dae278b..a46857102 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -3784,6 +3784,9 @@ export default { targetParts: 'Ziel-Teile', targetPartsPlaceholder: 'z.B. 150', targetPartsHelp: 'Benötigte Objekte insgesamt', + targetSets: 'Kopien pro Datei', + targetSetsPlaceholder: 'z. B. 10', + targetSetsHelp: 'Wie oft jede druckbare Datei gedruckt werden soll', tagsLabel: 'Tags (kommagetrennt)', tagsPlaceholder: 'z.B. voron, funktional, geschenk', dueDate: 'Fälligkeitsdatum', @@ -3859,6 +3862,9 @@ export default { progress: { platesProgress: 'Platten-Fortschritt', partsProgress: 'Teile-Fortschritt', + setsProgress: 'Vollständige Sätze', + sets: 'Sätze', + setsHint: 'Ein Satz ist vollständig, wenn jede druckbare Datei diese Anzahl erreicht hat', printJobs: 'Druckaufträge', parts: 'Teile', percentComplete: '{{percent}}% abgeschlossen', @@ -3897,6 +3903,8 @@ export default { forQuickAccess: 'für schnellen Zugriff auf dieses Projekt.', fileCount: '{{count}} Datei(en)', empty: 'Keine Ordner verknüpft. Gehen Sie zum Dateimanager und verknüpfen Sie einen Ordner mit diesem Projekt.', + printedCount: '{{count}}-mal gedruckt', + progressTooltip: '{{done}} von {{target}} abgeschlossenen Drucken', noFiles: 'Keine Dateien in diesem Ordner.', }, bom: { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 02ecdd3d0..17d62e6ef 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3813,6 +3813,9 @@ export default { targetParts: 'Target Parts', targetPartsPlaceholder: 'e.g., 150', targetPartsHelp: 'Total objects needed', + targetSets: 'Copies per File', + targetSetsPlaceholder: 'e.g., 10', + targetSetsHelp: 'Times each printable file should be printed', tagsLabel: 'Tags (comma-separated)', tagsPlaceholder: 'e.g., voron, functional, gift', dueDate: 'Due Date', @@ -3888,6 +3891,9 @@ export default { progress: { platesProgress: 'Plates Progress', partsProgress: 'Parts Progress', + setsProgress: 'Complete Sets', + sets: 'sets', + setsHint: 'A set is complete once every printable file has reached this count', printJobs: 'print jobs', parts: 'parts', percentComplete: '{{percent}}% complete', @@ -3926,6 +3932,8 @@ export default { forQuickAccess: 'to this project for quick access.', fileCount: '{{count}} file(s)', empty: 'No folders linked. Go to File Manager and link a folder to this project.', + printedCount: 'Printed {{count}} time(s)', + progressTooltip: '{{done}} of {{target}} completed prints', noFiles: 'No files in this folder.', }, bom: { diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index eb1052d49..996a7e3f6 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -3787,6 +3787,9 @@ export default { targetParts: 'Piezas objetivo', targetPartsPlaceholder: 'p. ej., 150', targetPartsHelp: 'Total de objetos necesarios', + targetSets: 'Copias por archivo', + targetSetsPlaceholder: 'p. ej., 10', + targetSetsHelp: 'Veces que debe imprimirse cada archivo imprimible', tagsLabel: 'Etiquetas (separadas por comas)', tagsPlaceholder: 'p. ej., voron, funcional, regalo', dueDate: 'Fecha límite', @@ -3862,6 +3865,9 @@ export default { progress: { platesProgress: 'Progreso de camas', partsProgress: 'Progreso de piezas', + setsProgress: 'Juegos completos', + sets: 'juegos', + setsHint: 'Un juego está completo cuando cada archivo imprimible alcanza este número', printJobs: 'trabajos de impresión', parts: 'piezas', percentComplete: '{{percent}}% completado', @@ -3900,6 +3906,8 @@ export default { forQuickAccess: 'a este proyecto para un acceso rápido.', fileCount: '{{count}} archivo(s)', empty: 'No hay carpetas vinculadas. Vaya al gestor de archivos y vincule una carpeta a este proyecto.', + printedCount: 'Impreso {{count}} veces', + progressTooltip: '{{done}} de {{target}} impresiones completadas', noFiles: 'No hay archivos en esta carpeta.', }, bom: { diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index eb8ce1669..14dd344da 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -3773,6 +3773,9 @@ export default { targetParts: 'Pièces cibles', targetPartsPlaceholder: 'ex: 150', targetPartsHelp: 'Nombre total d\'objets', + targetSets: 'Copies par fichier', + targetSetsPlaceholder: 'ex. 10', + targetSetsHelp: 'Nombre d\'impressions de chaque fichier imprimable', tagsLabel: 'Tags (séparés par virgules)', tagsPlaceholder: 'ex: voron, cadeau', dueDate: 'Échéance', @@ -3848,6 +3851,9 @@ export default { progress: { platesProgress: 'Progression Plateaux', partsProgress: 'Progression Pièces', + setsProgress: 'Jeux complets', + sets: 'jeux', + setsHint: 'Un jeu est complet quand chaque fichier imprimable atteint ce nombre', printJobs: 'jobs d\'impression', parts: 'pièces', percentComplete: '{{percent}}% terminé', @@ -3886,6 +3892,8 @@ export default { forQuickAccess: 'pour un accès rapide.', fileCount: '{{count}} fichier(s)', empty: 'Aucun dossier lié.', + printedCount: 'Imprimé {{count}} fois', + progressTooltip: '{{done}} sur {{target}} impressions terminées', noFiles: 'Aucun fichier dans ce dossier.', }, bom: { diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 3aa96bfc7..a813745f4 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -3772,6 +3772,9 @@ export default { targetParts: 'Parti target', targetPartsPlaceholder: 'es., 150', targetPartsHelp: 'Totale oggetti necessari', + targetSets: 'Copie per file', + targetSetsPlaceholder: 'es. 10', + targetSetsHelp: 'Quante volte stampare ogni file stampabile', tagsLabel: 'Tag (separati da virgola)', tagsPlaceholder: 'es., voron, funzionale, regalo', dueDate: 'Data scadenza', @@ -3847,6 +3850,9 @@ export default { progress: { platesProgress: 'Avanzamento piatti', partsProgress: 'Avanzamento parti', + setsProgress: 'Set completi', + sets: 'set', + setsHint: 'Un set è completo quando ogni file stampabile raggiunge questo numero', printJobs: 'job di stampa', parts: 'parti', percentComplete: '{{percent}}% completato', @@ -3885,6 +3891,8 @@ export default { forQuickAccess: 'a questo progetto per accesso rapido.', fileCount: '{{count}} file', empty: 'Nessuna cartella collegata. Vai a Gestore file e collega una cartella a questo progetto.', + printedCount: 'Stampato {{count}} volte', + progressTooltip: '{{done}} di {{target}} stampe completate', noFiles: 'Nessun file in questa cartella.', }, bom: { diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index f3aa7a41c..29f7caf8f 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -3784,6 +3784,9 @@ export default { targetParts: '目標パーツ数', targetPartsPlaceholder: '例: 50', targetPartsHelp: '必要なオブジェクトの総数', + targetSets: 'ファイルごとの部数', + targetSetsPlaceholder: '例: 10', + targetSetsHelp: '各印刷可能ファイルを印刷する回数', tagsLabel: 'タグ(カンマ区切り)', tagsPlaceholder: 'カンマ区切りのタグ', dueDate: '期限', @@ -3859,6 +3862,9 @@ export default { progress: { platesProgress: 'プレート進捗', partsProgress: 'パーツ進捗', + setsProgress: '完成セット', + sets: 'セット', + setsHint: 'すべての印刷可能ファイルがこの回数に達するとセットが完成します', printJobs: '印刷ジョブ', parts: 'パーツ', percentComplete: '{{percent}}% 完了', @@ -3897,6 +3903,8 @@ export default { forQuickAccess: 'してクイックアクセスできるようにします。', fileCount: '{{count}}ファイル', empty: '<空>', + printedCount: '{{count}}回印刷済み', + progressTooltip: '{{target}}回中{{done}}回の印刷が完了', noFiles: 'このフォルダにファイルはありません。', }, bom: { diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index cc12f05f9..b42cc7a84 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -3592,6 +3592,9 @@ export default { targetParts: '목표 부품', targetPartsPlaceholder: '예: 150', targetPartsHelp: '필요한 총 개체 수', + targetSets: '파일당 복사본 수', + targetSetsPlaceholder: '예: 10', + targetSetsHelp: '각 인쇄 가능한 파일을 인쇄할 횟수', tagsLabel: '태그 (쉼표로 구분)', tagsPlaceholder: '예: voron, 기능성, 선물', dueDate: '마감일', @@ -3660,6 +3663,9 @@ export default { progress: { platesProgress: '플레이트 진행률', partsProgress: '부품 진행률', + setsProgress: '완성된 세트', + sets: '세트', + setsHint: '모든 인쇄 가능한 파일이 이 횟수에 도달하면 세트가 완성됩니다', printJobs: '인쇄 작업', parts: '부품', percentComplete: '{{percent}}% 완료', @@ -3698,6 +3704,8 @@ export default { forQuickAccess: '빠른 접근을 위해 이 프로젝트에 연결합니다.', fileCount: '{{count}}개 파일', empty: '연결된 폴더가 없습니다. 파일 관리자로 이동하여 폴더를 이 프로젝트에 연결하세요.', + printedCount: '{{count}}회 인쇄됨', + progressTooltip: '{{target}}회 중 {{done}}회 인쇄 완료', noFiles: '이 폴더에 파일이 없습니다.' }, bom: { diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 484b8034a..d90a096b5 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -3772,6 +3772,9 @@ export default { targetParts: 'Peças Alvo', targetPartsPlaceholder: 'ex., 150', targetPartsHelp: 'Total de objetos necessários', + targetSets: 'Cópias por arquivo', + targetSetsPlaceholder: 'ex.: 10', + targetSetsHelp: 'Quantas vezes cada arquivo imprimível deve ser impresso', tagsLabel: 'Tags (separadas por vírgula)', tagsPlaceholder: 'ex., voron, funcional, presente', dueDate: 'Data de Vencimento', @@ -3847,6 +3850,9 @@ export default { progress: { platesProgress: 'Progresso das Placas', partsProgress: 'Progresso das Peças', + setsProgress: 'Conjuntos completos', + sets: 'conjuntos', + setsHint: 'Um conjunto está completo quando cada arquivo imprimível atinge esse número', printJobs: 'Trabalhos de Impressão', parts: 'Peças', percentComplete: '{{percent}}% concluído', @@ -3885,6 +3891,8 @@ export default { forQuickAccess: 'a este projeto para acesso rápido.', fileCount: '{{count}} arquivo(s)', empty: 'Nenhuma pasta vinculada. Vá para o Gerenciador de Arquivos e vincule uma pasta a este projeto.', + printedCount: 'Impresso {{count}} vez(es)', + progressTooltip: '{{done}} de {{target}} impressões concluídas', noFiles: 'Nenhum arquivo nesta pasta.', }, bom: { diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index 0d4359a08..e0a80d051 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/frontend/src/i18n/locales/ru.ts @@ -3584,6 +3584,9 @@ export default { targetParts: "План по деталям", targetPartsPlaceholder: "например, 150", targetPartsHelp: "Общее требуемое количество объектов", + targetSets: "Копий на файл", + targetSetsPlaceholder: "напр. 10", + targetSetsHelp: "Сколько раз нужно напечатать каждый печатаемый файл", tagsLabel: "Метки (через запятую)", tagsPlaceholder: "например, voron, функциональное, подарок", dueDate: "Срок", @@ -3652,6 +3655,9 @@ export default { progress: { platesProgress: "Выполнение по пластинам", partsProgress: "Выполнение по деталям", + setsProgress: "Полные комплекты", + sets: "комплектов", + setsHint: "Комплект готов, когда каждый печатаемый файл достиг этого количества", printJobs: "заданий печати", parts: "деталей", percentComplete: "Выполнено {{percent}}%", @@ -3690,6 +3696,8 @@ export default { forQuickAccess: "с этим проектом для быстрого доступа.", fileCount: "Файлов: {{count}}", empty: "Связанных папок нет. Откройте файловый менеджер и свяжите папку с проектом.", + printedCount: "Напечатано {{count}} раз", + progressTooltip: "{{done}} из {{target}} завершённых печатей", noFiles: "В этой папке нет файлов.", }, bom: { diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index 644ebc414..3a3eec954 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -3779,6 +3779,9 @@ export default { targetParts: 'Hedef Parçalar', targetPartsPlaceholder: 'örn., 150', targetPartsHelp: 'Gereken toplam nesne', + targetSets: 'Dosya başına kopya', + targetSetsPlaceholder: 'örn. 10', + targetSetsHelp: 'Her yazdırılabilir dosyanın kaç kez yazdırılacağı', tagsLabel: 'Etiketler (virgülle ayrılmış)', tagsPlaceholder: 'örn., voron, fonksiyonel, hediye', dueDate: 'Son Tarih', @@ -3849,6 +3852,9 @@ export default { progress: { platesProgress: 'Plaka İlerlemesi', partsProgress: 'Parça İlerlemesi', + setsProgress: 'Tamamlanan setler', + sets: 'set', + setsHint: 'Her yazdırılabilir dosya bu sayıya ulaştığında bir set tamamlanır', printJobs: 'baskı işi', parts: 'parça', percentComplete: '%{{percent}} tamamlandı', @@ -3887,6 +3893,8 @@ export default { forQuickAccess: 'hızlı erişim için bu projeye.', fileCount: '{{count}} dosya', empty: 'Bağlı klasör yok. Dosya Yöneticisine gidin ve bu projeye bir klasör bağlayın.', + printedCount: '{{count}} kez yazdırıldı', + progressTooltip: '{{target}} yazdırmadan {{done}} tanesi tamamlandı', noFiles: 'Bu klasörde dosya yok.', }, bom: { diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 8c44d3df8..9f4d2ab06 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -3772,6 +3772,9 @@ export default { targetParts: '目标零件数', targetPartsPlaceholder: '例如:150', targetPartsHelp: '所需零件总数', + targetSets: '每个文件的份数', + targetSetsPlaceholder: '例如 10', + targetSetsHelp: '每个可打印文件需要打印的次数', tagsLabel: '标签(逗号分隔)', tagsPlaceholder: '例如:voron、功能件、礼物', dueDate: '截止日期', @@ -3847,6 +3850,9 @@ export default { progress: { platesProgress: '板进度', partsProgress: '零件进度', + setsProgress: '完整套数', + sets: '套', + setsHint: '当每个可打印文件都达到此次数时,即完成一套', printJobs: '打印任务', parts: '零件', percentComplete: '{{percent}}% 完成', @@ -3885,6 +3891,8 @@ export default { forQuickAccess: '到此项目以便快速访问。', fileCount: '{{count}} 个文件', empty: '未链接文件夹。前往文件管理器将文件夹链接到此项目。', + printedCount: '已打印 {{count}} 次', + progressTooltip: '已完成 {{done}}/{{target}} 次打印', noFiles: '此文件夹中没有文件。', }, bom: { diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index da9c55fd3..00af124af 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -3772,6 +3772,9 @@ export default { targetParts: '目標零件數', targetPartsPlaceholder: '例如:150', targetPartsHelp: '所需零件總數', + targetSets: '每個檔案的份數', + targetSetsPlaceholder: '例如 10', + targetSetsHelp: '每個可列印檔案需要列印的次數', tagsLabel: '標籤(逗號分隔)', tagsPlaceholder: '例如:voron、功能件、禮物', dueDate: '截止日期', @@ -3847,6 +3850,9 @@ export default { progress: { platesProgress: '板進度', partsProgress: '零件進度', + setsProgress: '完整套數', + sets: '套', + setsHint: '當每個可列印檔案都達到此次數時,即完成一套', printJobs: '列印任務', parts: '零件', percentComplete: '{{percent}}% 完成', @@ -3885,6 +3891,8 @@ export default { forQuickAccess: '到此項目以便快速存取。', fileCount: '{{count}} 個檔案', empty: '未連結資料夾。前往檔案管理器將資料夾連結到此項目。', + printedCount: '已列印 {{count}} 次', + progressTooltip: '已完成 {{done}}/{{target}} 次列印', noFiles: '此資料夾中沒有檔案。', }, bom: { diff --git a/frontend/src/pages/ProjectDetailPage.tsx b/frontend/src/pages/ProjectDetailPage.tsx index 44268430a..61ae6cc60 100644 --- a/frontend/src/pages/ProjectDetailPage.tsx +++ b/frontend/src/pages/ProjectDetailPage.tsx @@ -268,6 +268,29 @@ export function ProjectDetailPage() { return map; }, [allProjectFiles]); + // Per-file completed-run counts (#1897); a file absent from the response has 0 + const { data: fileProgress } = useQuery({ + queryKey: ['project-file-progress', projectId], + queryFn: () => api.getProjectFileProgress(projectId), + enabled: projectId > 0, + }); + + const progressByFileId = useMemo(() => { + const map = new Map(); + for (const row of fileProgress ?? []) map.set(row.file_id, row.completed_count); + return map; + }, [fileProgress]); + + // Complete sets (#1897): the number of finished assemblies — the minimum + // completed count across the project's printable files, capped at the target. + const completeSets = useMemo(() => { + const target = project?.target_sets; + if (!target || !allProjectFiles) return null; + const printable = allProjectFiles.filter((f) => isSlicedFilename(f.filename)); + if (printable.length === 0) return null; + return Math.min(...printable.map((f) => Math.min(progressByFileId.get(f.id) ?? 0, target))); + }, [project?.target_sets, allProjectFiles, progressByFileId]); + const currency = getCurrencySymbol(settings?.currency || 'USD'); const timeFormat: TimeFormat = settings?.time_format || 'system'; @@ -536,7 +559,7 @@ export function ProjectDetailPage() { {/* Progress bars (if targets set) */} - {(project.target_count || project.target_parts_count) && ( + {(project.target_count || project.target_parts_count || project.target_sets) && ( {/* Plates progress */} @@ -599,6 +622,27 @@ export function ProjectDetailPage() { )} + {/* Complete sets progress (#1897): min per-file completed count */} + {project.target_sets ? ( +
+
+ {t('projectDetail.progress.setsProgress')} + + {completeSets ?? 0} / {project.target_sets} {t('projectDetail.progress.sets')} + +
+
+
= project.target_sets ? '#22c55e' : project.color || '#6b7280', + }} + /> +
+

{t('projectDetail.progress.setsHint')}

+
+ ) : null} )} @@ -953,6 +997,41 @@ export function ProjectDetailPage() {
+ {/* Per-file print progress (#1897) */} + {printable && (() => { + const done = progressByFileId.get(file.id) ?? 0; + const target = project.target_sets; + if (!target) { + // No copies-per-file target — show a plain printed-count badge + return done > 0 ? ( + + {done}× + + ) : null; + } + const pct = Math.min((done / target) * 100, 100); + const textColor = + done >= target ? 'text-status-ok' : done > 0 ? 'text-status-warning' : 'text-bambu-gray'; + const barColor = + done >= target ? 'bg-status-ok' : done > 0 ? 'bg-status-warning' : 'bg-bambu-gray'; + return ( +
+

+ {done} / {target} +

+
+
+
+
+ ); + })()} + {/* Print actions for sliced files */} {printable && (
diff --git a/frontend/src/pages/ProjectsPage.tsx b/frontend/src/pages/ProjectsPage.tsx index afe5d60b0..33bce12df 100644 --- a/frontend/src/pages/ProjectsPage.tsx +++ b/frontend/src/pages/ProjectsPage.tsx @@ -61,6 +61,7 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb 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 [targetSets, setTargetSets] = useState(project?.target_sets?.toString() || ''); const [status, setStatus] = useState(project?.status || 'active'); const [tags, setTags] = useState(project?.tags || ''); const [dueDate, setDueDate] = useState(project?.due_date?.split('T')[0] || ''); @@ -120,6 +121,8 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb color, target_count: targetCount ? parseInt(targetCount, 10) : undefined, target_parts_count: targetPartsCount ? parseInt(targetPartsCount, 10) : undefined, + // Null clears the copies-per-file target on edit (#1897); undefined omits on create. + target_sets: project ? (targetSets ? parseInt(targetSets, 10) : null) : (targetSets ? parseInt(targetSets, 10) : undefined), // Null clears the stored value on edit; undefined omits the key on create. // Sending undefined on edit would make an emptied field un-clearable. tags: project ? (tags.trim() || null) : (tags.trim() || undefined), @@ -299,6 +302,22 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
+ {/* Copies-per-file target (#1897) */} +
+ + setTargetSets(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={t('projects.targetSetsPlaceholder')} + min="1" + /> +

{t('projects.targetSetsHelp')}

+
+ {/* Tags */}