Jelajahi Sumber

fix(queue): GET /queue?status= takes several comma-separated statuses, as documented; remove the archive rescan and project attachment routes that had no UI (cover images keep their storage)

maziggy 2 hari lalu
induk
melakukan
07d99f3383

+ 1 - 0
CHANGELOG.md

@@ -164,6 +164,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **API: `GET /queue?status=` with several statuses returned nothing** — The documented `?status=pending,printing` was matched as one status. Comma-separated statuses are now each matched.
 - **Saving an edited queue item showed "Queue item updated" twice** — The dialog also closed twice, a failed save showed two error toasts, and an edit that also added the job for further printers closed the dialog before those were added. It now reports and closes once, after everything is saved.
 - **A failed Unassign on the printer card said nothing** — Unassigning a spool from an AMS slot (built-in inventory or Spoolman) that the server refused showed no message, and the spool simply stayed assigned. The error is now shown.
 - **The OBS stream overlay ignored live updates** — It read the printer's status updates from the wrong field, so it moved only with its 2-second refresh instead of in real time.

+ 0 - 115
backend/app/api/routes/archives.py

@@ -2162,66 +2162,6 @@ async def _one_print_cost(db: AsyncSession, archive: PrintArchive, previous_gram
     return price_one_print(grams, None, 0.0, 0.0, 0, fallback, default_cost)
 
 
-@router.post("/{archive_id}/rescan", response_model=ArchiveResponse)
-async def rescan_archive(
-    archive_id: int,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
-    printer_scope: PrinterScope = RequestPrinterScope,
-):
-    """Rescan the 3MF file and update metadata."""
-    from backend.app.services.archive import ThreeMFParser
-
-    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive or not printer_scope.allows(archive.printer_id):
-        raise HTTPException(404, "Archive not found")
-
-    file_path = settings.base_dir / archive.file_path
-    if not file_path.is_file():
-        raise HTTPException(404, "Archive file not found")
-
-    # Parse the 3MF file
-    parser = ThreeMFParser(file_path)
-    metadata = parser.parse()
-
-    # Update fields from metadata
-    if metadata.get("filament_type"):
-        archive.filament_type = metadata["filament_type"]
-    if metadata.get("filament_color"):
-        archive.filament_color = metadata["filament_color"]
-    if metadata.get("print_time_seconds"):
-        archive.print_time_seconds = metadata["print_time_seconds"]
-    if metadata.get("filament_used_grams"):
-        archive.filament_used_grams = metadata["filament_used_grams"]
-    if metadata.get("layer_height"):
-        archive.layer_height = metadata["layer_height"]
-    if metadata.get("nozzle_diameter"):
-        archive.nozzle_diameter = metadata["nozzle_diameter"]
-    if metadata.get("bed_temperature"):
-        archive.bed_temperature = metadata["bed_temperature"]
-    if metadata.get("bed_type"):
-        archive.bed_type = metadata["bed_type"]
-    if metadata.get("nozzle_temperature"):
-        archive.nozzle_temperature = metadata["nozzle_temperature"]
-    if metadata.get("makerworld_url"):
-        archive.makerworld_url = metadata["makerworld_url"]
-    if metadata.get("designer"):
-        archive.designer = metadata["designer"]
-
-    # Calculate cost: prefer spool-based cost if available, else catalog-based.
-    # When spool-based costs exist but don't cover every filament gram used
-    # (#1344), fall back to the global default rate for the untracked weight
-    # so the displayed cost still reflects the whole print.
-
-    if archive.filament_used_grams and archive.filament_type:
-        archive.cost = await _one_print_cost(db, archive)
-
-    await db.commit()
-    await db.refresh(archive)
-    return archive
-
-
 @router.post("/recalculate-costs")
 async def recalculate_all_costs(
     db: AsyncSession = Depends(get_db),
@@ -2322,61 +2262,6 @@ async def recalculate_all_costs(
     return {"message": message, "updated": updated, "preserved": preserved}
 
 
-@router.post("/rescan-all")
-async def rescan_all_archives(
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
-    printer_scope: PrinterScope = RequestPrinterScope,
-):
-    """Rescan all archives and update their metadata."""
-    from backend.app.services.archive import ThreeMFParser
-
-    # Only the archives of the caller's printers, as the archive list shows them.
-    query = select(PrintArchive)
-    if (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
-        query = query.where(clause)
-    result = await db.execute(query)
-    archives = list(result.scalars().all())
-
-    updated = 0
-    errors = []
-
-    for archive in archives:
-        try:
-            file_path = settings.base_dir / archive.file_path
-            if not file_path.is_file():
-                errors.append({"id": archive.id, "error": "File not found"})
-                continue
-
-            parser = ThreeMFParser(file_path)
-            metadata = parser.parse()
-
-            if metadata.get("filament_type"):
-                archive.filament_type = metadata["filament_type"]
-            if metadata.get("filament_color"):
-                archive.filament_color = metadata["filament_color"]
-            if metadata.get("print_time_seconds"):
-                archive.print_time_seconds = metadata["print_time_seconds"]
-            if metadata.get("filament_used_grams"):
-                archive.filament_used_grams = metadata["filament_used_grams"]
-            if metadata.get("layer_height"):
-                archive.layer_height = metadata["layer_height"]
-            if metadata.get("nozzle_diameter"):
-                archive.nozzle_diameter = metadata["nozzle_diameter"]
-            if metadata.get("makerworld_url"):
-                archive.makerworld_url = metadata["makerworld_url"]
-            if metadata.get("designer"):
-                archive.designer = metadata["designer"]
-
-            updated += 1
-        except Exception as e:
-            logger.exception("Failed to rescan archive %s: %s", archive.id, e)
-            errors.append({"id": archive.id, "error": "Failed to parse 3MF file"})
-
-    await db.commit()
-    return {"updated": updated, "errors": errors}
-
-
 @router.get("/{archive_id}/duplicates")
 async def get_archive_duplicates(
     archive_id: int,

+ 3 - 1
backend/app/api/routes/print_queue.py

@@ -749,7 +749,9 @@ async def list_queue(
     elif target_model:
         query = query.where(func.lower(PrintQueueItem.target_model) == target_model.lower())
     if status:
-        query = query.where(PrintQueueItem.status == status)
+        # Several statuses, comma-separated (?status=pending,printing).
+        statuses = [part.strip() for part in status.split(",") if part.strip()]
+        query = query.where(PrintQueueItem.status.in_(statuses))
 
     result = await db.execute(query)
     items = result.scalars().all()

+ 4 - 228
backend/app/api/routes/projects.py

@@ -1216,14 +1216,14 @@ async def remove_archives_from_project(
 
 
 def get_project_attachments_dir(project_id: int) -> Path:
-    """Get the attachments directory for a project."""
+    """Directory holding a project's cover image (named for the file
+    attachments it once held as well)."""
     base_dir = Path(settings.archive_dir)
     return base_dir / "projects" / str(project_id) / "attachments"
 
 
-# Cover-image upload accepts only common web-renderable image types (#1155).
-# Subset of ALLOWED_ATTACHMENT_EXTENSIONS minus .svg/.ico because those don't
-# render well as a card thumbnail.
+# Cover-image upload accepts only common web-renderable image types (#1155);
+# .svg/.ico don't render well as a card thumbnail.
 COVER_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
 COVER_IMAGE_CONTENT_TYPES = {
     ".jpg": "image/jpeg",
@@ -1234,230 +1234,6 @@ COVER_IMAGE_CONTENT_TYPES = {
 }
 
 
-# Allowed file extensions for attachments
-ALLOWED_ATTACHMENT_EXTENSIONS = {
-    # Images
-    ".jpg",
-    ".jpeg",
-    ".png",
-    ".gif",
-    ".webp",
-    ".svg",
-    ".bmp",
-    ".ico",
-    # Documents
-    ".pdf",
-    ".doc",
-    ".docx",
-    ".xls",
-    ".xlsx",
-    ".ppt",
-    ".pptx",
-    ".odt",
-    ".ods",
-    ".odp",
-    ".txt",
-    ".rtf",
-    ".csv",
-    ".md",
-    # 3D/CAD files
-    ".stl",
-    ".obj",
-    ".3mf",
-    ".step",
-    ".stp",
-    ".iges",
-    ".igs",
-    ".f3d",
-    ".scad",
-    # Archives
-    ".zip",
-    ".rar",
-    ".7z",
-    ".tar",
-    ".gz",
-    # Code/scripts (for Klipper macros, scripts, etc.)
-    ".py",
-    ".sh",
-    ".cfg",
-    ".conf",
-    ".gcode",
-    ".ini",
-    # Other common formats
-    ".json",
-    ".xml",
-    ".yaml",
-    ".yml",
-}
-
-
-@router.post("/{project_id}/attachments")
-async def upload_attachment(
-    project_id: int,
-    file: UploadFile = File(...),
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
-):
-    """Upload an attachment to a project."""
-    logger.info("=== UPLOAD START: %s for project %s ===", file.filename, project_id)
-
-    # Verify project exists
-    result = await db.execute(select(Project).where(Project.id == project_id))
-    project = result.scalar_one_or_none()
-    if not project:
-        raise HTTPException(status_code=404, detail="Project not found")
-
-    # Validate file extension
-    original_name = file.filename or "unknown"
-    ext = os.path.splitext(original_name)[1].lower()
-    if ext not in ALLOWED_ATTACHMENT_EXTENSIONS:
-        raise HTTPException(
-            status_code=400,
-            detail=f"File type '{ext}' not supported. Allowed: images, PDFs, documents, STL, 3MF, archives.",
-        )
-
-    # Create attachments directory
-    attachments_dir = get_project_attachments_dir(project_id)
-    attachments_dir.mkdir(parents=True, exist_ok=True)
-
-    # Generate unique filename
-    unique_filename = f"{uuid.uuid4().hex}{ext}"
-    file_path = attachments_dir / unique_filename  # SEC-PATH-OK: unique_filename = uuid.uuid4().hex + ext
-
-    # Save file
-    try:
-        with open(file_path, "wb") as f:
-            content = await file.read()
-            f.write(content)
-        logger.info("=== FILE SAVED: %s, size: %s ===", file_path, len(content))
-    except Exception as e:
-        logger.error("Failed to save attachment: %s", e)
-        raise HTTPException(status_code=500, detail="Failed to save attachment")
-
-    # Update project attachments JSON
-    attachments = list(project.attachments or [])
-    new_attachment = {
-        "filename": unique_filename,
-        "original_name": original_name,
-        "size": len(content),
-        "uploaded_at": datetime.now().isoformat(),
-    }
-    attachments.append(new_attachment)
-
-    # Simple ORM update
-    project.attachments = attachments
-    db.add(project)  # Explicitly add to session
-
-    logger.info("=== BEFORE COMMIT: %s attachments ===", len(attachments))
-
-    await db.flush()
-    await db.commit()
-
-    logger.info("=== AFTER COMMIT ===")
-
-    # Verify by re-querying
-    result = await db.execute(select(Project).where(Project.id == project_id))
-    fresh_project = result.scalar_one()
-
-    logger.info("=== VERIFIED: %s attachments ===", len(fresh_project.attachments or []))
-
-    return {
-        "status": "success",
-        "filename": unique_filename,
-        "original_name": original_name,
-        "attachments": fresh_project.attachments,
-    }
-
-
-@router.get("/{project_id}/attachments/{filename}")
-async def download_attachment(
-    project_id: int,
-    filename: str,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
-):
-    """Download an attachment from a project."""
-    # Validate filename to prevent path traversal
-    if "/" in filename or "\\" in filename or ".." in filename or not filename:
-        raise HTTPException(status_code=400, detail="Invalid filename")
-
-    # Verify project exists
-    result = await db.execute(select(Project).where(Project.id == project_id))
-    project = result.scalar_one_or_none()
-    if not project:
-        raise HTTPException(status_code=404, detail="Project not found")
-
-    # Verify attachment exists in project
-    attachments = project.attachments or []
-    attachment = next((a for a in attachments if a.get("filename") == filename), None)
-    if not attachment:
-        raise HTTPException(status_code=404, detail="Attachment not found")
-
-    # Check file exists
-    file_path = (
-        get_project_attachments_dir(project_id) / filename
-    )  # SEC-PATH-OK: filename validated above (no /, \\, .., empty) + attachment membership check
-    if not file_path.exists():
-        raise HTTPException(status_code=404, detail="Attachment file not found")
-
-    return FileResponse(
-        file_path,
-        filename=attachment.get("original_name", filename),
-        media_type="application/octet-stream",
-    )
-
-
-@router.delete("/{project_id}/attachments/{filename}")
-async def delete_attachment(
-    project_id: int,
-    filename: str,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
-):
-    """Delete an attachment from a project."""
-    # Validate filename to prevent path traversal
-    if "/" in filename or "\\" in filename or ".." in filename or not filename:
-        raise HTTPException(status_code=400, detail="Invalid filename")
-
-    # Verify project exists
-    result = await db.execute(select(Project).where(Project.id == project_id))
-    project = result.scalar_one_or_none()
-    if not project:
-        raise HTTPException(status_code=404, detail="Project not found")
-
-    # Find and remove attachment from list
-    attachments = project.attachments or []
-    attachment = next((a for a in attachments if a.get("filename") == filename), None)
-    if not attachment:
-        raise HTTPException(status_code=404, detail="Attachment not found")
-
-    # Remove from list
-    attachments = [a for a in attachments if a.get("filename") != filename]
-    project.attachments = attachments if attachments else None
-
-    # Delete file
-    file_path = (
-        get_project_attachments_dir(project_id) / filename
-    )  # SEC-PATH-OK: filename validated above (no /, \\, .., empty) + attachment membership check
-    if file_path.exists():
-        try:
-            os.remove(file_path)
-        except Exception as e:
-            logger.warning("Failed to delete attachment file: %s", e)
-
-    await db.flush()
-    await db.refresh(project)
-
-    return {
-        "status": "success",
-        "message": "Attachment deleted",
-        "attachments": project.attachments,
-    }
-
-
-# ============ #1155: Cover image ============
-
-
 @router.post("/{project_id}/cover-image")
 async def upload_project_cover_image(
     project_id: int,

+ 2 - 2
backend/app/schemas/archive.py

@@ -45,8 +45,8 @@ class ArchiveUpdate(IgnoresNullForRequiredColumns, ArchiveBase):
     # 'api' — some script or integration did it.
     user_verdict_source: str | None = Field(default=None, pattern="^(dialog|printer_card|api)$")
     # Editable because a print archived without its 3MF has no figure at all,
-    # and nothing else can supply one after the fact -- rescan needs a file
-    # this archive does not have (#1820). Bounded because it feeds the filament
+    # and nothing else can supply one after the fact -- the figure is read from
+    # a 3MF this archive does not have (#1820). Bounded because it feeds the filament
     # totals: 100 kg is far past any single print and well short of a value
     # that would swamp a chart.
     filament_used_grams: Annotated[float | None, Field(ge=0, le=100_000)] = None

+ 2 - 2
backend/tests/integration/test_archives_api.py

@@ -810,8 +810,8 @@ class TestArchivesAPI:
         self, async_client: AsyncClient, archive_factory, printer_factory, db_session
     ):
         """#1820: a print that archived without its 3MF has no filament figure
-        and no way to recover one — rescan needs a file this archive does not
-        have. The edit is the only route, so it has to reach the log entry too:
+        and no way to recover one — the figure is read from a 3MF this archive
+        does not have. The edit is the only route, so it has to reach the log entry too:
         the Projects roll-up and the Prometheus counter sum PrintLogEntry, not
         the archive.
         """

+ 2 - 18
backend/tests/integration/test_bulk_archive_routes_scope.py

@@ -1,8 +1,7 @@
 """Bulk archive actions keep to the caller's printers.
 
-Recalculating costs and rescanning all archives went over every archive,
-so a member of a group limited to some printers rewrote other printers'
-archives and got their ids back in the error list.
+Recalculating costs went over every archive, so a member of a group limited
+to some printers rewrote other printers' archives.
 """
 
 from __future__ import annotations
@@ -24,21 +23,6 @@ async def _member(client: AsyncClient, printer_ids: list[int]) -> dict[str, str]
     return _auth((await _user(client, admin, "member", [perms, team]))[0])
 
 
-async def test_rescan_all_reports_only_the_team_printers_archives(
-    async_client: AsyncClient, printer_factory, archive_factory
-):
-    a, b = await printer_factory(name="A"), await printer_factory(name="B")
-    # No file behind either archive, so each one lands in the error list.
-    on_a = await archive_factory(a.id, file_path="archives/missing-a.3mf")
-    await archive_factory(b.id, file_path="archives/missing-b.3mf")
-    member = await _member(async_client, [a.id])
-
-    response = await async_client.post("/api/v1/archives/rescan-all", headers=member)
-
-    assert response.status_code == 200, response.text
-    assert [error["id"] for error in response.json()["errors"]] == [on_a.id]
-
-
 async def test_recalculating_costs_leaves_other_printers_alone(
     async_client: AsyncClient, db_session, printer_factory, archive_factory
 ):

+ 0 - 20
backend/tests/integration/test_one_print_cost_b5.py

@@ -6,13 +6,10 @@ sum the spool usage of every run, so an archive printed three times was priced
 three times over; editing its grams repriced nothing.
 """
 
-import zipfile
-
 import pytest
 from httpx import AsyncClient
 from sqlalchemy import select
 
-from backend.app.core.config import settings
 from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 
@@ -71,23 +68,6 @@ async def test_recalculate_still_prices_untracked_grams_at_the_default(
     assert await _cost(async_client, archive_id) == 3.25
 
 
-@pytest.mark.asyncio
-@pytest.mark.integration
-async def test_rescan_prices_one_print(
-    async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path, monkeypatch
-):
-    monkeypatch.setattr(settings, "base_dir", tmp_path)
-    archive_id = await _archive(archive_factory, printer_factory, db_session, grams=100.0)
-    (tmp_path / "archives/test").mkdir(parents=True)
-    with zipfile.ZipFile(tmp_path / "archives/test/test_print.gcode.3mf", "w") as zf:
-        zf.writestr("3D/3dmodel.model", "<model/>")
-    await _usage(db_session, archive_id, (100.0, 3.0), (100.0, 3.0), (100.0, 3.0))
-
-    assert (await async_client.post(f"/api/v1/archives/{archive_id}/rescan")).status_code == 200
-
-    assert await _cost(async_client, archive_id) == 3.0
-
-
 @pytest.mark.asyncio
 @pytest.mark.integration
 async def test_editing_grams_prices_the_archive_and_its_run(

+ 16 - 0
backend/tests/integration/test_print_queue_api.py

@@ -119,6 +119,22 @@ class TestPrintQueueAPI:
         assert response.status_code == 200
         assert isinstance(response.json(), list)
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_filters_on_several_statuses(self, async_client: AsyncClient, queue_item_factory):
+        """The wiki documents ?status=pending,printing; it was matched as one
+        literal status, so it returned nothing."""
+        pending = await queue_item_factory(status="pending")
+        printing = await queue_item_factory(status="printing")
+        await queue_item_factory(status="completed")
+
+        several = await async_client.get("/api/v1/queue/?status=pending,printing")
+        one = await async_client.get("/api/v1/queue/?status=printing")
+
+        assert several.status_code == 200
+        assert sorted(item["id"] for item in several.json()) == sorted([pending.id, printing.id])
+        assert [item["id"] for item in one.json()] == [printing.id]
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.parametrize("pinned_first", [True, False], ids=["pinned-above-any", "any-above-pinned"])

+ 35 - 0
backend/tests/integration/test_removed_unwired_routes.py

@@ -0,0 +1,35 @@
+"""Routes for two features that never had a UI are gone (audit A1-02, A2-01).
+
+Archive rescan and project file attachments were reachable only through the
+API, while the wiki described buttons and a section that did not exist.
+The project cover image keeps its own routes and storage.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+@pytest.mark.parametrize(
+    ("method", "path"),
+    [
+        ("POST", "/api/v1/archives/{archive}/rescan"),
+        ("POST", "/api/v1/archives/rescan-all"),
+        ("POST", "/api/v1/projects/{project}/attachments"),
+        ("GET", "/api/v1/projects/{project}/attachments/notes.pdf"),
+        ("DELETE", "/api/v1/projects/{project}/attachments/notes.pdf"),
+    ],
+)
+async def test_route_is_gone(async_client: AsyncClient, printer_factory, archive_factory, method, path):
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id)
+    project = (await async_client.post("/api/v1/projects/", json={"name": "Gone"})).json()
+
+    response = await async_client.request(method, path.format(archive=archive.id, project=project["id"]))
+
+    assert response.status_code in (404, 405), response.text
+    if response.status_code == 404:
+        # The router's (or the API catch-all's) generic answer, not the old
+        # route's own "Attachment not found" for a missing file.
+        assert response.json()["detail"].lower() == "not found"

+ 1 - 1
backend/tests/integration/test_slice_archive_output_path_2832.py

@@ -84,7 +84,7 @@ class TestTheReportedFailure:
 
     async def test_the_archive_row_points_at_the_file(self, db_session, archive_factory, printer_factory, archive_root):
         """A row whose file_path does not exist is the same class of bug one
-        step later -- every reprint and rescan reads it back."""
+        step later -- every reprint reads it back."""
         from backend.app.core.config import settings
         from backend.app.models.archive import PrintArchive
 

+ 1 - 1
backend/tests/integration/test_spoolman_cost_preserved_2591.py

@@ -5,7 +5,7 @@ Both cost recalculations rebuild a print's cost from ``SpoolUsageHistory``, and
 Spoolman mode never writes rows there -- the built-in usage tracker is handed
 ``spoolman_owns_usage`` at print start. Their catalogue-or-default fallback
 would therefore overwrite the Spoolman figure with a default-rate one on the
-next rescan or bulk recalculate, silently undoing the fix, and the per-slot
+next bulk recalculate, silently undoing the fix, and the per-slot
 spool resolution it came from is transient and cannot be rebuilt from the
 archive row.
 """

+ 1 - 1
frontend/src/__tests__/components/EditArchiveModal.test.tsx

@@ -395,7 +395,7 @@ describe('EditArchiveModal', () => {
   });
 
   describe('filament grams (#1820)', () => {
-    // A print archived without its 3MF carries no weight at all, and no rescan
+    // A print archived without its 3MF carries no weight at all, and nothing
     // can supply one — there is no file to read. Typing it here is the only
     // route, so the field has to reach the API, and an untouched save must not
     // overwrite a figure that came from a real slice.

+ 0 - 32
frontend/src/api/client.ts

@@ -7614,38 +7614,6 @@ export const api = {
       body: JSON.stringify({ queue_item_ids: queueItemIds }),
     }),
 
-  // Project Attachments
-  uploadProjectAttachment: async (projectId: number, file: File): Promise<{
-    status: string;
-    filename: string;
-    original_name: string;
-    attachments: ProjectAttachment[];
-  }> => {
-    const formData = new FormData();
-    formData.append('file', file);
-    const headers: Record<string, string> = {};
-    if (authToken) {
-      headers['Authorization'] = `Bearer ${authToken}`;
-    }
-    const response = await fetch(`${API_BASE}/projects/${projectId}/attachments`, {
-      method: 'POST',
-      headers,
-      body: formData,
-    });
-    if (!response.ok) {
-      const error = await response.json().catch(() => ({}));
-      throw new Error(error.detail || `HTTP ${response.status}`);
-    }
-    return response.json();
-  },
-  getProjectAttachmentUrl: (projectId: number, filename: string) =>
-    `${API_BASE}/projects/${projectId}/attachments/${encodeURIComponent(filename)}`,
-  deleteProjectAttachment: (projectId: number, filename: string) =>
-    request<{ status: string; message: string; attachments: ProjectAttachment[] | null }>(
-      `/projects/${projectId}/attachments/${encodeURIComponent(filename)}`,
-      { method: 'DELETE' }
-    ),
-
   // #1155: Cover image
   // Browsers can't attach `Authorization: Bearer ...` to `<img src>`, so we
   // append the stream-token query string the same way archive thumbnails do.

+ 1 - 1
frontend/src/components/EditArchiveModal.tsx

@@ -400,7 +400,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
           </div>
 
           {/* Filament used - the only way to supply a figure for a print that
-              archived without its 3MF, which no rescan can repair (#1820). */}
+              archived without its 3MF, which has nothing to read it from (#1820). */}
           <div>
             <label className="block text-sm text-bambu-gray mb-1" htmlFor="archive-filament-grams">
               <Weight className="w-4 h-4 inline mr-1" />

+ 1 - 1
frontend/src/i18n/locales/de.ts

@@ -5949,7 +5949,7 @@ export default {
     itemsPrintedHelp: 'Anzahl der in diesem Druckauftrag produzierten Teile. 0, wenn die ganze Platte unbrauchbar war.',
     filamentUsed: 'Verbrauchtes Filament (g)',
     filamentUsedPlaceholder: 'z. B. 46.16',
-    filamentUsedHelp: 'Von Hand eintragen, wenn ein Druck ohne 3MF archiviert wurde, damit er in die Filament-Summen eingeht. Beim erneuten Einlesen eines Archivs mit 3MF wird der Wert wieder aus der Datei übernommen.',
+    filamentUsedHelp: 'Von Hand eintragen, wenn ein Druck ohne 3MF archiviert wurde, damit er in die Filament-Summen eingeht.',
     filamentUsedLocked: 'Nur Benutzer, die alle Archive bearbeiten dürfen, können diesen Wert ändern: Spätere Drucke dieses Archivs werden danach berechnet.',
     statusHelp: 'Fehlgeschlagen und Abgebrochen zählen in der Statistik als Fehler. Gestoppt zählt als angehalten und senkt die Erfolgsquote nicht.',
     notes: 'Notizen',

+ 1 - 1
frontend/src/i18n/locales/en.ts

@@ -6002,7 +6002,7 @@ export default {
     itemsPrintedHelp: 'Number of items produced in this print job. Use 0 if the whole plate was ruined.',
     filamentUsed: 'Filament used (g)',
     filamentUsedPlaceholder: 'e.g. 46.16',
-    filamentUsedHelp: 'Set this by hand when a print archived without its 3MF, so it still counts towards the filament totals. Rescanning an archive that has its 3MF reads the figure back from the file.',
+    filamentUsedHelp: 'Set this by hand when a print archived without its 3MF, so it still counts towards the filament totals.',
     filamentUsedLocked: 'Only users who can edit all archives can change this figure: later prints of this archive are charged from it.',
     statusHelp: 'Failed and Aborted count as failures in the statistics. Cancelled counts as stopped and does not lower the success rate.',
     notes: 'Notes',

+ 1 - 1
frontend/src/i18n/locales/es.ts

@@ -5957,7 +5957,7 @@ export default {
     itemsPrintedHelp: 'Número de elementos producidos en este trabajo de impresión. Use 0 si toda la placa se estropeó.',
     filamentUsed: 'Filamento usado (g)',
     filamentUsedPlaceholder: 'p. ej. 46.16',
-    filamentUsedHelp: 'Introdúcelo a mano cuando una impresión se haya archivado sin su 3MF, para que siga contando en los totales de filamento. Al volver a escanear un archivo que sí tiene su 3MF, el valor se lee de nuevo del fichero.',
+    filamentUsedHelp: 'Introdúcelo a mano cuando una impresión se haya archivado sin su 3MF, para que siga contando en los totales de filamento.',
     filamentUsedLocked: 'Solo los usuarios que pueden editar todos los archivos pueden cambiar este valor: las impresiones posteriores de este archivo se cobran a partir de él.',
     statusHelp: 'Fallida y Abortada cuentan como fallos en las estadísticas. Cancelada cuenta como detenida y no reduce la tasa de éxito.',
     notes: 'Notas',

+ 1 - 1
frontend/src/i18n/locales/fr.ts

@@ -5937,7 +5937,7 @@ export default {
     itemsPrintedHelp: 'Nombre d\'objets produits. Indiquez 0 si tout le plateau est raté.',
     filamentUsed: 'Filament utilisé (g)',
     filamentUsedPlaceholder: 'p. ex. 46.16',
-    filamentUsedHelp: 'À saisir à la main lorsqu\'une impression a été archivée sans son 3MF, afin qu\'elle compte quand même dans les totaux de filament. Une nouvelle analyse d\'une archive qui possède son 3MF relit la valeur depuis le fichier.',
+    filamentUsedHelp: 'À saisir à la main lorsqu\'une impression a été archivée sans son 3MF, afin qu\'elle compte quand même dans les totaux de filament.',
     filamentUsedLocked: 'Seuls les utilisateurs autorisés à modifier toutes les archives peuvent changer cette valeur : les impressions suivantes de cette archive sont facturées à partir d\'elle.',
     statusHelp: 'Échouée et Interrompue comptent comme des échecs dans les statistiques. Annulée compte comme arrêtée et ne fait pas baisser le taux de réussite.',
     notes: 'Notes',

+ 1 - 1
frontend/src/i18n/locales/it.ts

@@ -5936,7 +5936,7 @@ export default {
     itemsPrintedHelp: 'Numero di elementi prodotti in questo job di stampa. Usa 0 se l\'intero piatto è rovinato.',
     filamentUsed: 'Filamento usato (g)',
     filamentUsedPlaceholder: 'es. 46.16',
-    filamentUsedHelp: 'Inseriscilo a mano quando una stampa è stata archiviata senza il suo 3MF, così da farla rientrare comunque nei totali del filamento. La riscansione di un archivio che ha il suo 3MF rilegge il valore dal file.',
+    filamentUsedHelp: 'Inseriscilo a mano quando una stampa è stata archiviata senza il suo 3MF, così da farla rientrare comunque nei totali del filamento.',
     filamentUsedLocked: 'Solo gli utenti che possono modificare tutti gli archivi possono cambiare questo valore: le stampe successive di questo archivio vengono addebitate in base ad esso.',
     statusHelp: 'Fallito e Interrotto contano come errori nelle statistiche. Annullato conta come fermato e non abbassa la percentuale di successo.',
     notes: 'Note',

+ 1 - 1
frontend/src/i18n/locales/ja.ts

@@ -5950,7 +5950,7 @@ export default {
     itemsPrintedHelp: 'この印刷ジョブで製造したアイテム数。プレート全体が失敗した場合は 0 を入力します。',
     filamentUsed: '使用フィラメント (g)',
     filamentUsedPlaceholder: '例: 46.16',
-    filamentUsedHelp: '3MF なしでアーカイブされた印刷は、手入力するとフィラメント合計に反映されます。3MF があるアーカイブを再スキャンすると、この値はファイルから読み直されます。',
+    filamentUsedHelp: '3MF なしでアーカイブされた印刷は、手入力するとフィラメント合計に反映されます。',
     filamentUsedLocked: 'この値を変更できるのは、すべてのアーカイブを編集できるユーザーだけです。このアーカイブの以降の印刷はこの値に基づいて課金されます。',
     statusHelp: '統計では「失敗」と「中断」は失敗として数えられます。「キャンセル」は停止として数えられ、成功率を下げません。',
     notes: 'メモ',

+ 1 - 1
frontend/src/i18n/locales/ko.ts

@@ -5698,7 +5698,7 @@ export default {
     itemsPrintedHelp: '이 인쇄 작업에서 생산된 항목 수. 플레이트 전체를 버린 경우 0을 입력하세요.',
     filamentUsed: '사용된 필라멘트 (g)',
     filamentUsedPlaceholder: '예: 46.16',
-    filamentUsedHelp: '3MF 없이 보관된 인쇄는 직접 입력하면 필라멘트 합계에 반영됩니다. 3MF가 있는 보관 항목을 다시 스캔하면 이 값은 파일에서 다시 읽어옵니다.',
+    filamentUsedHelp: '3MF 없이 보관된 인쇄는 직접 입력하면 필라멘트 합계에 반영됩니다.',
     filamentUsedLocked: '모든 아카이브를 편집할 수 있는 사용자만 이 값을 변경할 수 있습니다. 이 아카이브의 이후 출력은 이 값을 기준으로 청구됩니다.',
     statusHelp: '통계에서 \'실패\'와 \'중단됨\'은 실패로 집계됩니다. \'취소됨\'은 중지로 집계되며 성공률을 낮추지 않습니다.',
     notes: '메모',

+ 1 - 1
frontend/src/i18n/locales/nl.ts

@@ -6000,7 +6000,7 @@ export default {
     itemsPrintedHelp: 'Aantal items dat met deze afdruktaak is geproduceerd. Gebruik 0 als de hele plaat mislukt is.',
     filamentUsed: 'Gebruikt filament (g)',
     filamentUsedPlaceholder: 'bijv. 46.16',
-    filamentUsedHelp: 'Stel dit handmatig in wanneer een afdruk zonder 3MF is gearchiveerd, zodat deze toch meetelt in de filamenttotalen. Bij het opnieuw scannen van een archief met een 3MF wordt de waarde weer uit het bestand gelezen.',
+    filamentUsedHelp: 'Stel dit handmatig in wanneer een afdruk zonder 3MF is gearchiveerd, zodat deze toch meetelt in de filamenttotalen.',
     filamentUsedLocked: 'Alleen gebruikers die alle archieven mogen bewerken, kunnen deze waarde wijzigen: latere afdrukken van dit archief worden ermee afgerekend.',
     statusHelp: 'Mislukt en Afgebroken tellen in de statistieken als mislukt. Geannuleerd telt als gestopt en verlaagt het succespercentage niet.',
     notes: 'Notities',

+ 1 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -5936,7 +5936,7 @@ export default {
     itemsPrintedHelp: 'Número de itens produzidos neste trabalho de impressão. Use 0 se a placa inteira foi perdida.',
     filamentUsed: 'Filamento usado (g)',
     filamentUsedPlaceholder: 'ex.: 46.16',
-    filamentUsedHelp: 'Informe manualmente quando uma impressão for arquivada sem o 3MF, para que ela ainda conte nos totais de filamento. Reescanear um arquivo que tem o 3MF lê o valor novamente do arquivo.',
+    filamentUsedHelp: 'Informe manualmente quando uma impressão for arquivada sem o 3MF, para que ela ainda conte nos totais de filamento.',
     filamentUsedLocked: 'Somente usuários que podem editar todos os arquivos podem alterar este valor: as impressões seguintes deste arquivo são cobradas com base nele.',
     statusHelp: 'Falhou e Abortado contam como falhas nas estatísticas. Cancelado conta como parado e não reduz a taxa de sucesso.',
     notes: 'Notas',

+ 1 - 1
frontend/src/i18n/locales/ru.ts

@@ -5686,7 +5686,7 @@ export default {
     itemsPrintedHelp: "Количество изделий, полученных в этом задании печати. Укажите 0, если испорчена вся пластина.",
     filamentUsed: "Израсходованный филамент (г)",
     filamentUsedPlaceholder: "напр. 46.16",
-    filamentUsedHelp: "Укажите вручную, если печать заархивирована без 3MF — тогда она попадёт в итоги по филаменту. При повторном сканировании архива с 3MF значение снова считывается из файла.",
+    filamentUsedHelp: "Укажите вручную, если печать заархивирована без 3MF — тогда она попадёт в итоги по филаменту.",
     filamentUsedLocked: "Изменить это значение могут только пользователи, которым разрешено редактировать все архивы: по нему рассчитываются последующие печати этого архива.",
     statusHelp: "Ошибка и Прервано считаются в статистике неудачами. Отменено считается остановкой и не снижает процент успешных печатей.",
     notes: "Заметки",

+ 1 - 1
frontend/src/i18n/locales/sv.ts

@@ -5998,7 +5998,7 @@ errors: {
     itemsPrintedHelp: 'Antal objekt som producerats i detta utskriftsjobb',
     filamentUsed: 'Filament använt (g)',
     filamentUsedPlaceholder: 't.ex. 46,16',
-    filamentUsedHelp: 'Ställ in detta manuellt när en utskrift arkiveras utan sin 3MF, så att den fortfarande räknas mot filamenttotalen. Om man skannar om ett arkiv som har sin 3MF läser man tillbaka siffran från filen.',
+    filamentUsedHelp: 'Ställ in detta manuellt när en utskrift arkiveras utan sin 3MF, så att den fortfarande räknas mot filamenttotalen.',
     filamentUsedLocked: 'Endast användare som får redigera alla arkiv kan ändra detta värde: senare utskrifter av arkivet debiteras utifrån det.',
     statusHelp: 'Misslyckad och Avbruten räknas som fel i statistiken. Stoppad räknas som stoppad och sänker inte andelen lyckade utskrifter.',
     notes: 'Anteckningar',

+ 1 - 1
frontend/src/i18n/locales/tr.ts

@@ -5922,7 +5922,7 @@ export default {
     itemsPrintedHelp: 'Bu baskı işinde üretilen öğe sayısı. Tüm tabla bozulduysa 0 girin.',
     filamentUsed: 'Kullanılan filament (g)',
     filamentUsedPlaceholder: 'ör. 46.16',
-    filamentUsedHelp: 'Bir baskı 3MF dosyası olmadan arşivlendiyse elle girin; böylece filament toplamlarına yine de dahil olur. 3MF dosyası olan bir arşiv yeniden tarandığında bu değer dosyadan yeniden okunur.',
+    filamentUsedHelp: 'Bir baskı 3MF dosyası olmadan arşivlendiyse elle girin; böylece filament toplamlarına yine de dahil olur.',
     filamentUsedLocked: 'Bu değeri yalnızca tüm arşivleri düzenleyebilen kullanıcılar değiştirebilir: bu arşivin sonraki baskıları buna göre ücretlendirilir.',
     statusHelp: 'İstatistiklerde Başarısız ve Yarıda kesildi hata olarak sayılır. İptal edildi durdurulmuş sayılır ve başarı oranını düşürmez.',
     notes: 'Notlar',

+ 1 - 1
frontend/src/i18n/locales/uk.ts

@@ -5990,7 +5990,7 @@ export default {
     itemsPrintedHelp: "Кількість елементів, виготовлених у цьому завданні друку. Укажіть 0, якщо зіпсовано всю пластину.",
     filamentUsed: "Витрачений філамент (г)",
     filamentUsedPlaceholder: "напр. 46.16",
-    filamentUsedHelp: "Вкажіть вручну, якщо друк заархівовано без 3MF — тоді він потрапить до підсумків з філаменту. Повторне сканування архіву, який має 3MF, зчитує значення з файлу.",
+    filamentUsedHelp: "Вкажіть вручну, якщо друк заархівовано без 3MF — тоді він потрапить до підсумків з філаменту.",
     filamentUsedLocked: "Змінити це значення можуть лише користувачі, яким дозволено редагувати всі архіви: за ним розраховуються наступні друки цього архіву.",
     statusHelp: "Не вдалося та Перервано вважаються в статистиці невдачами. Скасовано вважається зупинкою і не знижує відсоток успішних друків.",
     notes: "Примітки",

+ 1 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -5936,7 +5936,7 @@ export default {
     itemsPrintedHelp: '此打印任务中生产的物品数量。整块打印板报废时填 0。',
     filamentUsed: '已用耗材 (g)',
     filamentUsedPlaceholder: '例如 46.16',
-    filamentUsedHelp: '当打印任务在没有 3MF 的情况下归档时,可手动填写,使其仍计入耗材总量。重新扫描带有 3MF 的归档时,该数值会从文件中重新读取。',
+    filamentUsedHelp: '当打印任务在没有 3MF 的情况下归档时,可手动填写,使其仍计入耗材总量。',
     filamentUsedLocked: '只有可以编辑所有归档的用户才能更改此数值:此归档之后的打印将据此计费。',
     statusHelp: '在统计中,“失败”和“已中止”计为失败。“已取消”计为停止,不会降低成功率。',
     notes: '备注',

+ 1 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -5936,7 +5936,7 @@ export default {
     itemsPrintedHelp: '此列印任務中生產的物品數量。整塊列印板報廢時填 0。',
     filamentUsed: '已用耗材 (g)',
     filamentUsedPlaceholder: '例如 46.16',
-    filamentUsedHelp: '當列印工作在沒有 3MF 的情況下封存時,可手動填寫,使其仍計入耗材總量。重新掃描具有 3MF 的封存項目時,此數值會從檔案重新讀取。',
+    filamentUsedHelp: '當列印工作在沒有 3MF 的情況下封存時,可手動填寫,使其仍計入耗材總量。',
     filamentUsedLocked: '只有可以編輯所有封存的使用者才能變更此數值:此封存之後的列印將據此計費。',
     statusHelp: '在統計中,「失敗」和「已中止」計為失敗。「已取消」計為停止,不會降低成功率。',
     notes: '備註',