diff --git a/CHANGELOG.md b/CHANGELOG.md index 18d171bb6..c65f291e6 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 +- **Post-print outcome confirmation: say whether a completed print is actually a good part (#1898, requested by @FedericoPuntelli, contributed by @Thomansky in #3047)** — The printer can only report that a print *completed*; a warped, out-of-tolerance or wrong-colour part counted as a success anyway. A print can now ask for a verdict: turn on **Ask for Outcome** in the print dialog, or set it as the default under Settings → Workflow → Queue & Dispatch → Default Print Options, and when the print finishes Bambuddy asks "How did your print come out?" with the finish photo and **Good** / **Reject**. It can be answered in the dialog that opens in the web UI, from the printer card next to the plate-clear button, or from the phone: the new **Outcome Confirmation** notification event puts Good/Reject buttons into ntfy and Telegram notifications and a link to the dialog into every other channel. A reject takes an optional reason from the failure-reason list and offers **Print again**. *Ask me later* leaves an amber **outcome?** badge on the archive card, and the Archives page gets an **Unconfirmed** filter; the verdict stays editable from the card's menu or Edit Archive, and says where it came from (in the app, a link, the printer card, or a plate release). Two further options, both off by default: **Also ask for prints started outside Bambuddy** gives prints started at the printer, in Bambu Studio or in the Handy app the same prompt, and **Count unanswered outcomes as good on plate release** (Plate-Clear Confirmation card) records a still-open prompt as good when the plate is released. Rejected prints don't touch the machine failure rate; Failure Analysis shows them as their own line with a yield figure, and project part counts only count parts that were not rejected. A one-tap link shows a confirmation page and records nothing on a plain open, so a link preview, a mail scanner or a browser prefetch can't answer the question for you; only the notification buttons record in one tap. Nothing changes for anyone who doesn't turn it on: the option is off by default, and the notification event only fires for prints that asked. - **Connected apps: other applications can sign people in with their Bambuddy account** — Settings → API Keys → Connected Apps registers an app with one exact callback URL and gives it a client ID and a secret, shown once. The app sends the browser to `/connect/authorize`; the user confirms once ("Allow"), and after that sign-in is automatic, so an app opened from the sidebar needs no second login. The app's server then swaps a single-use code for the user's name, email, groups and permissions at `POST /api/v1/connect/token`. It is the OAuth 2.0 authorization-code flow with PKCE (S256 only), kept to what an app on the same network needs: a code lives 60 seconds, works once, is bound to its app, callback URL and PKCE challenge, and is redeemable only with the app's secret; codes and secrets are stored hashed, failed exchanges are rate-limited per app and per IP, and the page never redirects to a callback URL the backend has not matched against the registration. The app never receives the user's password or Bambuddy session, and an API key cannot sign its owner in to an app. Connected apps require authentication to be enabled; with it off, both endpoints answer `auth_disabled` so an app falls back to its own login instead of letting everyone in. The consent page may be shown inside Bambuddy's own frames (`frame-ancestors 'self'`), so an app opened from the sidebar can sign in there; every other page still refuses all framing. See the wiki page *Connected Apps*. - **A batch can record the external order it fulfils, so an integration can't queue the same order twice** — `POST /queue/batches` accepts `external_source` (e.g. `shopify`) and `external_ref`, a reference to the record in that system; both are returned on every batch and can be filtered on with `GET /queue/batches?external_source=&external_ref=`. The pair is unique: a second create for the same record answers 409 instead of making a second batch, so a connector that retries after losing the response can't print an order twice. Both fields are optional and must be given together; batches created without them behave exactly as before. - **PDFs in the File Manager get their thumbnail the moment they arrive (#2976)** — A PDF's grid thumbnail used to exist only once somebody had opened its preview, because the browser rendered it and posted it back; until then the card was blank. Page one is now rendered on the server with PDFium (`pypdfium2`, a new dependency whose wheels carry the PDFium binary for every platform Bambuddy ships, so no system package is needed) on upload, inside an extracted ZIP and when an external folder is scanned. **Generate Thumbnails** covers PDFs too, so existing ones can be backfilled in one click; its tooltip and its nothing-to-do toast say so in all 15 languages. The thumbnail has the same shape as the browser's (a 256 px square, page centred on white), so old and new cards match. PDFium is not thread-safe, so renders are serialised behind a lock and run off the event loop; the render scale comes from the page's own size, so a PDF declaring a huge page cannot make it allocate a huge bitmap. A PDF it cannot read (damaged, encrypted) gets no thumbnail and still lands in the library, and the browser preview remains the fallback. @@ -38,6 +39,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 +- **Filament printed from the external spool was deducted from an AMS spool (#3166)** — The firmware doesn't accept the external spool in a print command's `ams_mapping` list. So BambuStudio and Bambuddy's own dispatch both write it there as -1, the same value as an unused slot, and put the real target in `ams_mapping2`. Bambuddy read only the first list, so an external-spool print looked unmapped, and usage tracking fell back to guessing by position: the first loaded AMS slot. The reporter's H2D charged every external-spool print that way, including 225 g of ABS taken from the PLA spool in AMS slot 1; the same was reported on H2S and P2S. The external spool is now read from `ams_mapping2` (left or right on dual-nozzle printers). A slot the mapping marks as unused is no longer given a tray by position. When a mapping names no tray at all, the tray the printer reports decides instead. The last change also stops an unused slot from taking over the external spool that another slot really used and dropping that slot's weight (#2880). - **Upgrading from Bambuddy 0.2.4.0 or older no longer crashes at startup** — Since 1.2.5.4, startup first converts old failure-reason labels to the new vocabulary (#2974), in both the archives and the print log. A database from before the print log had a failure-reason column (#1378) reached that conversion before the migration that adds the column, and Bambuddy stopped with `no such column: failure_reason` instead of starting. The conversion now skips a table that doesn't have the column yet; it can't hold old labels anyway. On SQLite it also rebuilds the archive search index before converting archives, because archives created before that index existed were never added to it, and updating one of them failed with `database disk image is malformed`. It only rebuilds when there is something to convert, and it skips both tables entirely when nothing needs converting, so a normal startup does less work than before. - **A connection check that overruns in the support bundle now says which step hung, and keeps what finished (#3164)** — The support bundle gives each printer's connection diagnostic 15 seconds and used to discard the whole result when it overran, recording only `timed_out`. A 14-printer farm's bundle came back with that marker on every printer and nothing else, so it could not show which check was slow or whether the printers were reachable at all. The diagnostic now records each check as it finishes and the step it is on; a timed-out entry carries `stalled_in` (the step that was still running), `elapsed_s`, and the checks that completed before it. - **PDF previews in the File Manager failed on any browser older than Chrome 145 or Firefox 144 (#2976)** — Every PDF showed "This file cannot be previewed." pdf.js 6 calls `Map.prototype.getOrInsertComputed` and other recent APIs directly, and the preview loaded its standard build, which assumes them. It now loads pdf.js's legacy build, which bundles polyfills for both the page and the worker. The worker is also bundled through Vite (`?worker&url`) instead of being copied as-is: the copied file kept a class static block that Safari 16.0-16.3 cannot parse, and `build.target` now lowers it like the rest of the app. The Safari 16 baseline check missed that file because it only scanned `.js` output; it now scans `.mjs` too. Checked in a real Chromium without the API, under the app's own Content-Security-Policy: a two-page text PDF renders, pages, and reopens. diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 6b2dcb956..d943d6d5e 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -7,6 +7,7 @@ import zipfile from collections import defaultdict from datetime import date, datetime, time, timedelta, timezone from decimal import ROUND_HALF_UP, Decimal +from html import escape as html_escape from pathlib import Path from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile @@ -39,6 +40,12 @@ from backend.app.services.archive import ArchiveService from backend.app.services.bambu_ftp import ftps_handshake_blocked, list_files_result_async from backend.app.services.design_settings import overrides_from_config from backend.app.services.filament_requirements import annotate_rack_groups +from backend.app.services.print_confirmation import ( + is_one_tap_request, + is_unattended_fetch, + retire_confirm_token, + stamp_verdict, +) from backend.app.services.print_storage import ( REASON_FTP_TRANSFER_FAILED, REASON_FTPS_COOLOFF, @@ -366,6 +373,14 @@ def archive_to_response( "cost": archive.cost, "photos": archive.photos, "failure_reason": archive.failure_reason, + # Post-print outcome confirmation (#1898). confirm_token stays + # server-side — it is a capability and never belongs in a response. + "user_verdict": archive.user_verdict, + "user_verdict_source": archive.user_verdict_source, + "user_verdict_at": archive.user_verdict_at, + # bool() because the column is nullable to match the migration; the + # response contract stays a strict bool either way. + "confirm_requested": bool(archive.confirm_requested), "quantity": archive.quantity, "energy_kwh": archive.energy_kwh, "energy_cost": archive.energy_cost, @@ -1762,9 +1777,23 @@ async def update_archive( previous_filament_grams = archive.filament_used_grams update_payload = update_data.model_dump(exclude_unset=True) + # #1898: how the verdict arrived is recorded with it, never on its own. + verdict_source = update_payload.pop("user_verdict_source", None) for field, value in update_payload.items(): setattr(archive, field, value) + # #1898: a landed verdict retires the one-tap capability token from the + # push notification — the links stop changing anything once someone + # decided, and report the recorded verdict instead. Clearing the verdict + # drops the provenance but leaves the token spent: it was used. + if "user_verdict" in update_payload: + if update_payload["user_verdict"] is None: + archive.user_verdict_source = None + archive.user_verdict_at = None + else: + stamp_verdict(archive, verdict_source or "api") + retire_confirm_token(archive) + # #1444: Mirror per-run classification fields to the most recent # PrintLogEntry for this archive. PrintLogEntry.failure_reason is captured # once at print-completion time from archive.failure_reason — which is @@ -1780,7 +1809,7 @@ async def update_archive( # ENTRY's grams, not the archive's, so correcting only the archive would fix # the card and leave every aggregate reading the old figure -- or, for a # print that archived without its 3MF, no figure at all. - mirror_fields = {"failure_reason", "status", "filament_used_grams"} + mirror_fields = {"failure_reason", "status", "filament_used_grams", "user_verdict"} to_mirror = {k: v for k, v in update_payload.items() if k in mirror_fields} if to_mirror: from backend.app.models.print_log import PrintLogEntry @@ -3394,6 +3423,246 @@ async def delete_photo( return {"status": "deleted", "photos": archive.photos} +# ============================================ +# Post-print outcome confirmation (#1898) +# ============================================ + +# English-only on purpose: this page is rendered by the backend for a phone +# browser that carries no session and therefore no language preference. +_VERDICT_LABELS = {"good": "Good part", "reject": "Rejected"} +_VERDICT_SOURCE_PHRASES = { + "dialog": "in the app", + "link": "with a one-tap link", + "plate_clear": "automatically when the print plate was cleared", + "printer_card": "from the printer card", + "api": "through the API", + "reaction": "with a reaction in chat", +} + + +def _confirm_page(glyph: str, heading: str, body: str) -> str: + """The small HTML page every one-tap outcome link renders.""" + return ( + "" + "Bambuddy" + "" + f"
" + f"
{glyph}

{heading}

{body}
" + ) + + +async def _render_already_answered_page(db: AsyncSession, archive: PrintArchive) -> str: + """Explain a spent one-tap link instead of calling it invalid. + + The plate-clear default, the app and the other link all answer the same + prompt, so the button in a push notification is routinely tapped after the + question is settled. Report the verdict on file, when and how it landed, + and where to change it. + """ + from backend.app.api.routes.settings import get_external_base_url + + name = html_escape(archive.print_name or archive.filename or "") + # The verdict's own timestamp, not the moment the token was spent: a + # verdict changed later in the app would otherwise be dated by the older + # event. Pre-#1898 rows have neither, and then the page omits the date. + used_at = archive.user_verdict_at or archive.confirm_token_used_at + if used_at is not None and used_at.tzinfo is not None: + used_at = used_at.astimezone(timezone.utc) + when = used_at.strftime("%Y-%m-%d %H:%M UTC") if used_at else None + how = _VERDICT_SOURCE_PHRASES.get(archive.user_verdict_source or "") + label = _VERDICT_LABELS.get(archive.user_verdict or "") + + if label: + recorded = f"Recorded as {label}" + if how: + recorded += f" {how}" + if when: + recorded += f" on {when}" + recorded += "." + else: + # The verdict was cleared again in the app; the link stays spent. + recorded = "This prompt was already answered and the verdict has since been cleared." + + base = await get_external_base_url(db) + link = html_escape(f"{base}/archives?confirm={archive.id}", quote=True) + glyph = "✓" if archive.user_verdict == "good" else "✗" if archive.user_verdict else "ℹ" + return _confirm_page( + glyph, + "Already answered", + f"

{name}

" + f"

{recorded}

" + f"

Open this print in Bambuddy to change it.

", + ) + + +def _render_confirm_prompt_page(request: Request, archive: PrintArchive, verdict: str) -> str: + """The page a one-tap verdict link opens. It has recorded nothing yet. + + GET is where link unfurlers, mail-security scanners and browser prefetchers + arrive, uninvited and within seconds of the message being sent, so GET + writes nothing at all -- the verdict is recorded by the form below, over + POST, which none of them issue. + + The form submits itself only for a page opened from a notification button, + which is what keeps the operator at one tap. The marker for that + (``?tap=1``) is put on the Telegram inline keyboard's URLs and nowhere else + -- never on a URL that travels in message text -- so a mail-security + sandbox that renders HTML and runs JavaScript cannot press the button for + the operator: it only ever sees the unmarked URL out of the body. The + User-Agent heuristic still runs on top of that, but it is no longer the + only thing between a scanner and the write; it cannot be, because such a + sandbox sends an ordinary Chrome string. + + Every other arrival -- an unmarked link somebody typed or mailed, a browser + with JavaScript off -- gets the same page and presses the button. + """ + name = html_escape(archive.print_name or archive.filename or "") + label = _VERDICT_LABELS.get(verdict, verdict) + # No action attribute: the form posts back to the URL the page was loaded + # from, so it works behind a reverse proxy and on a host external_url does + # not name. + form = ( + "
" + "" + "
" + ) + # The SPA's CSP allows inline scripts only with the per-request nonce the + # security-headers middleware mints (main.py). Without one -- no middleware, + # an unmarked URL, or an unattended-looking caller -- the page simply waits + # for the button. + nonce = getattr(request.state, "csp_nonce", None) + script = "" + if nonce and is_one_tap_request(request.query_params) and not is_unattended_fetch(request.method, request.headers): + script = ( + f"" + ) + return _confirm_page( + "?", + "Confirm this outcome", + f"

{name}

" + f"

Record this print as {label}?

" + f"{form}{script}", + ) + + +def _confirm_response(html: str): + """The one-tap pages, never cached. + + A proxy holding on to "Saved" or to the prompt would answer a later tap + from its cache, and the prompt page is a capability URL either way. + """ + from fastapi.responses import HTMLResponse + + return HTMLResponse(html, headers={"Cache-Control": "no-store"}) + + +async def _load_confirmable_archive(db: AsyncSession, token: str, verdict: str) -> PrintArchive: + """Resolve a one-tap capability token, or raise the route's 400/404.""" + if verdict not in ("good", "reject"): + raise HTTPException(400, "Verdict must be 'good' or 'reject'") + + result = await db.execute( + select(PrintArchive).where(PrintArchive.confirm_token == token, PrintArchive.confirm_token.isnot(None)) + ) + archive = result.scalar_one_or_none() + if not archive: + raise HTTPException(404, "Confirmation link is invalid or was already used") + return archive + + +@router.get("/confirm/{token}/{verdict}") +async def confirm_outcome_page( + request: Request, + token: str, + verdict: str, + db: AsyncSession = Depends(get_db), +): + """Open a one-tap verdict link. Reads only — the verdict is recorded by POST. + + This used to be the route that recorded, and that was the defect: a GET + that changes state is answered by everything that walks a URL. Telegram + and Slack fetch the links in a message body to build a preview card, mail + gateways detonate them before delivery, browsers prefetch them — any one + of those spent the single-use token and settled the outcome before the + operator had read the question, always in the "good" direction because + good_url came first. Suppressing previews per channel does not cover the + proxies and scanners in between; only removing the write from GET does. + + So GET now hands back the prompt page and nothing else. The page's form + POSTs to this same URL, and :func:`confirm_outcome_by_token` records it. + The human cost is zero for the path the feature is built around: a URL + opened from a notification button carries ``?tap=1`` and the page submits + itself, so that tap is still the only tap. Nothing else gets that script, + including a scanner that runs JavaScript -- the marker is on the buttons, + not in the message text a scanner reads. + """ + archive = await _load_confirmable_archive(db, token, verdict) + + if archive.confirm_token_used_at is not None: + # Answered already — by hand, by the other link, by the plate-clear + # default or by a reaction. Report what is on file and change nothing. + return _confirm_response(await _render_already_answered_page(db, archive)) + + return _confirm_response(_render_confirm_prompt_page(request, archive, verdict)) + + +@router.post("/confirm/{token}/{verdict}") +async def confirm_outcome_by_token( + token: str, + verdict: str, + db: AsyncSession = Depends(get_db), +): + """Record a print-outcome verdict via the capability token from a push notification. + + Deliberately unauthenticated: the token IS the credential. It is a 256-bit + per-archive capability, minted when the confirmation prompt fires, and it + only ever grants writing good/reject on that one archive, exactly once. + "Exactly once" is enforced by ``confirm_token_used_at`` rather than by + dropping the token value, so a link for a print that was already answered + can be recognised and explained instead of looking broken. + + POST is what keeps the capability the operator's: unfurlers, scanners and + prefetchers issue GET, and the GET route above writes nothing. The two + callers that reach here are the prompt page's form and the ntfy action + button, which performs its own request from the phone and is configured + with ``method=POST``. Returns a small HTML page for the phone browser. + """ + archive = await _load_confirmable_archive(db, token, verdict) + + if archive.confirm_token_used_at is not None: + return _confirm_response(await _render_already_answered_page(db, archive)) + + archive.user_verdict = verdict + stamp_verdict(archive, "link") + retire_confirm_token(archive) + + # Same mirror as the PATCH route (#1444): verdict-aware statistics read + # print_log_entries, so the latest run must carry the verdict too. + from backend.app.models.print_log import PrintLogEntry + + latest_entry = await db.scalar( + select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc()).limit(1) + ) + if latest_entry is not None: + latest_entry.user_verdict = verdict + + await db.commit() + + label = "Good part" if verdict == "good" else "Rejected" + name = archive.print_name or archive.filename + return _confirm_response( + _confirm_page( + "✓" if verdict == "good" else "✗", + label, + f"

{html_escape(name)}

" + "

Saved — you can close this page.

", + ) + ) + + # ============================================ # QR Code Endpoint # ============================================ diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index add65d13e..5f4c098fd 100644 --- a/backend/app/api/routes/library.py +++ b/backend/app/api/routes/library.py @@ -78,6 +78,7 @@ from backend.app.services.design_settings import ( from backend.app.services.filament_requirements import annotate_rack_groups from backend.app.services.pdf_thumbnail import generate_pdf_thumbnail from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing +from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item from backend.app.services.process_overrides import apply_process_overrides from backend.app.services.slice_output_check import ( missing_start_gcode_message, @@ -2972,6 +2973,10 @@ async def add_files_to_queue( pos_result = await db.execute(select(func.coalesce(func.max(PrintQueueItem.position), 0))) max_position = pos_result.scalar() or 0 + # There is no per-job ask-for-outcome toggle on a bulk add, so the rows take + # the same default the print dialog seeds itself from (#1898). + confirm_outcome = await confirm_outcome_for_new_queue_item(db) + for file_id in request.file_ids: lib_file = files.get(file_id) @@ -3065,6 +3070,7 @@ async def add_files_to_queue( or (folder_projects.get(lib_file.folder_id) if lib_file.folder_id is not None else None), position=max_position, status="pending", + confirm_outcome=confirm_outcome, # Without this the row is ownerless, and `queue:read_own` filters # on `created_by_id` — so the user who queued the file could not # see it in their own queue. diff --git a/backend/app/api/routes/notifications.py b/backend/app/api/routes/notifications.py index dc1f242f5..81bce5d86 100644 --- a/backend/app/api/routes/notifications.py +++ b/backend/app/api/routes/notifications.py @@ -66,6 +66,8 @@ def _provider_to_dict(provider: NotificationProvider) -> dict: # Build plate detection "on_plate_not_empty": provider.on_plate_not_empty, "on_plate_clear_required": provider.on_plate_clear_required, + # Post-print outcome confirmation (#1898) + "on_print_confirm_request": provider.on_print_confirm_request, # Bed cooled "on_bed_cooled": provider.on_bed_cooled, # First layer complete @@ -158,6 +160,8 @@ async def create_notification_provider( # Build plate detection on_plate_not_empty=provider_data.on_plate_not_empty, on_plate_clear_required=provider_data.on_plate_clear_required, + # Post-print outcome confirmation (#1898) + on_print_confirm_request=provider_data.on_print_confirm_request, # Bed cooled on_bed_cooled=provider_data.on_bed_cooled, # First layer complete diff --git a/backend/app/api/routes/pipeline_runs.py b/backend/app/api/routes/pipeline_runs.py index bf649815c..af06ccf29 100644 --- a/backend/app/api/routes/pipeline_runs.py +++ b/backend/app/api/routes/pipeline_runs.py @@ -61,6 +61,7 @@ from backend.app.services.pipeline_eligibility import ( EligibilityReport, check_pipeline_eligibility, ) +from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item logger = logging.getLogger(__name__) @@ -571,6 +572,9 @@ def _make_orchestration_callable( if len(jobs) != copies: logger.warning("pipeline_run %d expected %d jobs, found %d", run_id, copies, len(jobs)) + # No per-job ask-for-outcome toggle on a pipeline run either (#1898). + confirm_outcome = await confirm_outcome_for_new_queue_item(session) + for job, (printer_id, target_model) in zip(jobs, assignments, strict=False): queue_item = PrintQueueItem( printer_id=printer_id, @@ -578,6 +582,7 @@ def _make_orchestration_callable( library_file_id=slice_response.library_file_id, created_by_id=creator_user_id, status="pending", + confirm_outcome=confirm_outcome, ) session.add(queue_item) await session.flush() diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index 6c71c27b6..4c3a9c586 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -472,6 +472,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse: "timelapse": item.timelapse, "use_ams": item.use_ams, "nozzle_offset_cali": item.nozzle_offset_cali, + "confirm_outcome": item.confirm_outcome, "preheat_override": item.preheat_override, "preheat_chamber_target_override": item.preheat_chamber_target_override, "status": item.status, @@ -1193,6 +1194,7 @@ async def add_to_queue( timelapse=data.timelapse, use_ams=data.use_ams, nozzle_offset_cali=data.nozzle_offset_cali, + confirm_outcome=data.confirm_outcome, preheat_override=data.preheat_override, preheat_chamber_target_override=data.preheat_chamber_target_override, gcode_injection=data.gcode_injection, diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 56bad42f4..e58bbbffa 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -3537,6 +3537,21 @@ async def clear_plate( printer_manager.set_awaiting_plate_clear(printer_id, False) + # #1898: releasing the plate without answering the outcome prompt can + # count as "good" (opt-in setting) — this is the moment the operator + # moves on, so an unanswered prompt would otherwise linger unconfirmed. + from backend.app.api.routes.settings import get_setting, setting_is_true + + # setting_is_true rather than a comparison of our own: one reader deciding + # for itself what "on" spells is how two parts of the app end up + # disagreeing about the same row. + if setting_is_true(await get_setting(db, "confirm_default_good_on_plate_clear")): + from backend.app.services.print_confirmation import resolve_pending_confirmation_as_good + + resolved = await resolve_pending_confirmation_as_good(db, printer_id) + if resolved is not None: + await db.commit() + return {"success": True, "message": "Plate cleared, next print will start shortly"} diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py index f572699dc..129cd98a6 100644 --- a/backend/app/api/routes/projects.py +++ b/backend/app/api/routes/projects.py @@ -11,7 +11,7 @@ from pathlib import Path from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi.responses import FileResponse, StreamingResponse -from sqlalchemy import case, func, select, update +from sqlalchemy import and_, case, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -54,6 +54,12 @@ router = APIRouter(prefix="/projects", tags=["projects"]) _FAILURE_STATUSES = ("failed", "aborted", "cancelled", "stopped") +# A completed run whose user verdict is 'reject' (#1898) finished on the +# machine but produced scrap — everywhere a project counts good parts, that +# run must not contribute. NULL verdict (never asked / not answered) counts +# as good, matching behaviour before the feature existed. +_NOT_REJECTED = or_(PrintLogEntry.user_verdict.is_(None), PrintLogEntry.user_verdict != "reject") + # Soft-deleted archives (#1343) keep their row — and therefore their # ``project_id`` — after their files have been removed from disk, so that global # Quick Stats can still count their filament / time / cost. Nothing in this @@ -140,8 +146,12 @@ async def _load_totals(db: AsyncSession, project_ids: Sequence[int]) -> dict[int func.coalesce(func.sum(PrintLogEntry.energy_kwh), 0).label("total_energy"), func.coalesce(func.sum(PrintLogEntry.energy_cost), 0).label("total_energy_cost"), func.coalesce(func.sum(PrintArchive.quantity), 0).label("total_items"), + # A completed run the user marked as reject (#1898) produced no + # usable parts — keep it out of the good-parts count. func.coalesce( - func.sum(case((PrintLogEntry.status == "completed", PrintArchive.quantity), else_=0)), + func.sum( + case((and_(PrintLogEntry.status == "completed", _NOT_REJECTED), PrintArchive.quantity), else_=0) + ), 0, ).label("completed_items"), func.coalesce( @@ -423,7 +433,9 @@ async def list_projects( func.count(PrintLogEntry.id).label("archive_count"), func.coalesce(func.sum(PrintArchive.quantity), 0).label("total_items"), func.coalesce( - func.sum(case((PrintLogEntry.status == "completed", PrintArchive.quantity), else_=0)), + func.sum( + case((and_(PrintLogEntry.status == "completed", _NOT_REJECTED), PrintArchive.quantity), else_=0) + ), 0, ).label("completed_count"), func.coalesce( @@ -1009,7 +1021,12 @@ async def get_project_file_progress( func.count(PrintLogEntry.id), ) .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id) - .where(PrintArchive.project_id == project_id, PrintLogEntry.status == "completed", _LIVE_ARCHIVE) + .where( + PrintArchive.project_id == project_id, + PrintLogEntry.status == "completed", + _NOT_REJECTED, + _LIVE_ARCHIVE, + ) .group_by(PrintArchive.library_file_id, PrintArchive.content_hash, PrintArchive.filename) ) diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 2bc9582cb..4ae0d2bd2 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -129,6 +129,22 @@ def normalize_str_setting(key: str, value: object) -> str: raise HTTPException(400, f"{key} must be a string; got {type(value).__name__}") +async def get_external_base_url(db: AsyncSession) -> str: + """Base URL for links Bambuddy hands to the outside world (no trailing slash). + + ``external_url`` is optional and has no default, so anything that must be + absolute — a login link in an e-mail, the one-tap outcome verdict links + (#1898), whose Telegram/ntfy buttons are dropped for a relative URL — falls + back to APP_URL and finally to the dev origin. + """ + import os + + external_url = await get_setting(db, "external_url") + if external_url: + return external_url.rstrip("/") + return os.environ.get("APP_URL", "http://localhost:5173").rstrip("/") + + async def get_external_login_url(db: AsyncSession) -> str: """Get the external URL for the login page. @@ -140,14 +156,7 @@ async def get_external_login_url(db: AsyncSession) -> str: Returns: Full URL to the login page """ - import os - - external_url = await get_setting(db, "external_url") - if external_url: - external_url = external_url.rstrip("/") - else: - external_url = os.environ.get("APP_URL", "http://localhost:5173") - return external_url + "/login" + return await get_external_base_url(db) + "/login" async def set_setting(db: AsyncSession, key: str, value: str) -> None: @@ -199,6 +208,9 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) - "default_vibration_cali", "default_layer_inspect", "default_timelapse", + "default_confirm_outcome", + "confirm_outcome_external_prints", + "confirm_default_good_on_plate_clear", "billing_enabled", "printer_kill_switch_enabled", "ldap_enabled", diff --git a/backend/app/api/routes/webhook.py b/backend/app/api/routes/webhook.py index da2940843..0a0ca91b5 100644 --- a/backend/app/api/routes/webhook.py +++ b/backend/app/api/routes/webhook.py @@ -11,6 +11,7 @@ from backend.app.models.api_key import APIKey from backend.app.models.archive import PrintArchive from backend.app.models.print_queue import PrintQueueItem from backend.app.models.printer import Printer +from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item from backend.app.services.printer_manager import printer_manager logger = logging.getLogger(__name__) @@ -115,6 +116,9 @@ async def webhook_add_to_queue( scheduled_time=scheduled_time, require_previous_success=data.require_previous_success, auto_off_after=data.auto_off_after, + # No dialog to pick this per job, so the install-wide default decides + # whether the finished print asks for a verdict (#1898). + confirm_outcome=await confirm_outcome_for_new_queue_item(db), # Attribute to the key's owner so the item shows up under `queue:read_own` # for the person whose key it is. Legacy keys predating per-user ownership # have no `user_id`, and those rows stay ownerless. diff --git a/backend/app/core/database.py b/backend/app/core/database.py index bfa8a4cb7..0075d7f89 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -5022,6 +5022,45 @@ async def run_migrations(conn): conn, "ALTER TABLE notification_providers ADD COLUMN on_location_ha_sensor_alert BOOLEAN DEFAULT FALSE" ) + # Migration: post-print outcome confirmation (#1898). VARCHAR and the + # BOOLEAN DEFAULT FALSE/TRUE spellings are identical on SQLite and + # Postgres (see the on_ha_sensor_alert note above for why not DEFAULT 0). + await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN user_verdict VARCHAR(10)") + await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN confirm_requested BOOLEAN DEFAULT FALSE") + await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN confirm_token VARCHAR(64)") + await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN user_verdict_source VARCHAR(16)") + # ``DATETIME`` is a SQLite-only alias; PostgreSQL rejects it and + # _safe_execute would swallow the error, leaving the column missing. + if is_sqlite(): + await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN confirm_token_used_at DATETIME") + else: + await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN confirm_token_used_at TIMESTAMP") + # When the verdict on file was recorded (#1898): the "already answered" + # page dates the verdict by this, not by the moment the one-tap token was + # spent, so a verdict changed later in the app reads correctly. + if is_sqlite(): + await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN user_verdict_at DATETIME") + else: + await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN user_verdict_at TIMESTAMP") + await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN user_verdict VARCHAR(10)") + await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN confirm_outcome BOOLEAN DEFAULT FALSE") + await _safe_execute( + conn, "ALTER TABLE notification_providers ADD COLUMN on_print_confirm_request BOOLEAN DEFAULT TRUE" + ) + # The one-tap verdict route looks archives up by this token and runs with no + # authentication, so an upgraded install needs the index too — without it + # every tap, and every unauthenticated request carrying a bogus token, is a + # sequential scan of print_archives. + await _safe_execute( + conn, + "CREATE UNIQUE INDEX IF NOT EXISTS ix_print_archives_confirm_token ON print_archives (confirm_token)", + ) + # Migration: take the capability URLs out of the outcome prompt's body + # (#1898). Seeding only ever inserts a template that is missing, so an + # install that already ran an earlier build of this feature would keep + # sending the verdict links as body text for every channel. + await _migrate_confirm_prompt_body_template(conn) + # Migration: rename the ha_sensor_alert template (#2824). "Home Assistant # Sensor Alert" was fine as a name while it was the only such template; # next to the new "Storage Location Sensor Alert" it no longer says which @@ -5057,6 +5096,32 @@ async def run_migrations(conn): ) +async def _migrate_confirm_prompt_body_template(conn) -> None: + """Replace the one-tap verdict URLs in the outcome prompt's body (#1898). + + The first shape of this template put ``{good_url}`` and ``{reject_url}`` + into the message body, where a link unfurler, a mail gateway or a proxy + reaches them and spends the single-use token before the operator has read + the question. The body now carries ``{confirm_url}``, which only opens the + archive in Bambuddy; the capability links travel in the ntfy action buttons + and the Telegram inline keyboard instead. + + Rewrites only a body that is still the old default verbatim — an admin who + edited the template keeps their own text. Same shape as the two template + renames below. + """ + from sqlalchemy import text + + await conn.execute( + text("UPDATE notification_templates SET body_template = :new WHERE event_type = :et AND body_template = :old"), + { + "new": "{printer}: {filename}\nConfirm: {confirm_url}", + "et": "print_confirm_request", + "old": "{printer}: {filename}\nGood: {good_url}\nReject: {reject_url}", + }, + ) + + async def _migrate_rename_ha_sensor_alert_template(conn) -> None: """Rename the ha_sensor_alert template to "Printer Sensor Alert" (#2824). diff --git a/backend/app/core/websocket.py b/backend/app/core/websocket.py index 4174894c7..ca2c26014 100644 --- a/backend/app/core/websocket.py +++ b/backend/app/core/websocket.py @@ -109,6 +109,16 @@ class ConnectionManager: } ) + async def send_print_confirm_request(self, printer_id: int, data: dict): + """Ask connected clients for a post-print outcome verdict (#1898).""" + await self.broadcast( + { + "type": "print_confirm_request", + "printer_id": printer_id, + "data": data, + } + ) + async def send_archive_created(self, archive: dict): """Notify clients that a new archive was created.""" await self.broadcast( diff --git a/backend/app/main.py b/backend/app/main.py index c9e6c8982..84a2fb89f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3397,6 +3397,190 @@ def _schedule_fallback_3mf_retry( ) +async def _ask_outcome_for_external_print(db, printer_id: int, observed_name: str | None = None) -> bool: + """Whether an archive created here should ask for the print's outcome (#1898). + + Only a print Bambuddy did not dispatch reaches the archive-*creating* + branches below: a queued job already has its archive and takes the + expected-print branch, where the queue item's own ``confirm_outcome`` + decides. The queue is still consulted, because a restart mid-print empties + ``_expected_prints`` — without the check the setting could override a queue + item that deliberately has the flag off. Any failure answers "don't ask": + an unwanted prompt is worse than a missing one, and this must never be the + reason a print goes unarchived. + + A ``printing`` row is not proof on its own, though: Bambuddy deliberately + leaves one behind when a completion cannot be matched to it + (``_completion_belongs_to_queue_item``), and the scheduler's stranded sweep + only takes it back once the printer has sat connected and terminal for + minutes. Until then every screen-started print on that printer would + silently lose its prompt, so the row is held against ``observed_name`` by + the same comparison a completion uses: a positive disagreement means the row + is about some other run and this print is external after all. + """ + logger = logging.getLogger(__name__) + try: + # A savepoint rather than a rollback of the caller's transaction on + # failure: a failed statement leaves the transaction unusable, but a + # full rollback also expires every object the caller has loaded (the + # printer, the archive it just created), and on an async session the + # next attribute read then raises instead of reloading. Everything here + # is a read, so undoing the savepoint loses nothing. + async with db.begin_nested(): + return await _decide_outcome_for_external_print(db, printer_id, observed_name, logger) + except Exception as e: + logger.warning("[CALLBACK] Could not decide the outcome prompt for printer %s: %s", printer_id, e) + return False + + +async def _decide_outcome_for_external_print(db, printer_id: int, observed_name: str | None, logger) -> bool: + """The reads behind ``_ask_outcome_for_external_print``; see there.""" + from backend.app.api.routes.settings import get_setting, setting_is_true + + if not setting_is_true(await get_setting(db, "confirm_outcome_external_prints")): + return False + + from backend.app.models.print_queue import PrintQueueItem + + dispatched_here = await db.scalar( + select(PrintQueueItem) + .where( + PrintQueueItem.printer_id == printer_id, + PrintQueueItem.status == "printing", + ) + .limit(1) + ) + if dispatched_here is None: + return True + + expected = await _queue_item_dispatched_name(db, dispatched_here) + observed = (observed_name or "").strip() + if not expected or not observed or _subtask_names_match(expected, observed): + logger.info( + "[CALLBACK] Not asking for the outcome on printer %s: queue item %s is still printing, so " + "Bambuddy dispatched this run and the item's own ask-for-outcome flag decides.", + printer_id, + dispatched_here.id, + ) + return False + + logger.info( + "[CALLBACK] Queue item %s is still marked printing on printer %s but was dispatched as %r, not " + "%r; treating this as an externally started print.", + dispatched_here.id, + printer_id, + expected, + observed, + ) + return True + + +async def _dispatch_outcome_confirmation_safely( + db, + printer_id: int, + printer_name: str, + data: dict, + archive_id: int, + archive_data: dict | None = None, +) -> None: + """``dispatch_outcome_confirmation`` for a caller that has more to do on ``db``. + + The completion task sends the per-user print email on the same session + right after the prompt. A failed statement in the prompt leaves that + session needing a rollback, and without one the email step fails with + PendingRollbackError; the prompt is the optional part, so it must not be + the reason the email never goes out. + """ + try: + await dispatch_outcome_confirmation(db, printer_id, printer_name, data, archive_id, archive_data) + except Exception as e: + logging.getLogger(__name__).error("[NOTIFY-BG] Outcome-confirmation dispatch failed: %s", e, exc_info=True) + try: + await db.rollback() + except Exception: + pass + + +async def dispatch_outcome_confirmation( + db, + printer_id: int, + printer_name: str, + data: dict, + archive_id: int, + archive_data: dict | None = None, +) -> bool: + """Emit the post-print outcome prompt for a completed archive (#1898). + + Gated on the archive itself: ``confirm_requested`` is the opt-in (from the + queue item, or from ``confirm_outcome_external_prints`` for a print + Bambuddy did not start) and ``user_verdict`` being unset is what makes the + question still open. Mints the per-archive capability token the one-tap + verdict links carry. + + Lives here rather than inline in ``on_print_complete``'s notification task + because that task swallows every exception: extracted, the gate and the two + emissions can be driven by a test, which is the only thing standing between + a regression here and a farm that quietly stops asking. + + Returns whether a prompt was sent. + """ + logger = logging.getLogger(__name__) + from backend.app.models.archive import PrintArchive + + confirm_archive = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none() + if not (confirm_archive and confirm_archive.confirm_requested and confirm_archive.user_verdict is None): + return False + + import secrets as _secrets + + # A spent token stays on the row so the one-tap route can recognise it, so + # "has a token" no longer means "answerable". A prompt going out now needs a + # live one: mint a new token whenever the stored one is already spent, or + # every button in the new message would land on the already-answered page. + if not confirm_archive.confirm_token or confirm_archive.confirm_token_used_at is not None: + confirm_archive.confirm_token = _secrets.token_urlsafe(32) + confirm_archive.confirm_token_used_at = None + await db.commit() + + from backend.app.api.routes.settings import get_external_base_url, get_setting + + base = await get_external_base_url(db) + if not await get_setting(db, "external_url"): + # Both the Telegram inline keyboard and the ntfy action buttons need an + # absolute URL, so an unconfigured install used to get a message body + # with two unusable relative paths and no buttons at all. The shared + # fallback at least produces tappable links; say which setting makes + # them resolve from a phone. + logger.warning( + "[#1898] No external_url configured — the outcome prompt's Good/Reject links point at %s. " + "Set Settings → External URL so they resolve away from this host.", + base, + ) + token = confirm_archive.confirm_token + good_url = f"{base}/api/v1/archives/confirm/{token}/good" + reject_url = f"{base}/api/v1/archives/confirm/{token}/reject" + confirm_url = f"{base}/archives?confirm={archive_id}" + + await ws_manager.send_print_confirm_request( + printer_id, + { + "archive_id": archive_id, + "print_name": confirm_archive.print_name or confirm_archive.filename, + }, + ) + await notification_service.on_print_confirm_request( + printer_id, + printer_name, + data, + db, + archive_data=archive_data, + good_url=good_url, + reject_url=reject_url, + confirm_url=confirm_url, + ) + return True + + async def on_print_start(printer_id: int, data: dict): """Handle print start - archive the 3MF file immediately.""" logger = logging.getLogger(__name__) @@ -3721,6 +3905,18 @@ async def on_print_start(printer_id: int, data: dict): archive.status = "printing" archive.started_at = datetime.now(timezone.utc) + # The previous run's answer is still on this row and the + # completion prompt is gated on ``user_verdict is None`` (#1898), + # so without a reset the second run inherits the first run's + # verdict: no prompt at all, and a green "good" badge on a run + # nobody ever judged. + if archive.confirm_requested: + archive.user_verdict = None + archive.user_verdict_source = None + archive.user_verdict_at = None + archive.confirm_token = None + archive.confirm_token_used_at = None + # Reprint of an archive reuses the source row. Without resetting # ``timelapse_path`` _scan_for_timelapse_with_retries early-returns # ("already has timelapse") and _capture_finish_photo_from_timelapse @@ -4529,6 +4725,7 @@ async def on_print_start(printer_id: int, data: dict): status="printing", started_at=datetime.now(timezone.utc), subtask_id=subtask_id, + confirm_requested=await _ask_outcome_for_external_print(db, printer_id, subtask_name), filament_type=mqtt_filament_meta.get("filament_type"), filament_color=mqtt_filament_meta.get("filament_color"), extra_data={ @@ -4662,6 +4859,35 @@ async def on_print_start(printer_id: int, data: dict): ) if archive: + # Ask-for-outcome for a print Bambuddy did not dispatch (#1898). + # Set on the row rather than passed to archive_print, which also + # serves the queue dispatcher — there the queue item decides. + # Guarded because this branch has no ``except``: an unhandled + # write error would take the _active_prints registration, the + # start notification, the energy reading and the timelapse + # baseline below it with it, and a missing prompt is by far the + # cheaper failure. + archive_id = archive.id + try: + if await _ask_outcome_for_external_print(db, printer_id, subtask_name): + archive.confirm_requested = True + await db.commit() + except Exception as e: + logger.warning("Could not flag archive %s for the outcome prompt: %s", archive_id, e) + # The rollback expires every loaded object, and on an async + # session reading one afterwards raises instead of + # reloading, so the two this branch goes on to use are + # fetched again. The archive itself was committed by + # archive_print; only the flag is lost. + try: + await db.rollback() + archive = await db.get(PrintArchive, archive_id) + printer = await db.get(Printer, printer_id) + except Exception as reload_error: + logger.warning( + "Could not reload archive %s after the failed flag write: %s", archive_id, reload_error + ) + # Track this active print (use both original filename and downloaded filename) _active_prints[(printer_id, downloaded_filename)] = archive.id if filename and filename != downloaded_filename: @@ -6222,6 +6448,23 @@ def _subtask_names_match(expected: str, observed: str) -> bool: return False +async def _queue_item_dispatched_name(db, item) -> str: + """The subtask name *item* was dispatched under, or "" when unknowable. + + A row with no archive, or an archive with no file name, is unverifiable + rather than wrong; every caller answers that with its permissive branch. + """ + if item.archive_id is None: + return "" + + from backend.app.models.archive import PrintArchive + + archive = await db.get(PrintArchive, item.archive_id) + if archive is None or not archive.filename: + return "" + return _subtask_name_from_filename(archive.filename) + + async def _completion_belongs_to_queue_item(db, item, data: dict) -> bool: """Whether this completion event is plausibly about *item*'s print. @@ -7681,6 +7924,14 @@ async def on_print_complete(printer_id: int, data: dict): else: logger.info("[NOTIFY-BG] Skipped duplicate kill-switch provider notification") + # Post-print outcome confirmation (#1898). Runs in this + # background task so the finish photo fetched above rides + # along with the prompt. + if print_status == "completed" and archive_id: + await _dispatch_outcome_confirmation_safely( + db, printer_id, printer_name, data, archive_id, archive_data + ) + # Send user-specific email notification if archive_data: created_by_id = archive_data.get("created_by_id") @@ -9504,6 +9755,12 @@ PUBLIC_API_PREFIXES = [ "/api/v1/ws", # OIDC authorize redirects — include provider_id in path "/api/v1/auth/oidc/authorize/", + # One-tap outcome-verdict links from push notifications (#1898). Tapped on + # a phone with no session, so no header can carry a JWT — the single-use + # capability token in the path IS the credential (same reasoning as the + # /dl/ slicer downloads below). The route grants nothing beyond writing + # good/reject on the one archive the token was minted for. + "/api/v1/archives/confirm/", ] # Route patterns that are public (read-only display data) @@ -9633,6 +9890,10 @@ async def security_headers_middleware(request, call_next): # script passes the policy without us needing 'unsafe-inline'. See # https://developers.cloudflare.com/cloudflare-challenges/challenge-types/javascript-detections/#if-you-have-a-content-security-policy-csp csp_nonce = secrets.token_urlsafe(16) + # Routes that render their own HTML need it too, or their inline script is + # blocked by the policy below. The outcome-confirmation page (#1898) is the + # one that does: it submits its own form so a verdict still costs one tap. + request.state.csp_nonce = csp_nonce response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" # X-Frame-Options is the legacy cross-origin embedding control. Modern diff --git a/backend/app/models/archive.py b/backend/app/models/archive.py index a185dedf8..f459f289f 100644 --- a/backend/app/models/archive.py +++ b/backend/app/models/archive.py @@ -106,6 +106,41 @@ class PrintArchive(Base): failure_reason: Mapped[str | None] = mapped_column(String(100)) # For failed prints quantity: Mapped[int] = mapped_column(Integer, default=1) # Number of items printed + # Post-print outcome confirmation (#1898). user_verdict is the USER's + # quality judgement ('good' / 'reject'), deliberately orthogonal to the + # machine-reported `status`: completed + reject means "printer finished + # it, part is scrap". confirm_requested is copied from the queue item's + # opt-in flag at dispatch (like plate_id) and drives the prompt + the + # "unconfirmed" badge; confirm_token is a per-archive capability for the + # one-tap verdict links in push notifications, minted when the prompt + # fires. A landed verdict RETIRES the token by stamping + # confirm_token_used_at rather than clearing the value: the link stays + # resolvable so a second tap can say "already answered, here is what was + # recorded" instead of the bare "invalid or already used" 404. + # user_verdict_source records how the verdict arrived ('dialog', 'link', + # 'plate_clear', 'printer_card', 'api', 'reaction') so the UI can explain + # a verdict nobody remembers giving. + user_verdict: Mapped[str | None] = mapped_column(String(10), nullable=True) + user_verdict_source: Mapped[str | None] = mapped_column(String(16), nullable=True) + # When the verdict on file was recorded (#1898). Written with every verdict, + # unlike `confirm_token_used_at`, which marks the one moment the one-tap + # capability was spent — a verdict changed later in the app must not be + # dated by that older event. + user_verdict_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + # Nullable to match the ALTER that adds it to an existing install: a fresh + # database would otherwise get NOT NULL while an upgraded one gets a + # nullable column, and the two would disagree about the same table. The + # default still means every row written by Bambuddy is True or False; only + # `is_(True)` and truthiness read it, both of which treat NULL as off. + confirm_requested: Mapped[bool | None] = mapped_column(Boolean, nullable=True, default=False, server_default="0") + # Indexed and unique: the one-tap route is reachable with no credential + # at all, so an unindexed lookup would let anyone turn a stream of + # garbage tokens into a stream of full scans of this table. Uniqueness + # costs nothing (the only writer is secrets.token_urlsafe(32)) and keeps + # scalar_one_or_none from ever raising MultipleResultsFound. + confirm_token: Mapped[str | None] = mapped_column(String(64), nullable=True, unique=True, index=True) + confirm_token_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None) + # Energy tracking energy_kwh: Mapped[float | None] = mapped_column(Float) # Energy consumed in kWh energy_cost: Mapped[float | None] = mapped_column(Float) # Cost of energy consumed diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py index ef1253b3a..428225b93 100644 --- a/backend/app/models/notification.py +++ b/backend/app/models/notification.py @@ -101,6 +101,11 @@ class NotificationProvider(Base): # Off by default: fires after every print, alongside the print-complete alert (#2525) on_plate_clear_required = Column(Boolean, default=False) # Print ended, queue gated until plate is confirmed clear + # Print asked for an outcome verdict (#1898). Defaults ON: it only ever + # fires for prints where the user opted in per-job, so the provider-level + # toggle exists to silence a channel, not to enable the feature. + on_print_confirm_request = Column(Boolean, default=True) + # Event triggers - Bed cooled after print on_bed_cooled = Column(Boolean, default=False) # Bed cooled below threshold after print on_first_layer_complete = Column(Boolean, default=False) # First layer finished printing diff --git a/backend/app/models/notification_template.py b/backend/app/models/notification_template.py index 9e0be4ba9..7a20d7aad 100644 --- a/backend/app/models/notification_template.py +++ b/backend/app/models/notification_template.py @@ -55,6 +55,20 @@ DEFAULT_TEMPLATES = [ "title_template": "Print {progress}% Complete", "body_template": "{printer}: {filename}\nRemaining: {remaining_time}", }, + { + # Post-print outcome confirmation (#1898). The body carries + # {confirm_url}, which only opens the archive in Bambuddy: anything + # that walks a URL out of a message body — a preview card, a mail + # gateway, a proxy — then reaches a page that changes nothing. + # {good_url} / {reject_url} are the single-use capability links and + # stay available as variables, but they travel by default only in the + # affordances nothing prefetches: the ntfy action buttons and the + # Telegram inline keyboard, both built in notification_service. + "event_type": "print_confirm_request", + "name": "Print Outcome Confirmation", + "title_template": "How did your print come out?", + "body_template": "{printer}: {filename}\nConfirm: {confirm_url}", + }, { "event_type": "print_missing_spool_assignment", "name": "Missing Spool Assignment", diff --git a/backend/app/models/print_log.py b/backend/app/models/print_log.py index 82e479cbd..512c32a73 100644 --- a/backend/app/models/print_log.py +++ b/backend/app/models/print_log.py @@ -45,6 +45,10 @@ class PrintLogEntry(Base): energy_kwh: Mapped[float | None] = mapped_column(Float) energy_cost: Mapped[float | None] = mapped_column(Float) failure_reason: Mapped[str | None] = mapped_column(String(100)) + # User quality verdict ('good' / 'reject'), mirrored from the archive on + # PATCH exactly like status/failure_reason (#1444 mirror) so verdict-aware + # statistics can stay on this table (#1898). + user_verdict: Mapped[str | None] = mapped_column(String(10), nullable=True) thumbnail_path: Mapped[str | None] = mapped_column(String(500)) created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) created_by_username: Mapped[str | None] = mapped_column(String(100)) diff --git a/backend/app/models/print_queue.py b/backend/app/models/print_queue.py index 351e48c3b..fa029b47f 100644 --- a/backend/app/models/print_queue.py +++ b/backend/app/models/print_queue.py @@ -118,6 +118,9 @@ class PrintQueueItem(Base): use_ams: Mapped[bool] = mapped_column(Boolean, default=True) # Nozzle offset calibration — dual-nozzle printers only, MQTT-gated (#1682) nozzle_offset_cali: Mapped[str] = mapped_column(String(8), default="auto") + # Ask for a post-print outcome verdict when this job completes (#1898). + # Copied onto the archive as confirm_requested at dispatch, like plate_id. + confirm_outcome: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0") # Preheat / heat-soak override (#1468). 'inherit' uses the global # preheat_enabled setting; 'on' / 'off' force the per-item decision. The diff --git a/backend/app/schemas/archive.py b/backend/app/schemas/archive.py index 91b628ef6..5906f35dc 100644 --- a/backend/app/schemas/archive.py +++ b/backend/app/schemas/archive.py @@ -32,6 +32,14 @@ class ArchiveUpdate(ArchiveBase): project_id: int | None = None # Allow changing status (e.g., clearing failed flag) status: str | None = None + # Post-print quality verdict (#1898): 'good' / 'reject'; null clears it. + user_verdict: str | None = Field(default=None, pattern="^(good|reject)$") + # Which surface the verdict came from, for the "recorded when the plate was + # cleared" hint. Only the sources a client can honestly claim are accepted; + # 'link', 'plate_clear' and 'reaction' are stamped server-side by the paths + # that own them and must not be forgeable over this route. Omitted means + # '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 @@ -109,6 +117,12 @@ class ArchiveResponse(BaseModel): failure_reason: str | None quantity: int = 1 # Number of items printed + # Post-print outcome confirmation (#1898) + user_verdict: str | None = None + user_verdict_source: str | None = None + user_verdict_at: datetime | None = None + confirm_requested: bool = False + # Energy tracking energy_kwh: float | None = None energy_cost: float | None = None diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py index 22aff3682..6f0c05f59 100644 --- a/backend/app/schemas/notification.py +++ b/backend/app/schemas/notification.py @@ -82,6 +82,12 @@ class NotificationProviderBase(BaseModel): default=False, description="Notify when a finished print is waiting for plate-clear confirmation" ) + # Event triggers - Post-print outcome confirmation (#1898) + on_print_confirm_request: bool = Field( + default=True, + description="Notify with one-tap verdict links when a print that opted in asks for its outcome", + ) + # Event triggers - Bed cooled on_bed_cooled: bool = Field(default=False, description="Notify when bed cools after print") @@ -188,6 +194,9 @@ class NotificationProviderUpdate(BaseModel): on_plate_not_empty: bool | None = None on_plate_clear_required: bool | None = None + # Event triggers - Post-print outcome confirmation (#1898) + on_print_confirm_request: bool | None = None + # Event triggers - Bed cooled on_bed_cooled: bool | None = None diff --git a/backend/app/schemas/print_log.py b/backend/app/schemas/print_log.py index df48fe1b4..f6c492652 100644 --- a/backend/app/schemas/print_log.py +++ b/backend/app/schemas/print_log.py @@ -28,6 +28,7 @@ class PrintLogEntrySchema(BaseModel): energy_kwh: float | None = None energy_cost: float | None = None failure_reason: str | None = None + user_verdict: str | None = None # Post-print quality verdict (#1898) thumbnail_path: str | None = None created_by_id: int | None = None created_by_username: str | None = None diff --git a/backend/app/schemas/print_queue.py b/backend/app/schemas/print_queue.py index f53557e17..cafe9c2ce 100644 --- a/backend/app/schemas/print_queue.py +++ b/backend/app/schemas/print_queue.py @@ -103,6 +103,8 @@ class PrintQueueItemCreate(BaseModel): # Nozzle offset calibration — dual-nozzle printers only (#1682). The MQTT # layer ignores the value on single-nozzle printers so the wire stays "skip". nozzle_offset_cali: TriState = "auto" + # Ask for a post-print outcome verdict when this job completes (#1898) + confirm_outcome: bool = False # Preheat / heat-soak per-item override (#1468). 'inherit' uses the global # preheat_enabled setting; 'on' / 'off' force the decision. The chamber # target falls through: this override → max(filament-map[loaded tray]) → 0. @@ -155,6 +157,7 @@ class PrintQueueItemUpdate(BaseModel): timelapse: bool | None = None use_ams: bool | None = None nozzle_offset_cali: TriState | None = None + confirm_outcome: bool | None = None preheat_override: Literal["inherit", "on", "off"] | None = None preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C) # Auto-print G-code injection @@ -215,6 +218,7 @@ class PrintQueueItemResponse(BaseModel): timelapse: bool = False use_ams: bool = True nozzle_offset_cali: TriState = "auto" + confirm_outcome: bool = False preheat_override: Literal["inherit", "on", "off"] = "inherit" preheat_chamber_target_override: int | None = None status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"] @@ -339,6 +343,7 @@ class PrintQueueBulkUpdate(BaseModel): timelapse: bool | None = None use_ams: bool | None = None nozzle_offset_cali: TriState | None = None + confirm_outcome: bool | None = None preheat_override: Literal["inherit", "on", "off"] | None = None preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C) # Auto-print G-code injection diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 65e65a890..b6357f9e4 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -397,6 +397,24 @@ class AppSettings(BaseModel): default="auto", description="Default nozzle offset calibration option for new prints (dual-nozzle printers only)", ) + default_confirm_outcome: bool = Field( + default=False, + description="Default for asking for a post-print outcome verdict on new prints (#1898)", + ) + confirm_outcome_external_prints: bool = Field( + default=False, + description=( + "Also ask for the outcome of prints Bambuddy archived but did not dispatch — started at " + "the printer, in Bambu Studio or in the Handy app (#1898)" + ), + ) + confirm_default_good_on_plate_clear: bool = Field( + default=False, + description=( + "When the build plate is released (manual acknowledgment or next dispatch) with the " + "outcome prompt still unanswered, record the print as a good part (#1898)" + ), + ) # Staggered batch start for multi-printer jobs stagger_group_size: int = Field( @@ -737,6 +755,9 @@ class AppSettingsUpdate(BaseModel): default_layer_inspect: bool | None = None default_timelapse: bool | None = None default_nozzle_offset_cali: TriState | None = None + default_confirm_outcome: bool | None = None + confirm_outcome_external_prints: bool | None = None + confirm_default_good_on_plate_clear: bool | None = None stagger_group_size: int | None = Field(default=None, ge=1, le=50) stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60) billing_enabled: bool | None = None diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 3da2b9ba3..7b42760eb 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -179,6 +179,40 @@ def a2l_lite_wire_ids(ams_id: int, tray_id: int) -> tuple[int, int, int] | None: ) +def resolve_external_spools_in_mapping(ams_mapping: object, ams_mapping2: object, is_dual_nozzle: bool) -> object: + """Put the external spool back into a captured flat ``ams_mapping`` (#3166). + + The firmware rejects 254/255 in the flat list, so BambuStudio and our own + dispatch both write an external spool there as -1 -- the same value as a + slot that isn't fed at all -- and carry the real target only in + ``ams_mapping2`` as ``{ams_id: 254|255, slot_id: 0}``. Capturing the flat + list alone turns "printed from the external spool" into "unmapped", and + usage then lands on whatever tray the fallback guesses. + + Each -1 whose ``ams_mapping2`` entry names an external spool becomes the + global tray the rest of Bambuddy uses: the ams_id itself on dual-nozzle + printers (254 = left/deputy, 255 = right/main), 254 on single-nozzle + printers, which have one external spool that the wire always calls 255. + ``{255, 255}`` is the unmapped marker and stays -1. Every other entry is + left exactly as captured. Without a usable ``ams_mapping2`` the input is + returned unchanged. + """ + if not isinstance(ams_mapping, list) or not isinstance(ams_mapping2, list): + return ams_mapping + if len(ams_mapping2) != len(ams_mapping): + return ams_mapping + resolved = list(ams_mapping) + for i, (flat, detail) in enumerate(zip(ams_mapping, ams_mapping2, strict=True)): + if flat != -1 or not isinstance(detail, dict): + continue + ams_id = detail.get("ams_id") + slot_id = detail.get("slot_id") + if ams_id not in (254, 255) or slot_id == 255: + continue + resolved[i] = ams_id if is_dual_nozzle else 254 + return resolved + + def apply_tray_exist_bits( units: list, tray_exist_bits_str: str | int | None, @@ -1988,7 +2022,7 @@ class BambuMQTTClient: self.state.current_project_url = url self.state.last_project_url = url if "ams_mapping" in print_data: - self._captured_ams_mapping = print_data["ams_mapping"] + self._captured_ams_mapping = self._resolve_captured_mapping(print_data) logger.info( "[%s] Captured ams_mapping from print command: %s", self.serial_number, @@ -2068,13 +2102,24 @@ class BambuMQTTClient: # already captured this print's mapping that copy is the slicer's own, # and the echo can arrive without the field at all. if self._captured_ams_mapping is None and isinstance(print_data.get("ams_mapping"), list): - self._captured_ams_mapping = print_data["ams_mapping"] + self._captured_ams_mapping = self._resolve_captured_mapping(print_data) logger.info( "[%s] Captured ams_mapping from print response: %s", self.serial_number, self._captured_ams_mapping, ) + def _resolve_captured_mapping(self, print_data: dict) -> object: + """The ``ams_mapping`` of a project_file, with external spools resolved + from its ``ams_mapping2`` (#3166).""" + from backend.app.utils.printer_models import is_dual_nozzle_model + + return resolve_external_spools_in_mapping( + print_data.get("ams_mapping"), + print_data.get("ams_mapping2"), + self._is_dual_nozzle or is_dual_nozzle_model(self.model), + ) + @staticmethod def _project_file_key(print_data: dict) -> str: """Identity of a project_file dispatch, for telling ours from a slicer's. @@ -3185,15 +3230,16 @@ class BambuMQTTClient: # slot (typically 0) when the active feed is actually the # external spool. X1C / P1S / A1 correctly report 254 in # that case; H2S does not. When the slicer-captured - # ams_mapping is all-external (every entry == -1), the - # print can only be feeding from the external spool, so - # promote tray_now to 254. Mixed (e.g. [5, -1]) and + # ams_mapping is all-external (every entry is 254, or -1 + # when the command carried no ams_mapping2 to resolve it + # from, #3166), the print can only be feeding from the + # external spool, so promote tray_now to 254. Mixed (e.g. [5, 254]) and # AMS-only mappings are NOT overridden — there's no # evidence the firmware misreports in those cases. Prints # started without a captured mapping (printer-screen start, # or before Bambuddy connected) fall through unchanged. captured = self._captured_ams_mapping - if captured and all(s == -1 for s in captured): + if captured and all(s in (-1, 254, 255) for s in captured): if self.state.tray_now != 254: logger.debug( f"[{self.serial_number}] tray_now external-spool override (#1822): " diff --git a/backend/app/services/failure_analysis.py b/backend/app/services/failure_analysis.py index eae246f87..d99cb410d 100644 --- a/backend/app/services/failure_analysis.py +++ b/backend/app/services/failure_analysis.py @@ -103,6 +103,29 @@ class FailureAnalysisService: outcome_prints = successful_prints + failed_prints failure_rate = (failed_prints / outcome_prints * 100) if outcome_prints > 0 else 0 + # Quality dimension (#1898): a completed print the user marked as + # reject is machine-success but scrap. Kept OUT of failure_rate — that + # number stays the machine's — and reported separately, with a yield + # rate that treats rejects as non-good output. + rejected_result = await self.db.execute( + select(func.count(PrintLogEntry.id)).where( + and_(*base_filter, PrintLogEntry.status == "completed", PrintLogEntry.user_verdict == "reject") + ) + ) + rejected_prints = rejected_result.scalar() or 0 + yield_rate = ((successful_prints - rejected_prints) / outcome_prints * 100) if outcome_prints > 0 else 0 + + rejects_reason_result = await self.db.execute( + select( + PrintLogEntry.failure_reason, + func.count(PrintLogEntry.id).label("count"), + ) + .where(and_(*base_filter, PrintLogEntry.status == "completed", PrintLogEntry.user_verdict == "reject")) + .group_by(PrintLogEntry.failure_reason) + .order_by(func.count(PrintLogEntry.id).desc()) + ) + rejects_by_reason = {(row[0] or "Unknown"): row[1] for row in rejects_reason_result.fetchall()} + # Failures by reason reason_result = await self.db.execute( select( @@ -257,6 +280,9 @@ class FailureAnalysisService: "total_prints": total_prints, "failed_prints": failed_prints, "failure_rate": round(failure_rate, 1), + "rejected_prints": rejected_prints, + "yield_rate": round(yield_rate, 1), + "rejects_by_reason": rejects_by_reason, "failures_by_reason": failures_by_reason, "failures_by_filament": failures_by_filament, "failures_by_printer": failures_by_printer, diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 278a3fc9f..944deb540 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from backend.app.models.notification import NotificationDigestQueue, NotificationLog, NotificationProvider from backend.app.models.notification_template import NotificationTemplate +from backend.app.services.print_confirmation import one_tap_url logger = logging.getLogger(__name__) @@ -318,11 +319,13 @@ class NotificationService: else: return False, f"HTTP {response.status_code}: {response.text[:200]}" - async def _send_bark(self, config: dict, title: str, message: str) -> tuple[bool, str]: + async def _send_bark(self, config: dict, title: str, message: str, url: str | None = None) -> tuple[bool, str]: """Send notification via Bark, the self-hostable iOS push service (#1495). POSTs JSON to {server}/push. Defaults to the official api.day.app relay; a self-hosted bark-server works by overriding the server URL. + ``url`` opens on tap — the outcome confirmation (#1898) deep-links + into the archive's confirmation dialog with it. """ server = (config.get("server") or "https://api.day.app").strip().rstrip("/") device_key = (config.get("device_key") or "").strip() @@ -348,6 +351,8 @@ class NotificationService: level = (config.get("level") or "").strip() if level in ("active", "timeSensitive", "critical", "passive"): payload["level"] = level + if url: + payload["url"] = url client = await self._get_client() response = await client.post(f"{server}/push", json=payload) @@ -375,8 +380,14 @@ class NotificationService: message: str, image_data: bytes | None = None, event_type: str | None = None, + actions: str | None = None, ) -> tuple[bool, str]: - """Send notification via ntfy.""" + """Send notification via ntfy. + + ``actions`` is a pre-built value for ntfy's Actions header (simple + format), used by the outcome-confirmation event (#1898) to put + one-tap Good/Reject buttons directly into the push notification. + """ server = config.get("server", "https://ntfy.sh").rstrip("/") topic = config.get("topic", "").strip() auth_token = config.get("auth_token", "").strip() @@ -420,6 +431,9 @@ class NotificationService: if auth_token: headers["Authorization"] = f"Bearer {auth_token}" + if actions: + headers["Actions"] = actions + client = await self._get_client() if image_data: @@ -452,7 +466,13 @@ class NotificationService: return False, _opaque_http_failure(response, label="ntfy server") async def _send_pushover( - self, config: dict, title: str, message: str, image_data: bytes | None = None + self, + config: dict, + title: str, + message: str, + image_data: bytes | None = None, + url: str | None = None, + url_title: str | None = None, ) -> tuple[bool, str]: """Send notification via Pushover. @@ -461,6 +481,8 @@ class NotificationService: title: Notification title message: Notification body image_data: Optional JPEG image bytes to attach (max 2.5MB) + url: Optional supplementary URL shown under the message + url_title: Optional label for that URL """ user_key = config.get("user_key", "").strip() app_token = config.get("app_token", "").strip() @@ -472,7 +494,7 @@ class NotificationService: if not user_key or not app_token: return False, "User key and app token are required" - url = "https://api.pushover.net/1/messages.json" + api_url = "https://api.pushover.net/1/messages.json" data = { "token": app_token, "user": user_key, @@ -480,6 +502,10 @@ class NotificationService: "message": message, "priority": priority, } + if url: + data["url"] = url + if url_title: + data["url_title"] = url_title # Emergency priority (2) keeps re-alerting until acknowledged, so # Pushover *requires* retry (how often, >= 30s) and expire (when to @@ -502,9 +528,9 @@ class NotificationService: if image_data: # Pushover supports image attachments via multipart form-data files = {"attachment": ("photo.jpg", image_data, "image/jpeg")} - response = await client.post(url, data=data, files=files) + response = await client.post(api_url, data=data, files=files) else: - response = await client.post(url, data=data) + response = await client.post(api_url, data=data) if response.status_code == 200: return True, "Message sent successfully" @@ -516,8 +542,21 @@ class NotificationService: except Exception: return False, f"HTTP {response.status_code}: {response.text[:200]}" - async def _send_telegram(self, config: dict, message: str, image_data: bytes | None = None) -> tuple[bool, str]: - """Send notification via Telegram bot.""" + async def _send_telegram( + self, + config: dict, + message: str, + image_data: bytes | None = None, + buttons: list[dict] | None = None, + link_preview: bool = True, + ) -> tuple[bool, str]: + """Send notification via Telegram bot. + + ``buttons`` is one row of inline URL buttons (``{"text", "url"}`` + entries), used by the outcome-confirmation event (#1898) to put + one-tap Good/Reject under the message. ``link_preview=False`` asks + Telegram not to fetch the first URL in the text for a preview card. + """ bot_token = config.get("bot_token", "").strip() chat_id = config.get("chat_id", "").strip() @@ -548,36 +587,60 @@ class NotificationService: client = await self._get_client() - if image_data: - # Use sendPhoto to attach the thumbnail with the caption - url = f"https://api.telegram.org/bot{bot_token}/sendPhoto" - form: dict[str, Any] = {"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"} - if message_thread_id is not None: - form["message_thread_id"] = message_thread_id - response = await client.post( - url, - data=form, - files={"photo": ("photo.jpg", image_data, "image/jpeg")}, - ) - else: + async def _post(with_buttons: bool): + if image_data: + # Use sendPhoto to attach the thumbnail with the caption + url = f"https://api.telegram.org/bot{bot_token}/sendPhoto" + form: dict[str, Any] = {"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"} + if message_thread_id is not None: + form["message_thread_id"] = message_thread_id + if with_buttons: + # Multipart form fields are strings — reply_markup goes JSON-encoded. + form["reply_markup"] = json.dumps({"inline_keyboard": [buttons]}) + return await client.post( + url, + data=form, + files={"photo": ("photo.jpg", image_data, "image/jpeg")}, + ) + url = f"https://api.telegram.org/bot{bot_token}/sendMessage" - data: dict[str, Any] = { + payload: dict[str, Any] = { "chat_id": chat_id, "text": message, "parse_mode": "Markdown", } + if not link_preview: + payload["disable_web_page_preview"] = True if message_thread_id is not None: - data["message_thread_id"] = message_thread_id - response = await client.post(url, json=data) + payload["message_thread_id"] = message_thread_id + if with_buttons: + payload["reply_markup"] = {"inline_keyboard": [buttons]} + return await client.post(url, json=payload) - if response.status_code == 200: - result = response.json() + def _failure(resp) -> str | None: + """What Telegram objected to, or None when the send went through.""" + if resp.status_code != 200: + return f"HTTP {resp.status_code}: {resp.text[:200]}" + result = resp.json() if result.get("ok"): - return True, "Message sent successfully" - else: - return False, f"Telegram error: {result.get('description', 'Unknown error')}" - else: - return False, f"HTTP {response.status_code}: {response.text[:200]}" + return None + return f"Telegram error: {result.get('description', 'Unknown error')}" + + response = await _post(bool(buttons)) + failure = _failure(response) + if failure and buttons: + # Telegram validates every inline-keyboard URL and refuses the whole + # send when one of them is not a URL it accepts — which is what an + # install without a public external_url produces for the #1898 + # verdict links. Dropping the buttons is survivable; dropping the + # message the user is waiting for is not. + logger.warning("Telegram refused the message with inline buttons (%s); retrying without them", failure) + response = await _post(False) + failure = _failure(response) + + if failure: + return False, failure + return True, "Message sent successfully" async def _send_email( self, @@ -777,6 +840,21 @@ class NotificationService: if payload_format == "slack": # Slack/Mattermost format - just text field data = {"text": f"*{title}*\n{message}"} + if event_type == "print_confirm_request": + # Slack and Mattermost fetch every URL in the text to build + # preview cards, and the outcome prompt (#1898) is the one + # message whose links are single-use capabilities — that fetch + # would be a machine answering the operator's question. Off + # here for the same reason Telegram's preview is. + # + # Only here: the slack payload never attaches image bytes (the + # base64 attach below is generic-format only), so unfurling is + # the only way a {finish_photo_url} in a print_complete body + # can render as a photo in the channel. Switching it off for + # every event would quietly take that away with no setting to + # get it back. + data["unfurl_links"] = False + data["unfurl_media"] = False else: # Generic format with custom field names custom_field_title = config.get("field_title", "title").strip() or "title" @@ -947,11 +1025,85 @@ class NotificationService: if provider.provider_type == "callmebot": return await self._send_callmebot(config, f"{title}\n{message}") elif provider.provider_type == "ntfy": - return await self._send_ntfy(config, title, message, image_data=image_data, event_type=event_type) + # Outcome confirmation (#1898): render the verdict capability + # links as one-tap buttons on the notification itself. http so + # no browser needs to open; POST because that is the method + # that records — a GET only opens the confirmation page, which + # is what keeps unfurlers from answering the prompt. + # clear=true dismisses the notification once a button was tapped. + ntfy_actions = None + good_url = (variables or {}).get("good_url") + reject_url = (variables or {}).get("reject_url") + # Buttons need absolute URLs; without a configured external_url + # the links are relative, and the body's deep link into the + # archive has to do. + if ( + event_type == "print_confirm_request" + and good_url + and reject_url + and good_url.startswith("http") + and reject_url.startswith("http") + ): + ntfy_actions = ( + f"http, Good, {good_url}, method=POST, clear=true; " + f"http, Reject, {reject_url}, method=POST, clear=true" + ) + return await self._send_ntfy( + config, title, message, image_data=image_data, event_type=event_type, actions=ntfy_actions + ) elif provider.provider_type == "pushover": - return await self._send_pushover(config, title, message, image_data=image_data) + # Outcome confirmation (#1898): Pushover has no arbitrary + # buttons, but supports one supplementary URL — deep-link into + # the archive's confirmation dialog. + supplement_url = None + supplement_url_title = None + _confirm_url = (variables or {}).get("confirm_url") + if event_type == "print_confirm_request" and _confirm_url and _confirm_url.startswith("http"): + supplement_url = _confirm_url + supplement_url_title = "Confirm print outcome" + return await self._send_pushover( + config, title, message, image_data=image_data, url=supplement_url, url_title=supplement_url_title + ) elif provider.provider_type == "telegram": - return await self._send_telegram(config, f"*{title}*\n{message}", image_data=image_data) + # Outcome confirmation (#1898): inline URL buttons under the + # message — one tap records the verdict via the capability + # link. Same absolute-URL requirement as the ntfy actions. + # Telegram has no way to POST, so this is the one affordance + # that opens a browser, and it is the one that gets the one-tap + # marker: the page submits itself only for a URL that came off + # a button. Telegram does not fetch inline-keyboard URLs and + # nothing else can read them, so the marker never reaches a + # scanner — which is the difference between this and trusting + # the User-Agent. + tg_buttons = None + _tg_good = (variables or {}).get("good_url") + _tg_reject = (variables or {}).get("reject_url") + if ( + event_type == "print_confirm_request" + and _tg_good + and _tg_reject + and _tg_good.startswith("http") + and _tg_reject.startswith("http") + ): + tg_buttons = [ + {"text": "\U0001f44d Good", "url": one_tap_url(_tg_good)}, + {"text": "\U0001f44e Reject", "url": one_tap_url(_tg_reject)}, + ] + # Telegram's servers GET the first URL in the text to build a + # preview card. An outcome prompt whose edited body still + # carries {good_url} would have that fetch answer the question + # before the operator saw it, so the preview is off for this + # event. Only for this one, for the same reason as the Slack + # unfurl in _send_webhook: when the finish photo is too large to attach, + # the preview is how a {finish_photo_url} in a print_complete + # body still shows up as a photo in the chat. + return await self._send_telegram( + config, + f"*{title}*\n{message}", + image_data=image_data, + buttons=tg_buttons, + link_preview=event_type != "print_confirm_request", + ) elif provider.provider_type == "email": # finish_photo_url is pulled from the rendered template variables # so _send_email can detect whether the template referenced the @@ -969,7 +1121,13 @@ class NotificationService: elif provider.provider_type == "homeassistant": return await self._send_homeassistant(config, title, message, db=db) elif provider.provider_type == "bark": - return await self._send_bark(config, title, message) + # Outcome confirmation (#1898): Bark opens one URL on tap — + # deep-link into the confirmation dialog, like Pushover. + bark_url = None + _bark_confirm = (variables or {}).get("confirm_url") + if event_type == "print_confirm_request" and _bark_confirm and _bark_confirm.startswith("http"): + bark_url = _bark_confirm + return await self._send_bark(config, title, message, url=bark_url) else: return False, f"Unknown provider type: {provider.provider_type}" except Exception as e: @@ -1311,6 +1469,62 @@ class NotificationService: variables=variables, ) + async def on_print_confirm_request( + self, + printer_id: int, + printer_name: str, + data: dict, + db: AsyncSession, + archive_data: dict | None = None, + good_url: str | None = None, + reject_url: str | None = None, + confirm_url: str | None = None, + ): + """Ask for a post-print outcome verdict (#1898). + + Fires only for completed prints whose queue item opted in — the + provider-level toggle exists to mute a channel, not to enable the + feature. good_url / reject_url are the one-tap capability links + (rendered as ntfy action buttons), confirm_url deep-links into the + archive's confirmation dialog in the web UI. + """ + providers = await self._get_providers_for_event(db, "on_print_confirm_request", printer_id) + if not providers: + return + + subtask_name = data.get("subtask_name") + if subtask_name: + filename = subtask_name.replace("_", " ") + else: + filename = self._clean_filename(data.get("filename", "Unknown")) + + variables = {"printer": printer_name, "filename": filename} + if good_url: + variables["good_url"] = good_url + if reject_url: + variables["reject_url"] = reject_url + if confirm_url: + variables["confirm_url"] = confirm_url + + image_data = None + if archive_data: + if archive_data.get("finish_photo_url"): + variables["finish_photo_url"] = archive_data["finish_photo_url"] + image_data = archive_data.get("image_data") + + title, message = await self._build_message_from_template(db, "print_confirm_request", variables) + await self._send_to_providers( + providers, + title, + message, + db, + "print_confirm_request", + printer_id, + printer_name, + image_data=image_data, + variables=variables, + ) + async def on_print_progress( self, printer_id: int, diff --git a/backend/app/services/print_batch.py b/backend/app/services/print_batch.py index 1d28ed568..02e8a0d1d 100644 --- a/backend/app/services/print_batch.py +++ b/backend/app/services/print_batch.py @@ -72,6 +72,7 @@ CLONED_SETTING_COLUMNS = ( "timelapse", "use_ams", "nozzle_offset_cali", + "confirm_outcome", "preheat_override", "preheat_chamber_target_override", "skip_filament_check", diff --git a/backend/app/services/print_confirmation.py b/backend/app/services/print_confirmation.py new file mode 100644 index 000000000..87c5342d4 --- /dev/null +++ b/backend/app/services/print_confirmation.py @@ -0,0 +1,193 @@ +"""Post-print outcome confirmation helpers (#1898).""" + +import logging +from collections.abc import Mapping +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.models.archive import PrintArchive +from backend.app.models.print_log import PrintLogEntry + +logger = logging.getLogger(__name__) + +# How a verdict reached the archive. 'reaction' is written by the Telegram +# reaction handler (#3046), which lives on its own branch — listed here so the +# vocabulary is complete and the UI can label it the day that lands. +VERDICT_SOURCES = ("dialog", "link", "plate_clear", "printer_card", "api", "reaction") + +# Link-preview unfurlers and mail-security scanners fetch every URL they find in +# a message, unattended, within seconds of it being sent. Nothing they can do +# with a GET records a verdict any more -- that is the POST route's job -- so +# this list is the second layer: it decides whether the confirmation page +# submits its own form, which is what keeps a human at one tap. A scanner that +# runs JavaScript would otherwise press the button on the operator's behalf. +# Matched as case-insensitive substrings of the User-Agent; the generic "bot" +# token covers TelegramBot, Discordbot, Slackbot-LinkExpanding, Twitterbot and +# LinkedInBot in one go. +UNATTENDED_FETCH_AGENTS = ( + "bot", + "crawler", + "spider", + "facebookexternalhit", + "whatsapp", + "skypeuripreview", + "bingpreview", + "safelinks", + "urldefense", + "proofpoint", + "mimecast", + "barracuda", + "forcepoint", +) + +# Prefetch / preload hints. A finger on a notification button is never one. +UNATTENDED_FETCH_HEADERS = { + "purpose": ("prefetch", "preview"), + "x-purpose": ("prefetch", "preview"), + "x-moz": ("prefetch",), + "sec-purpose": ("prefetch",), +} + + +def is_unattended_fetch(method: str, headers: Mapping[str, str]) -> bool: + """Whether a request for a one-tap verdict link came from a machine. + + Decides whether the confirmation page submits itself. False positives are + deliberately cheap -- a browser mistaken for a bot gets the same page with + a button to press -- so the lists above err towards catching more. + """ + if method.upper() != "GET": + # Anything that is not the page load is not a page load: only the GET + # route renders, and only a GET can be widened to HEAD by a future + # router change. + return True + for header, markers in UNATTENDED_FETCH_HEADERS.items(): + value = (headers.get(header) or "").lower() + if value and any(marker in value for marker in markers): + return True + agent = (headers.get("user-agent") or "").lower() + return any(marker in agent for marker in UNATTENDED_FETCH_AGENTS) + + +# The one-tap marker. The confirmation page submits its own form only when the +# URL it was opened from carries this, and the marker is put on exactly one +# thing: the Telegram inline keyboard's buttons -- an affordance no unfurler, +# gateway or proxy reads, for the same reason the capability URLs themselves no +# longer travel in message text. +# +# It is what closes the gap the User-Agent list above cannot: a mail-security +# sandbox that renders HTML and runs JavaScript sends an ordinary Chrome string +# (so does literal HeadlessChrome), and a verdict URL that reached it did so out +# of the message BODY -- where the marker never appears. That fetch now gets the +# page with a button on it and records nothing. The operator's tap on the +# notification button still costs exactly one tap. +ONE_TAP_PARAM = "tap" + + +def one_tap_url(url: str) -> str: + """Mark a verdict URL as one a human is about to press. + + Only for the affordances a person taps directly. A URL that goes into text + anybody's machine might follow is left unmarked on purpose. + """ + return f"{url}{'&' if '?' in url else '?'}{ONE_TAP_PARAM}=1" + + +def is_one_tap_request(query_params: Mapping[str, str]) -> bool: + """Whether this page load came from a button rather than from message text.""" + return (query_params.get(ONE_TAP_PARAM) or "") == "1" + + +def stamp_verdict(archive: PrintArchive, source: str) -> None: + """Record a verdict's provenance and the moment it landed (#1898). + + Both fields move together on every verdict write, which is what keeps the + "already answered" page from pairing a new source with the timestamp of an + older decision. `retire_confirm_token` is separate on purpose: spending the + one-tap capability happens once, recording a verdict can happen again. + """ + archive.user_verdict_source = source + archive.user_verdict_at = datetime.now(timezone.utc) + + +def retire_confirm_token(archive: PrintArchive) -> None: + """Spend the one-tap capability token without destroying it. + + The token is still single-use: once ``confirm_token_used_at`` is stamped, + no verdict path accepts it again. Keeping the VALUE is what lets the + one-tap route recognise a link belonging to an already-answered print and + say so, instead of 404ing as if the link had never been real (the live-farm + case: the plate-clear default answered the prompt, then the user tapped the + Telegram button and got "invalid or already used"). + """ + if archive.confirm_token and archive.confirm_token_used_at is None: + archive.confirm_token_used_at = datetime.now(timezone.utc) + + +async def resolve_pending_confirmation_as_good(db: AsyncSession, printer_id: int) -> int | None: + """Mark the printer's latest pending-confirmation archive as good. + + Backs the opt-in ``confirm_default_good_on_plate_clear`` setting: releasing + the build plate is the moment the operator moves on to the next job, so an + unanswered outcome prompt can default to "good part" right there instead of + lingering as unconfirmed. Only the LATEST pending archive is resolved — the + plate release refers to the print that just came off the plate, not to + older unanswered prompts. + + Mirrors the verdict onto the latest PrintLogEntry (the #1444 mirror) and + retires the one-tap capability token. Deliberately does NOT commit — both + callers (the clear-plate route and the queue dispatcher) manage their own + transaction. + + Returns the resolved archive id, or None when nothing was pending. + """ + archive = await db.scalar( + select(PrintArchive) + .where( + PrintArchive.printer_id == printer_id, + PrintArchive.status == "completed", + PrintArchive.confirm_requested.is_(True), + PrintArchive.user_verdict.is_(None), + ) + .order_by(PrintArchive.id.desc()) + .limit(1) + ) + if archive is None: + return None + + archive.user_verdict = "good" + stamp_verdict(archive, "plate_clear") + retire_confirm_token(archive) + + latest_entry = await db.scalar( + select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc()).limit(1) + ) + if latest_entry is not None: + latest_entry.user_verdict = "good" + + logger.info("[#1898] Plate clear defaulted archive %s to 'good' (printer %s)", archive.id, printer_id) + return archive.id + + +async def confirm_outcome_for_new_queue_item(db: AsyncSession, *, started_outside_bambuddy: bool = False) -> bool: + """The ask-for-outcome flag for a queue item created without the print dialog. + + The dialog seeds its own per-job toggle from ``default_confirm_outcome``. + Every other queue-creation path -- the virtual printer, the library bulk + add, the webhook, a pipeline run -- has no toggle to seed and used to leave + the column at its ``False`` default, so "Ask for Outcome" only ever reached + jobs queued by hand. + + ``started_outside_bambuddy`` additionally honours + ``confirm_outcome_external_prints``: a plate sent from Bambu Studio to a + virtual printer is one of the prints that setting's description names, but + it arrives with a queue item, so ``on_print_start`` never sees it as + external and the setting could not otherwise reach it. + """ + from backend.app.api.routes.settings import get_setting, setting_is_true + + if setting_is_true(await get_setting(db, "default_confirm_outcome")): + return True + return started_outside_bambuddy and setting_is_true(await get_setting(db, "confirm_outcome_external_prints")) diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 482701b43..742128c5f 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -6379,6 +6379,12 @@ class PrintScheduler: if archive.plate_id is None and item.plate_id is not None: archive.plate_id = item.plate_id + # Ask-for-outcome opt-in rides from the queue item to the archive + # the same way (#1898); never cleared here so a reprint of an + # archive that already asked keeps asking. + if item.confirm_outcome: + archive.confirm_requested = True + file_path = settings.base_dir / archive.file_path filename = archive.filename @@ -6439,6 +6445,8 @@ class PrintScheduler: ) if archive: item.archive_id = archive.id + if item.confirm_outcome: + archive.confirm_requested = True # ask-for-outcome opt-in (#1898) if budget_reservation is not None: budget_reservation.print_archive_id = archive.id if item.cleanup_library_after_dispatch and not library_file.is_external: @@ -6968,6 +6976,16 @@ class PrintScheduler: # Clear the awaiting-plate-clear flag now that we're starting a new print printer_manager.set_awaiting_plate_clear(item.printer_id, False) + + # #1898: with the opt-in default-good setting, moving on to the next + # print resolves the previous print's unanswered outcome prompt as + # "good" — this path also covers the camera-based plate detection, + # which releases the gate by allowing dispatch rather than by an + # explicit acknowledgment. Rides on the dispatch transaction. + if await self._get_bool_setting(db, "confirm_default_good_on_plate_clear", default=False): + from backend.app.services.print_confirmation import resolve_pending_confirmation_as_good + + await resolve_pending_confirmation_as_good(db, item.printer_id) logger.info("Queue item %s: Status set to 'printing', sending print command...", item.id) # Capture state before dispatch so the watchdog can detect whether the diff --git a/backend/app/services/usage_tracker.py b/backend/app/services/usage_tracker.py index d8b6981f0..b05efc78b 100644 --- a/backend/app/services/usage_tracker.py +++ b/backend/app/services/usage_tracker.py @@ -1507,6 +1507,24 @@ async def _track_from_3mf( mapping_source or "none", ) + # A mapping that names no tray for any slot the print used carries no + # evidence at all -- typically a flat ams_mapping whose external spool is + # -1 and came without an ams_mapping2 to resolve it (#3166). Drop it so the + # tray_now evidence below decides, rather than leaving every slot unfed. + if slot_to_tray: + used_slots = [u.get("slot_id", 0) for u in filament_usage if u.get("used_g", 0) > 0] + if used_slots and all( + not (0 < s <= len(slot_to_tray) and isinstance(slot_to_tray[s - 1], int) and slot_to_tray[s - 1] >= 0) + for s in used_slots + ): + logger.info( + "[UsageTracker] 3MF: mapping %s names no tray for used slots %s — ignoring it", + slot_to_tray, + used_slots, + ) + slot_to_tray = None + mapping_source = None + # 5. For single-filament non-queue prints, use tray_now from printer state # Priority: tray_change_log (multi-tray split) > tray_now_at_start > current tray_now # > last_loaded_tray > vt_tray check @@ -1779,6 +1797,18 @@ async def _track_from_3mf( mapped = slot_to_tray[slot_id - 1] if isinstance(mapped, int) and mapped >= 0: global_tray_id = mapped + elif mapped == -1: + # The mapping says this slot isn't fed from any tray. The + # position-based guess below would hand it the Nth loaded + # tray anyway -- an AMS spool that never moved (#3166), or + # the tray another slot really used, which that slot then + # finds already handled and drops (#2880). + logger.info( + "[UsageTracker] 3MF: slot_id=%d is unmapped (-1) — nothing charged (used_g=%.1f)", + slot_id, + used_g, + ) + continue # Position-based default: sort available tray IDs so external spools (254/255) # naturally follow standard AMS trays, matching slicer slot numbering. # diff --git a/backend/app/services/virtual_printer/manager.py b/backend/app/services/virtual_printer/manager.py index 6718804ba..dd2a95c6b 100644 --- a/backend/app/services/virtual_printer/manager.py +++ b/backend/app/services/virtual_printer/manager.py @@ -838,6 +838,7 @@ class VirtualPrinterInstance: from backend.app.models.print_queue import PrintQueueItem from backend.app.services.archive import ArchiveService from backend.app.services.filament_requirements import extract_filament_requirements + from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item async with self._session_factory() as db: name_source = await get_setting(db, "virtual_printer_archive_name_source") @@ -905,6 +906,14 @@ class VirtualPrinterInstance: ) timelapse = _slicer_or("timelapse", _bool_setting(await get_setting(db, "default_timelapse"), False)) + # "Ask for Outcome" is a default print option like the ones + # above. A plate sent here from Bambu Studio is also one of the + # prints `confirm_outcome_external_prints` names — but it + # arrives with a queue item, so on_print_start resumes the + # archive created below instead of treating it as external, and + # without this the setting could never reach it (#1898). + confirm_outcome = await confirm_outcome_for_new_queue_item(db, started_outside_bambuddy=True) + # H2C dual-nozzle-rack slicer-pick preservation (#1780). # BambuStudio's project_file MQTT command for rack-swap models # (O1C2 today) carries `nozzle_mapping` — a per-filament array @@ -1133,6 +1142,7 @@ class VirtualPrinterInstance: vibration_cali=vibration_cali, layer_inspect=layer_inspect, timelapse=timelapse, + confirm_outcome=confirm_outcome, # Per-VP opt-in for auto-print G-code injection (#1516). # Default off; when on, the scheduler still no-ops unless # gcode_snippets are configured for the target model, so it's diff --git a/backend/tests/integration/test_confirm_outcome_queue_defaults_1898.py b/backend/tests/integration/test_confirm_outcome_queue_defaults_1898.py new file mode 100644 index 000000000..21b422ab7 --- /dev/null +++ b/backend/tests/integration/test_confirm_outcome_queue_defaults_1898.py @@ -0,0 +1,128 @@ +"""Every queue-creation path has to be able to ask for the outcome (#1898). + +``confirm_outcome`` rides from a queue item onto the archive at dispatch, and +the only place that ever set it was the print dialog. Jobs created anywhere +else -- a plate sent from Bambu Studio to a virtual printer, the Library's bulk +"Add to queue", the webhook, a pipeline run -- carried the column default and +were never asked about, however the install's settings were configured. + +That mattered most for the virtual printer: a Bambu Studio plate is one of the +prints ``confirm_outcome_external_prints`` names in its own description, but it +arrives with a queue item, so ``on_print_start`` resumes its archive instead of +treating it as external and the setting could not reach it at all. +""" + +from pathlib import Path +from uuid import uuid4 + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from backend.app.core.config import settings as app_settings +from backend.app.models.print_queue import PrintQueueItem +from backend.app.models.settings import Settings +from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item + +pytestmark = pytest.mark.integration + + +async def _set_setting(db_session, key: str, value: str) -> None: + db_session.add(Settings(key=key, value=value)) + await db_session.commit() + + +class TestTheDefaultForAQueueItemNobodyToggled: + @pytest.mark.asyncio + async def test_off_when_nothing_is_configured(self, db_session): + assert await confirm_outcome_for_new_queue_item(db_session) is False + assert await confirm_outcome_for_new_queue_item(db_session, started_outside_bambuddy=True) is False + + @pytest.mark.asyncio + async def test_the_per_job_default_covers_every_path(self, db_session): + """Same setting the print dialog seeds its own toggle from.""" + await _set_setting(db_session, "default_confirm_outcome", "true") + + assert await confirm_outcome_for_new_queue_item(db_session) is True + assert await confirm_outcome_for_new_queue_item(db_session, started_outside_bambuddy=True) is True + + @pytest.mark.asyncio + async def test_the_external_setting_only_covers_external_origins(self, db_session): + """A Bambu Studio plate counts; a pipeline run Bambuddy started itself + does not -- that one follows the per-job default like any queued job.""" + await _set_setting(db_session, "confirm_outcome_external_prints", "true") + + assert await confirm_outcome_for_new_queue_item(db_session, started_outside_bambuddy=True) is True + assert await confirm_outcome_for_new_queue_item(db_session) is False + + @pytest.mark.asyncio + async def test_an_odd_stored_value_counts_as_off(self, db_session): + await _set_setting(db_session, "default_confirm_outcome", "None") + + assert await confirm_outcome_for_new_queue_item(db_session) is False + + +@pytest.fixture +async def sliced_library_file(db_session): + """A library file that passes add-to-queue's gates: the filename has to look + sliced and the bytes have to exist under ``base_dir``.""" + from backend.app.models.library import LibraryFile + + # Unique per test: the suite runs with xdist and the teardown below would + # otherwise delete the bytes a sibling worker is still relying on, which the + # route answers with a 400 for a bulk add that queued nothing (#3112). + name = f"confirm_outcome_probe_{uuid4().hex}.gcode.3mf" + rel_path = f"archive/library/files/{name}" + abs_path = Path(app_settings.base_dir) / rel_path + abs_path.parent.mkdir(parents=True, exist_ok=True) + abs_path.write_bytes(b"probe") + + lib_file = LibraryFile( + filename=name, + file_path=rel_path, + file_size=5, + file_type="3mf", + ) + db_session.add(lib_file) + await db_session.commit() + await db_session.refresh(lib_file) + + yield lib_file + + abs_path.unlink(missing_ok=True) + + +async def _read_item(test_engine, item_id: int) -> PrintQueueItem: + """Fresh-session read: the route ran on its own ``Depends(get_db)`` session.""" + maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + async with maker() as fresh: + return (await fresh.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one() + + +class TestTheLibraryBulkAdd: + """The route has no per-job toggle at all, so the install-wide default is + the only thing that can decide.""" + + async def _add(self, async_client: AsyncClient, file_id: int) -> int: + response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [file_id]}) + assert response.status_code == 200 + added = response.json()["added"] + assert len(added) == 1 + return added[0]["queue_item_id"] + + @pytest.mark.asyncio + async def test_follows_the_default(self, async_client, db_session, test_engine, sliced_library_file): + await _set_setting(db_session, "default_confirm_outcome", "true") + + item_id = await self._add(async_client, sliced_library_file.id) + + assert (await _read_item(test_engine, item_id)).confirm_outcome is True + + @pytest.mark.asyncio + async def test_stays_off_for_an_install_that_never_asked_for_it( + self, async_client, db_session, test_engine, sliced_library_file + ): + item_id = await self._add(async_client, sliced_library_file.id) + + assert (await _read_item(test_engine, item_id)).confirm_outcome is False diff --git a/backend/tests/integration/test_external_print_confirmation_1898.py b/backend/tests/integration/test_external_print_confirmation_1898.py new file mode 100644 index 000000000..e85596822 --- /dev/null +++ b/backend/tests/integration/test_external_print_confirmation_1898.py @@ -0,0 +1,657 @@ +"""Ask for the outcome of prints Bambuddy did not start (#1898 follow-up). + +The ask-for-outcome flag rides from a queue item onto the archive at dispatch. +On a farm where most jobs are started at the printer's screen, in Bambu Studio +or in the Handy app there is no queue item to ride from, so every archive +``on_print_start`` created had ``confirm_requested`` false and the feature +looked broken. ``confirm_outcome_external_prints`` is the missing source. + +Both archive-creating branches of ``on_print_start`` are driven here against a +real database: the no-3MF fallback (what a P1S/A1 farm actually hits) and the +normal path that archives a downloaded 3MF. +""" + +from contextlib import ExitStack +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from backend.app.main import ( + _active_prints, + _expected_print_creators, + _expected_print_registered_at, + _expected_prints, + _print_ams_mappings, +) +from backend.app.models.archive import PrintArchive +from backend.app.models.print_queue import PrintQueueItem +from backend.app.models.settings import Settings + +DISPATCH = "/data/Metadata/plate_1.gcode" +SUBTASK = "Bracket_plate_1" + + +@pytest.fixture(autouse=True) +def _clear_print_state(): + dicts = ( + _expected_prints, + _expected_print_registered_at, + _expected_print_creators, + _print_ams_mappings, + _active_prints, + ) + for d in dicts: + d.clear() + yield + for d in dicts: + d.clear() + + +class _StubArchiveService: + """Stands in for ArchiveService on the downloaded-3MF branch. + + Writes the row the real service would write, without needing a parseable + 3MF on disk. Everything this test asserts happens *after* the row exists. + """ + + def __init__(self, db): + self.db = db + + async def archive_print(self, **kwargs): + archive = PrintArchive( + printer_id=kwargs.get("printer_id"), + filename=f"{SUBTASK}.gcode.3mf", + file_path=f"archives/test/{SUBTASK}.gcode.3mf", + file_size=2048, + print_name=SUBTASK, + status="printing", + started_at=datetime.now(timezone.utc), + ) + self.db.add(archive) + await self.db.commit() + await self.db.refresh(archive) + return archive + + +async def _drive_print_start(test_engine, printer, *, download_ok: bool, extra_patches=()) -> dict: + """Run ``on_print_start`` against the test database. + + ``download_ok`` picks the branch: False leaves the 3MF unreachable and the + no-3MF fallback archive is written inline; True takes the ArchiveService + branch. + """ + session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + state = MagicMock( + current_project_url=f"ftp://{SUBTASK}.gcode.3mf", + sdcard=True, + sdcard_reported=True, + ) + + patches = [ + patch("backend.app.main.async_session", session_maker), + patch("backend.app.core.database.async_session", session_maker), + patch("backend.app.main.download_file_async", new=AsyncMock(return_value=download_ok)), + patch("backend.app.main.download_file_try_paths_async", new=AsyncMock(return_value=None)), + patch("backend.app.main.get_cached_3mf", return_value=None), + patch("backend.app.main.cache_3mf_download"), + patch("backend.app.main.peek_plate_index_in_3mf", return_value=None), + patch("backend.app.main.ArchiveService", _StubArchiveService), + # Imported inside the function, so patching it anywhere else lets the + # directory walk open real sockets. + patch("backend.app.services.bambu_ftp.list_files_async", new=AsyncMock(return_value=[])), + patch("backend.app.main.ftps_handshake_blocked", return_value=False), + patch("backend.app.main.get_ftp_retry_settings", new=AsyncMock(return_value=(False, 3, 2.0, 30))), + patch("backend.app.main._record_energy_start", new_callable=AsyncMock), + patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock), + patch("backend.app.main._maybe_start_layer_timelapse"), + patch("backend.app.main._capture_timelapse_baseline_at_start", new_callable=AsyncMock), + # Real, it would spawn a task that outlives the test by a minute. + patch("backend.app.main._schedule_fallback_3mf_retry"), + patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock), + # Imported inside on_print_start; it would try to persist the mocked + # printer state's AMS trays and fail on the MagicMock values. + patch("backend.app.services.usage_tracker.on_print_start", new_callable=AsyncMock), + ] + + mocks: dict = {} + with ExitStack() as stack: + for p in [*patches, *extra_patches]: + mocks[p.attribute] = stack.enter_context(p) + notif = stack.enter_context(patch("backend.app.main.notification_service")) + plug = stack.enter_context(patch("backend.app.main.smart_plug_manager")) + ws = stack.enter_context(patch("backend.app.main.ws_manager")) + relay = stack.enter_context(patch("backend.app.main.mqtt_relay")) + pm = stack.enter_context(patch("backend.app.main.printer_manager")) + + notif.on_print_start = AsyncMock() + plug.on_print_start = AsyncMock() + ws.send_print_start = AsyncMock() + ws.send_archive_created = AsyncMock() + ws.send_archive_updated = AsyncMock() + relay.on_print_start = AsyncMock() + relay.on_archive_created = AsyncMock() + pm.get_status = MagicMock(return_value=state) + pm.get_printer = MagicMock(return_value=MagicMock(serial_number="TEST1898")) + + from backend.app.main import on_print_start + + await on_print_start(printer.id, {"filename": DISPATCH, "subtask_name": SUBTASK}) + return mocks + + +async def _set_setting(db_session, key: str, value: str) -> None: + db_session.add(Settings(key=key, value=value)) + await db_session.commit() + + +async def _created_archive(db_session, printer_id: int) -> PrintArchive: + db_session.expire_all() + archive = await db_session.scalar( + select(PrintArchive).where(PrintArchive.printer_id == printer_id).order_by(PrintArchive.id.desc()).limit(1) + ) + assert archive is not None, "on_print_start created no archive" + return archive + + +class TestExternalPrintGetsTheOutcomePrompt: + @pytest.mark.asyncio + @pytest.mark.integration + async def test_no_3mf_fallback_asks_when_the_setting_is_on(self, test_engine, db_session, printer_factory): + """The P1S/A1 case: the 3MF cannot be fetched, the archive is written + inline — and that is the row the completion path reads the flag off.""" + printer = await printer_factory() + await _set_setting(db_session, "confirm_outcome_external_prints", "true") + + await _drive_print_start(test_engine, printer, download_ok=False) + + archive = await _created_archive(db_session, printer.id) + assert archive.extra_data.get("no_3mf_available") is True + assert archive.confirm_requested is True + # What the completion path gates the prompt on. + assert archive.user_verdict is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_the_archive_reaches_the_rest_of_the_1898_machinery(self, test_engine, db_session, printer_factory): + """An externally started print is now a pending confirmation like any + other: the prompt the completion path emits is gated on exactly these + two fields, and the plate-release default resolves the same row.""" + from backend.app.services.print_confirmation import resolve_pending_confirmation_as_good + + printer = await printer_factory() + printer_id = printer.id + await _set_setting(db_session, "confirm_outcome_external_prints", "true") + + await _drive_print_start(test_engine, printer, download_ok=False) + + archive = await _created_archive(db_session, printer_id) + archive.status = "completed" + await db_session.commit() + + assert await resolve_pending_confirmation_as_good(db_session, printer_id) == archive.id + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_downloaded_3mf_archive_asks_when_the_setting_is_on(self, test_engine, db_session, printer_factory): + """The other creation branch: the 3MF arrived and ArchiveService wrote + the row. The flag is set on the row afterwards rather than passed into + archive_print, which also serves the queue dispatcher.""" + printer = await printer_factory() + await _set_setting(db_session, "confirm_outcome_external_prints", "true") + + await _drive_print_start(test_engine, printer, download_ok=True) + + archive = await _created_archive(db_session, printer.id) + assert archive.file_path.endswith(".3mf") + assert archive.confirm_requested is True + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_default_leaves_external_prints_alone(self, test_engine, db_session, printer_factory): + """Default off: nothing about today's behaviour changes for an install + that never touches the new setting — no row for it at all.""" + printer = await printer_factory() + + await _drive_print_start(test_engine, printer, download_ok=False) + + archive = await _created_archive(db_session, printer.id) + assert archive.confirm_requested is False + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_setting_off_does_not_ask(self, test_engine, db_session, printer_factory): + printer = await printer_factory() + await _set_setting(db_session, "confirm_outcome_external_prints", "false") + + await _drive_print_start(test_engine, printer, download_ok=False) + + assert (await _created_archive(db_session, printer.id)).confirm_requested is False + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_an_odd_stored_value_counts_as_off(self, test_engine, db_session, printer_factory): + """Settings live in a VARCHAR column and every other reader treats + anything that is not "true" as off.""" + printer = await printer_factory() + await _set_setting(db_session, "confirm_outcome_external_prints", "None") + + await _drive_print_start(test_engine, printer, download_ok=False) + + assert (await _created_archive(db_session, printer.id)).confirm_requested is False + + +class TestAFailureHereCostsOnlyThePrompt: + """The prompt is optional; the rest of print start is not. + + After a failed statement, a rollback of the whole transaction expires every + object the session holds, and on an async session the next read of one + raises instead of reloading. The print start below the check reads both + the printer and the archive, so the check runs in a savepoint, and the flag + write reloads what it used after its own rollback. + """ + + @staticmethod + def _assert_print_start_finished(mocks, archive_id: int) -> None: + assert archive_id in _active_prints.values() + mocks["_record_energy_start"].assert_awaited() + assert mocks["_record_energy_start"].await_args.args[0].id == archive_id + mocks["_capture_timelapse_baseline_at_start"].assert_awaited() + assert mocks["_capture_timelapse_baseline_at_start"].await_args.kwargs["archive_id"] == archive_id + + @pytest.mark.asyncio + @pytest.mark.integration + @pytest.mark.parametrize("download_ok", [True, False]) + async def test_a_failing_query_in_the_check(self, test_engine, db_session, printer_factory, download_ok): + from sqlalchemy import text + + from backend.app.api.routes import settings as settings_routes + + real_get_setting = settings_routes.get_setting + + async def _broken_for_this_key(db, key): + if key == "confirm_outcome_external_prints": + # A real failed statement, not just an exception: this is what + # leaves the transaction needing a rollback. + await db.execute(text("SELECT no_such_column FROM settings")) + return await real_get_setting(db, key) + + printer = await printer_factory() + await _set_setting(db_session, "confirm_outcome_external_prints", "true") + + mocks = await _drive_print_start( + test_engine, + printer, + download_ok=download_ok, + extra_patches=[patch.object(settings_routes, "get_setting", _broken_for_this_key)], + ) + + archive = await _created_archive(db_session, printer.id) + assert archive.confirm_requested is False + self._assert_print_start_finished(mocks, archive.id) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_a_failing_flag_write(self, test_engine, db_session, printer_factory): + async def _yes_but_poison_the_commit(db, printer_id, observed_name=None): + # Two rows for one unique key: the commit that carries the flag fails. + db.add(Settings(key="x_1898_duplicate", value="a")) + db.add(Settings(key="x_1898_duplicate", value="b")) + return True + + printer = await printer_factory() + + mocks = await _drive_print_start( + test_engine, + printer, + download_ok=True, + extra_patches=[patch("backend.app.main._ask_outcome_for_external_print", _yes_but_poison_the_commit)], + ) + + archive = await _created_archive(db_session, printer.id) + # archive_print committed the row; only the flag is lost. + assert archive.confirm_requested is False + self._assert_print_start_finished(mocks, archive.id) + + +class TestAQueuedPrintStillDecidesForItself: + @pytest.mark.asyncio + @pytest.mark.integration + async def test_a_dispatched_job_is_not_overridden_by_the_setting( + self, test_engine, db_session, printer_factory, archive_factory + ): + """A queue item that deliberately has the ask-for-outcome flag off must + stay off. A restart mid-print empties the expected-print registry, so + the queue row — which the scheduler commits to "printing" before the + MQTT send — is the durable record that Bambuddy started this.""" + printer = await printer_factory() + # Dispatched but not yet reported as started: the archive the scheduler + # attached to the row only turns "printing" once on_print_start runs, so + # the name-match resume above cannot find it and the queue row is the + # only record that Bambuddy sent this print. + source = await archive_factory(printer.id, filename=f"{SUBTASK}.gcode.3mf", status="pending", with_run=False) + db_session.add( + PrintQueueItem( + printer_id=printer.id, + archive_id=source.id, + status="printing", + confirm_outcome=False, + ) + ) + await db_session.commit() + await _set_setting(db_session, "confirm_outcome_external_prints", "true") + + await _drive_print_start(test_engine, printer, download_ok=False) + + archive = await _created_archive(db_session, printer.id) + assert archive.confirm_requested is False + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_a_queue_items_own_yes_survives_print_start( + self, test_engine, db_session, printer_factory, archive_factory + ): + """The dispatcher copies ``confirm_outcome`` onto the archive before + the print starts; the expected-print branch of ``on_print_start`` must + leave that alone even while the external-print setting is off.""" + from backend.app.main import register_expected_print + + printer = await printer_factory() + archive = await archive_factory( + printer.id, + filename=f"{SUBTASK}.gcode.3mf", + status="pending", + confirm_requested=True, + with_run=False, + ) + db_session.add( + PrintQueueItem( + printer_id=printer.id, + archive_id=archive.id, + status="printing", + confirm_outcome=True, + ) + ) + await db_session.commit() + archive_id, printer_id = archive.id, printer.id + register_expected_print(printer_id, f"{SUBTASK}.gcode.3mf", archive_id) + + await _drive_print_start(test_engine, printer, download_ok=False) + + db_session.expire_all() + refreshed = await db_session.get(PrintArchive, archive_id) + assert refreshed.status == "printing" + assert refreshed.confirm_requested is True + # No second row for the same print. + rows = (await db_session.scalars(select(PrintArchive).where(PrintArchive.printer_id == printer_id))).all() + assert len(rows) == 1 + + +class TestSettingsRoundTrip: + @pytest.mark.asyncio + @pytest.mark.integration + async def test_defaults_to_off_and_round_trips(self, async_client): + response = await async_client.get("/api/v1/settings/") + assert response.status_code == 200 + assert response.json()["confirm_outcome_external_prints"] is False + + response = await async_client.put("/api/v1/settings/", json={"confirm_outcome_external_prints": True}) + assert response.status_code == 200 + assert response.json()["confirm_outcome_external_prints"] is True + + assert (await async_client.get("/api/v1/settings/")).json()["confirm_outcome_external_prints"] is True + + response = await async_client.put("/api/v1/settings/", json={"confirm_outcome_external_prints": False}) + assert response.status_code == 200 + assert (await async_client.get("/api/v1/settings/")).json()["confirm_outcome_external_prints"] is False + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_updating_it_leaves_the_per_job_default_alone(self, async_client): + """Two different questions: one seeds the per-job toggle in the print + dialog, the other covers prints that never see that dialog.""" + await async_client.put("/api/v1/settings/", json={"default_confirm_outcome": True}) + + body = (await async_client.put("/api/v1/settings/", json={"confirm_outcome_external_prints": True})).json() + assert body["default_confirm_outcome"] is True + assert body["confirm_outcome_external_prints"] is True + + +class TestAStrandedQueueRowDoesNotMuteTheSetting: + """A ``printing`` row is not proof that Bambuddy started what is printing now. + + ``_completion_belongs_to_queue_item`` deliberately leaves a row open when a + completion's subtask name disagrees with the file it was dispatched with, + and the scheduler's stranded sweep only takes it back once the printer has + sat connected and terminal for minutes. Treating any such row as "we + dispatched this" switched the setting off for every screen-started print in + between -- the exact symptom the setting exists to cure. + """ + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_a_row_left_open_for_another_file_still_asks( + self, test_engine, db_session, printer_factory, archive_factory + ): + printer = await printer_factory() + stranded_for = await archive_factory( + printer.id, filename="SomeOtherJob.gcode.3mf", status="printing", with_run=False + ) + db_session.add( + PrintQueueItem( + printer_id=printer.id, + archive_id=stranded_for.id, + status="printing", + confirm_outcome=False, + ) + ) + await db_session.commit() + await _set_setting(db_session, "confirm_outcome_external_prints", "true") + stranded_filename = stranded_for.filename + + await _drive_print_start(test_engine, printer, download_ok=False) + + archive = await _created_archive(db_session, printer.id) + assert archive.filename != stranded_filename + assert archive.confirm_requested is True + + +class TestAReprintAsksAgain: + """The reprint reuses the archive row, and the completion prompt is gated on + ``user_verdict is None`` -- so without a reset the second run inherits the + first run's answer and is never asked about.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_the_previous_runs_verdict_does_not_carry_over( + self, test_engine, db_session, printer_factory, archive_factory + ): + from backend.app.main import register_expected_print + + printer = await printer_factory() + archive = await archive_factory( + printer.id, + filename=f"{SUBTASK}.gcode.3mf", + status="completed", + confirm_requested=True, + user_verdict="good", + confirm_token="token-from-the-first-run", + with_run=False, + ) + archive_id, printer_id = archive.id, printer.id + register_expected_print(printer_id, f"{SUBTASK}.gcode.3mf", archive_id) + + await _drive_print_start(test_engine, printer, download_ok=False) + + db_session.expire_all() + refreshed = await db_session.get(PrintArchive, archive_id) + assert refreshed.status == "printing" + assert refreshed.confirm_requested is True + assert refreshed.user_verdict is None + assert refreshed.confirm_token is None + + # And the completion really does ask again, which is the point. + refreshed.status = "completed" + await db_session.commit() + sent, ws, notif = await _dispatch(db_session, printer_id, archive_id) + assert sent is True + ws.send_print_confirm_request.assert_awaited_once() + notif.on_print_confirm_request.assert_awaited_once() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_an_archive_that_was_never_asked_about_keeps_its_verdict( + self, test_engine, db_session, printer_factory, archive_factory + ): + """The reset is scoped to rows that ask. An archive answered once and + later reprinted with the flag off must keep the answer it has.""" + from backend.app.main import register_expected_print + + printer = await printer_factory() + archive = await archive_factory( + printer.id, + filename=f"{SUBTASK}.gcode.3mf", + status="completed", + confirm_requested=False, + user_verdict="reject", + with_run=False, + ) + archive_id = archive.id + register_expected_print(printer.id, f"{SUBTASK}.gcode.3mf", archive_id) + + await _drive_print_start(test_engine, printer, download_ok=False) + + db_session.expire_all() + assert (await db_session.get(PrintArchive, archive_id)).user_verdict == "reject" + + +async def _dispatch(db_session, printer_id: int, archive_id: int, **kwargs): + """Run the completion path's outcome dispatch with the two emitters mocked.""" + from backend.app.main import dispatch_outcome_confirmation + + with ( + patch("backend.app.main.ws_manager") as ws, + patch("backend.app.main.notification_service") as notif, + ): + ws.send_print_confirm_request = AsyncMock() + notif.on_print_confirm_request = AsyncMock() + sent = await dispatch_outcome_confirmation( + db_session, + printer_id, + "Bench P1S", + {"subtask_name": SUBTASK}, + archive_id, + **kwargs, + ) + return sent, ws, notif + + +class TestTheCompletionEmitsThePrompt: + """The half of the feature the user actually sees. + + Everything else here asserts a column value; this drives the block + ``on_print_complete``'s notification task runs -- which is wrapped in a bare + ``except Exception`` that only logs, so a regression in it is invisible + unless something pins it. + """ + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_an_external_print_gets_a_prompt_with_one_tap_links(self, test_engine, db_session, printer_factory): + printer = await printer_factory() + printer_id = printer.id + await _set_setting(db_session, "confirm_outcome_external_prints", "true") + await _set_setting(db_session, "external_url", "https://farm.example.com/") + + await _drive_print_start(test_engine, printer, download_ok=False) + + archive = await _created_archive(db_session, printer_id) + archive_id = archive.id + assert archive.confirm_requested is True + archive.status = "completed" + await db_session.commit() + + sent, ws, notif = await _dispatch(db_session, printer_id, archive_id) + + assert sent is True + ws.send_print_confirm_request.assert_awaited_once() + assert ws.send_print_confirm_request.await_args.args[1]["archive_id"] == archive_id + + notif.on_print_confirm_request.assert_awaited_once() + kwargs = notif.on_print_confirm_request.await_args.kwargs + token = (await db_session.get(PrintArchive, archive_id)).confirm_token + assert token, "the one-tap links need a minted capability token" + assert kwargs["good_url"] == f"https://farm.example.com/api/v1/archives/confirm/{token}/good" + assert kwargs["reject_url"] == f"https://farm.example.com/api/v1/archives/confirm/{token}/reject" + assert kwargs["confirm_url"] == f"https://farm.example.com/archives?confirm={archive_id}" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_the_links_stay_absolute_without_an_external_url(self, test_engine, db_session, printer_factory): + """Telegram's inline keyboard and ntfy's action buttons are both dropped + for a relative URL, so an install that never set external_url used to + get a message carrying two unusable paths and no buttons at all.""" + printer = await printer_factory() + printer_id = printer.id + await _set_setting(db_session, "confirm_outcome_external_prints", "true") + + await _drive_print_start(test_engine, printer, download_ok=False) + + archive = await _created_archive(db_session, printer_id) + archive_id = archive.id + archive.status = "completed" + await db_session.commit() + + _, _, notif = await _dispatch(db_session, printer_id, archive_id) + + kwargs = notif.on_print_confirm_request.await_args.kwargs + assert kwargs["good_url"].startswith("http") + assert kwargs["reject_url"].startswith("http") + assert kwargs["confirm_url"].startswith("http") + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_a_failed_prompt_leaves_the_session_usable_for_the_email(self, db_session): + """The completion task sends the per-user print email on the same + session right after the prompt, so a prompt that dies mid-flush must + not take the email with it.""" + from backend.app.main import _dispatch_outcome_confirmation_safely + + async def _dies_mid_flush(db, *args, **kwargs): + db.add(Settings(key="x_1898_duplicate", value="a")) + db.add(Settings(key="x_1898_duplicate", value="b")) + await db.flush() + + with patch("backend.app.main.dispatch_outcome_confirmation", _dies_mid_flush): + await _dispatch_outcome_confirmation_safely(db_session, 1, "X1C", {}, 1, {}) + + # What _dispatch_user_print_email does next: read from the session. + # Without the rollback this raises PendingRollbackError. + await db_session.execute(select(Settings).limit(1)) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_an_already_answered_archive_is_not_asked_again(self, db_session, printer_factory, archive_factory): + printer = await printer_factory() + archive = await archive_factory( + printer.id, status="completed", confirm_requested=True, user_verdict="good", with_run=False + ) + + sent, ws, notif = await _dispatch(db_session, printer.id, archive.id) + + assert sent is False + ws.send_print_confirm_request.assert_not_awaited() + notif.on_print_confirm_request.assert_not_awaited() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_an_archive_that_never_opted_in_is_not_asked(self, db_session, printer_factory, archive_factory): + printer = await printer_factory() + archive = await archive_factory(printer.id, status="completed", confirm_requested=False, with_run=False) + + sent, _, notif = await _dispatch(db_session, printer.id, archive.id) + + assert sent is False + notif.on_print_confirm_request.assert_not_awaited() diff --git a/backend/tests/integration/test_print_confirmation.py b/backend/tests/integration/test_print_confirmation.py new file mode 100644 index 000000000..31302648f --- /dev/null +++ b/backend/tests/integration/test_print_confirmation.py @@ -0,0 +1,605 @@ +"""Integration tests for the post-print outcome confirmation (#1898). + +Covers the verdict PATCH (incl. the #1444-style mirror to the latest +PrintLogEntry and token retirement), the unauthenticated capability-token +endpoint, response defaults, and the verdict-aware statistics. +""" + +import re + +import pytest +from httpx import AsyncClient +from sqlalchemy import select + +from backend.app.models.print_log import PrintLogEntry + + +class TestOutcomeVerdictPatch: + @pytest.mark.asyncio + @pytest.mark.integration + async def test_defaults_present_on_response(self, async_client: AsyncClient, archive_factory, printer_factory): + """Archives created without any verdict expose the new fields with + their defaults — no verdict, no pending confirmation.""" + printer = await printer_factory() + archive = await archive_factory(printer.id) + + response = await async_client.get(f"/api/v1/archives/{archive.id}") + assert response.status_code == 200 + body = response.json() + assert body["user_verdict"] is None + assert body["confirm_requested"] is False + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_patch_verdict_mirrors_to_latest_log_entry( + self, async_client: AsyncClient, archive_factory, printer_factory, db_session + ): + """Setting the verdict via PATCH lands on the archive AND the latest + PrintLogEntry (verdict-aware statistics read the log), and retires a + pending confirmation token.""" + printer = await printer_factory() + archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="test-token-mirror") + + response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"user_verdict": "reject"}) + assert response.status_code == 200 + assert response.json()["user_verdict"] == "reject" + + entry = await db_session.scalar( + select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc()) + ) + assert entry is not None + assert entry.user_verdict == "reject" + + await db_session.refresh(archive) + assert archive.user_verdict == "reject" + # Retired by stamping, not by dropping the value: the link stays + # resolvable so a later tap can be told it is already answered. + assert archive.confirm_token == "test-token-mirror" + assert archive.confirm_token_used_at is not None + assert archive.user_verdict_source == "api" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_patch_rejects_unknown_verdict(self, async_client: AsyncClient, archive_factory, printer_factory): + printer = await printer_factory() + archive = await archive_factory(printer.id) + + response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"user_verdict": "meh"}) + assert response.status_code == 422 + + +class TestConfirmTokenEndpoint: + @pytest.mark.asyncio + @pytest.mark.integration + async def test_token_records_verdict_and_retires_token( + self, async_client: AsyncClient, archive_factory, printer_factory, db_session + ): + """The one-tap link from a push notification records the verdict + without auth, mirrors it to the log entry, and single-uses the token.""" + printer = await printer_factory() + archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="test-token-good") + + response = await async_client.post("/api/v1/archives/confirm/test-token-good/good") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + await db_session.refresh(archive) + assert archive.user_verdict == "good" + assert archive.user_verdict_source == "link" + assert archive.confirm_token == "test-token-good" + assert archive.confirm_token_used_at is not None + + entry = await db_session.scalar( + select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc()) + ) + assert entry is not None + assert entry.user_verdict == "good" + + # Second use of the same token: spent, and said so rather than 404. + response = await async_client.get("/api/v1/archives/confirm/test-token-good/reject") + assert response.status_code == 200 + assert "Already answered" in response.text + await db_session.refresh(archive) + assert archive.user_verdict == "good" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_spent_link_reports_the_recorded_verdict_without_changing_it( + self, async_client: AsyncClient, archive_factory, printer_factory, db_session + ): + """The live-farm case (#1898): the plate-clear default answered the + prompt, then the Telegram button was tapped. The link must name the + verdict on file, say how it got there, and leave it alone.""" + from backend.app.services.print_confirmation import resolve_pending_confirmation_as_good + + printer = await printer_factory() + archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="plate-cleared-token") + + assert await resolve_pending_confirmation_as_good(db_session, printer.id) == archive.id + await db_session.commit() + + response = await async_client.get("/api/v1/archives/confirm/plate-cleared-token/reject") + assert response.status_code == 200 + body = response.text + assert "Already answered" in body + assert "Good part" in body + assert "plate was cleared" in body + assert f"/archives?confirm={archive.id}" in body + + await db_session.refresh(archive) + assert archive.user_verdict == "good" + assert archive.user_verdict_source == "plate_clear" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_spent_link_after_the_verdict_was_cleared_again( + self, async_client: AsyncClient, archive_factory, printer_factory + ): + """Clearing the verdict in the app does not un-spend the link: the + capability was used, so the page explains rather than re-opening it.""" + printer = await printer_factory() + archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="cleared-again-token") + + assert (await async_client.post("/api/v1/archives/confirm/cleared-again-token/good")).status_code == 200 + assert ( + await async_client.patch(f"/api/v1/archives/{archive.id}", json={"user_verdict": None}) + ).status_code == 200 + + response = await async_client.get("/api/v1/archives/confirm/cleared-again-token/good") + assert response.status_code == 200 + assert "Already answered" in response.text + assert (await async_client.get(f"/api/v1/archives/{archive.id}")).json()["user_verdict"] is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_unknown_token_and_garbage_verdict(self, async_client: AsyncClient): + assert (await async_client.get("/api/v1/archives/confirm/no-such-token/good")).status_code == 404 + assert (await async_client.get("/api/v1/archives/confirm/whatever/maybe")).status_code == 400 + assert (await async_client.post("/api/v1/archives/confirm/no-such-token/good")).status_code == 404 + assert (await async_client.post("/api/v1/archives/confirm/whatever/maybe")).status_code == 400 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_a_get_records_nothing_whoever_sends_it( + self, async_client: AsyncClient, archive_factory, printer_factory, db_session + ): + """The blocker. Telegram and Slack GET the URLs in a message to build a + preview card, mail gateways detonate them before delivery, proxies and + browsers prefetch. While GET was the route that recorded, any of those + settled the outcome before the operator read the question — always + towards 'good', because good_url came first — and spent the token, so + the real tap landed on "already answered". A scrap part counted as a + success for good, in the statistics this branch adds. + + The heuristic below decides how the page behaves, not whether the + verdict is written: nothing a GET can say records anything. + """ + printer = await printer_factory() + archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="unfurler-token") + + for agent in ( + "TelegramBot (like TwitterBot)", + "Slackbot-LinkExpanding 1.0", + "Mimecast-Link-Protect", + # The one that used to be allowed straight through to the write. + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 " + "(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + ): + response = await async_client.get( + "/api/v1/archives/confirm/unfurler-token/good", headers={"user-agent": agent} + ) + assert response.status_code == 200, agent + assert "Confirm this outcome" in response.text, agent + assert "
archive.confirm_token_used_at + + page = await async_client.get(f"/api/v1/archives/confirm/{archive.confirm_token}/good") + assert page.status_code == 200 + + # SQLite hands back naive datetimes; they are already UTC, which is how + # the route formats them too. + def _as_shown(value): + if value.tzinfo is not None: + value = value.astimezone(timezone.utc) + return value.strftime("%Y-%m-%d %H:%M UTC") + + shown = _as_shown(archive.user_verdict_at) + stale = _as_shown(archive.confirm_token_used_at) + assert shown in page.text + assert stale not in page.text + # And the link still changed nothing. + await db_session.refresh(archive) + assert archive.user_verdict == "reject" + + async def test_a_re_sent_prompt_carries_a_live_token(self, archive_factory, printer_factory, db_session): + """A spent token must never be re-used for a new prompt. + + Since a verdict keeps the token value on the row, "has a token" stopped + meaning "answerable": without minting a fresh one, every button in the + new message would land on the already-answered page. + """ + from backend.app.main import dispatch_outcome_confirmation + + printer = await printer_factory() + archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="spent-token") + from backend.app.services.print_confirmation import retire_confirm_token + + retire_confirm_token(archive) + await db_session.commit() + assert archive.confirm_token_used_at is not None + + await dispatch_outcome_confirmation(db_session, printer.id, printer.name, {}, archive.id, None) + + await db_session.refresh(archive) + assert archive.confirm_token != "spent-token" + assert archive.confirm_token_used_at is None + + async def test_clearing_the_verdict_drops_the_source( + self, async_client: AsyncClient, archive_factory, printer_factory, db_session + ): + printer = await printer_factory() + archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="drop-source-token") + + await async_client.patch( + f"/api/v1/archives/{archive.id}", json={"user_verdict": "good", "user_verdict_source": "dialog"} + ) + await async_client.patch(f"/api/v1/archives/{archive.id}", json={"user_verdict": None}) + + await db_session.refresh(archive) + assert archive.user_verdict is None + assert archive.user_verdict_source is None + # ...but the capability stays spent. + assert archive.confirm_token_used_at is not None diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py index f573efa03..8491ca512 100644 --- a/backend/tests/unit/services/test_bambu_mqtt.py +++ b/backend/tests/unit/services/test_bambu_mqtt.py @@ -7039,11 +7039,12 @@ class TestTrayNowH2SExternalSpoolOverride: instead of 254 when the active feed is the external spool. Bambuddy detects the all-external case via the slicer-captured - ams_mapping (every entry == -1) and promotes tray_now to 254 so the - UI active-tray highlight matches the real feed. + ams_mapping (every entry 254, or -1 when the command carried no + ams_mapping2 to resolve it from, #3166) and promotes tray_now to 254 so + the UI active-tray highlight matches the real feed. The override is intentionally narrow: - * only fires when ams_mapping is captured AND every entry is -1 + * only fires when ams_mapping is captured AND every entry is external * does not touch mixed prints ([5, -1]) or AMS-only prints ([5]) * does not fire when no ams_mapping is captured (printer-screen start) """ @@ -7072,6 +7073,14 @@ class TestTrayNowH2SExternalSpoolOverride: mqtt_client._process_message(_ams_payload(0)) assert mqtt_client.state.tray_now == 254 + def test_resolved_external_mapping_promotes(self, mqtt_client): + """Since #3166 the capture resolves the external spool from + ams_mapping2, so a single-nozzle all-external print arrives as [254] + rather than [-1]. The override must still fire.""" + mqtt_client._captured_ams_mapping = [254] + mqtt_client._process_message(_ams_payload(0)) + assert mqtt_client.state.tray_now == 254 + def test_ams_only_mapping_does_not_override(self, mqtt_client): """ams_mapping=[5] (AMS slot 5 only) -> firmware value trusted as-is. Without the all-external guard, this would falsely override real diff --git a/backend/tests/unit/test_confirm_link_unattended_fetch_1898.py b/backend/tests/unit/test_confirm_link_unattended_fetch_1898.py new file mode 100644 index 000000000..643ede00b --- /dev/null +++ b/backend/tests/unit/test_confirm_link_unattended_fetch_1898.py @@ -0,0 +1,361 @@ +"""A machine must not be able to spend a one-tap verdict token (#1898). + +Telegram and Slack fetch the URLs in a message to build a preview card, mail +gateways detonate links before delivery, and browsers prefetch. While the +verdict URLs went out as plain text in the notification body -- which is what +``DEFAULT_TEMPLATES['print_confirm_request']`` used to ship -- any one of those +GETs recorded a verdict nobody chose and retired the token, so the operator's +real tap landed on "already answered" and a scrap part counted as good for the +rest of time. + +Four defences, in the order they matter. The capability URLs no longer travel +in body text at all. Recording is a POST, so a GET from anything that walks a +URL changes nothing (the route side of that is in the integration tests). The +confirmation page submits its own form only for a URL carrying the one-tap +marker, which rides on the Telegram inline keyboard and on nothing a machine +can read — so the scanners that render HTML and run JavaScript, which send an +ordinary Chrome string and which no User-Agent list can name, get a page with a +button on it. And the channels that build previews are asked not to, on the one +message whose links are capabilities. + +The unattended-fetch heuristic is the fifth and the weakest, which is why it is +no longer the only thing in front of the write. +""" + +import json + +import httpx +import pytest + +from backend.app.models.notification import NotificationProvider +from backend.app.models.notification_template import DEFAULT_TEMPLATES +from backend.app.services.notification_service import NotificationService +from backend.app.services.print_confirmation import is_one_tap_request, is_unattended_fetch, one_tap_url + +CONFIG = {"bot_token": "123456:AAbbCC", "chat_id": "-1002520100736"} + +# Real link-preview and mail-security fetchers, plus the browsers that must +# keep working. The phone UAs are the ones a notification tap actually opens. +BROWSER_AGENTS = [ + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 " + "(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/126.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0", + # The ntfy app performs its action buttons itself, with an HTTP client UA. + "okhttp/4.12.0", + "python-httpx/0.27.0", +] + +MACHINE_AGENTS = [ + "TelegramBot (like TwitterBot)", + "Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)", + "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)", + "facebookexternalhit/1.1", + "Twitterbot/1.0", + "WhatsApp/2.23.20.0 A", + "Mozilla/5.0 (compatible; SkypeUriPreview Preview/0.5)", + "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)", + "Barracuda Sentinel (EE)", + "Mimecast-Link-Protect", +] + + +class _Client: + """Stand-in for httpx.AsyncClient that records what would be sent.""" + + def __init__(self): + self.is_closed = False + self.calls: list[dict] = [] + + async def post(self, url, data=None, files=None, json=None, headers=None): + self.calls.append(json if json is not None else (data or {})) + return httpx.Response(200, json={"ok": True, "result": {}}) + + +class TestUnattendedFetchDetection: + @pytest.mark.parametrize("agent", BROWSER_AGENTS) + def test_a_real_browser_is_left_alone(self, agent): + assert is_unattended_fetch("GET", {"user-agent": agent}) is False + + @pytest.mark.parametrize("agent", MACHINE_AGENTS) + def test_link_fetchers_are_recognised(self, agent): + assert is_unattended_fetch("GET", {"user-agent": agent}) is True + + def test_prefetch_hints_count_even_from_a_browser_ua(self): + """Chrome and Firefox preload links before they are clicked; Safari's + preview sends the same hint. A finger on a button never does.""" + browser = BROWSER_AGENTS[1] + assert is_unattended_fetch("GET", {"user-agent": browser, "purpose": "prefetch"}) is True + assert is_unattended_fetch("GET", {"user-agent": browser, "x-purpose": "preview"}) is True + assert is_unattended_fetch("GET", {"user-agent": browser, "x-moz": "prefetch"}) is True + assert is_unattended_fetch("GET", {"user-agent": browser, "sec-purpose": "prefetch;prerender"}) is True + + def test_head_is_never_a_tap(self): + """Only the GET route renders the page, so only a GET can be widened + to HEAD by a future router change. A probe must not come back looking + like a page load.""" + assert is_unattended_fetch("HEAD", {"user-agent": BROWSER_AGENTS[0]}) is True + + def test_a_missing_user_agent_is_not_held_against_the_caller(self): + """Some notification clients send none; the token is still the + credential, and guessing here would cost real verdicts.""" + assert is_unattended_fetch("GET", {}) is False + + +class TestPromptKeepsTheCapabilityOutOfBodyText: + """This file used to pin the opposite: that both verdict URLs were in the + body. They were, and that was the defect — the body is the one part of a + notification that every unfurler, gateway and proxy reads. The capability + links now travel only in affordances nothing prefetches.""" + + def test_the_default_template_carries_only_the_deep_link(self): + template = next(t for t in DEFAULT_TEMPLATES if t["event_type"] == "print_confirm_request") + assert "{good_url}" not in template["body_template"] + assert "{reject_url}" not in template["body_template"] + assert "{confirm_url}" in template["body_template"] + + +class TestNtfyButtonsRecordOverPost: + @pytest.mark.asyncio + async def test_the_action_buttons_use_post(self): + """The ntfy app issues the action itself, so it can use the method that + records. A GET would only open the confirmation page — which is the + point of the split, and is what every crawler gets.""" + service = NotificationService() + captured: dict = {} + + async def _fake_ntfy(config, title, message, image_data=None, event_type=None, actions=None): + captured["actions"] = actions + return True, "ok" + + service._send_ntfy = _fake_ntfy + provider = NotificationProvider( + name="farm", provider_type="ntfy", config=json.dumps({"server": "https://ntfy.sh", "topic": "farm"}) + ) + + ok, _ = await service._send_to_provider( + provider, + "How did your print come out?", + "X1C: bracket.3mf", + event_type="print_confirm_request", + variables={ + "good_url": "https://farm.example.com/api/v1/archives/confirm/tok/good", + "reject_url": "https://farm.example.com/api/v1/archives/confirm/tok/reject", + }, + ) + + assert ok + assert "method=POST" in captured["actions"] + assert "method=GET" not in captured["actions"] + + +class TestTheOneTapMarkerRidesOnlyOnButtons: + """The marker is what decides whether the confirmation page submits itself, + and it is on the Telegram inline keyboard and nowhere else. + + The User-Agent list above catches the fetchers that say what they are, and + none of those run JavaScript. The ones that do — a mail-security sandbox + detonating the link, a browser-isolation proxy — send an ordinary Chrome + string, so no list can name them. What they cannot have is a URL that never + appeared in any text they can read. + """ + + def test_a_marked_url_is_the_only_thing_that_opens_the_script(self): + assert is_one_tap_request({"tap": "1"}) is True + assert is_one_tap_request({}) is False, "a link out of a message body must not qualify" + assert is_one_tap_request({"tap": "0"}) is False + assert is_one_tap_request({"tap": "yes"}) is False + + def test_marking_a_url_that_already_carries_a_query(self): + assert one_tap_url("https://host/api/v1/archives/confirm/tok/good") == ( + "https://host/api/v1/archives/confirm/tok/good?tap=1" + ) + assert one_tap_url("https://host/confirm/tok/good?from=ntfy") == "https://host/confirm/tok/good?from=ntfy&tap=1" + + @pytest.mark.asyncio + async def test_the_telegram_buttons_are_marked_and_the_body_link_is_not(self): + """Telegram cannot POST, so its buttons are the one affordance that + opens a browser — and the one that needs the page to submit itself. + Telegram never fetches an inline-keyboard URL, so the marker does not + leak into anything a machine reads.""" + service = NotificationService() + captured: dict = {} + + async def _fake_telegram(config, message, image_data=None, buttons=None, link_preview=True): + captured["buttons"] = buttons + captured["message"] = message + return True, "ok" + + service._send_telegram = _fake_telegram + provider = NotificationProvider(name="farm", provider_type="telegram", config=json.dumps(CONFIG)) + + ok, _ = await service._send_to_provider( + provider, + "How did your print come out?", + "X1C: bracket.3mf\nGood: https://farm.example.com/api/v1/archives/confirm/tok/good", + event_type="print_confirm_request", + variables={ + "good_url": "https://farm.example.com/api/v1/archives/confirm/tok/good", + "reject_url": "https://farm.example.com/api/v1/archives/confirm/tok/reject", + }, + ) + + assert ok + assert [b["url"] for b in captured["buttons"]] == [ + "https://farm.example.com/api/v1/archives/confirm/tok/good?tap=1", + "https://farm.example.com/api/v1/archives/confirm/tok/reject?tap=1", + ] + # An install that kept {good_url} in its edited body still sends the + # plain URL in the text — and that is exactly the one that must not + # press its own button when a scanner opens it. + assert "?tap=1" not in captured["message"] + + @pytest.mark.asyncio + async def test_the_ntfy_actions_stay_unmarked(self): + """They POST, so no page is rendered and there is nothing to submit. + Marking them would put the marker in an Authorization-free HTTP header + for no gain.""" + service = NotificationService() + captured: dict = {} + + async def _fake_ntfy(config, title, message, image_data=None, event_type=None, actions=None): + captured["actions"] = actions + return True, "ok" + + service._send_ntfy = _fake_ntfy + provider = NotificationProvider( + name="farm", provider_type="ntfy", config=json.dumps({"server": "https://ntfy.sh", "topic": "farm"}) + ) + + ok, _ = await service._send_to_provider( + provider, + "How did your print come out?", + "X1C: bracket.3mf", + event_type="print_confirm_request", + variables={ + "good_url": "https://farm.example.com/api/v1/archives/confirm/tok/good", + "reject_url": "https://farm.example.com/api/v1/archives/confirm/tok/reject", + }, + ) + + assert ok + assert "tap=1" not in captured["actions"] + + +class TestSlackDoesNotUnfurl: + @pytest.mark.asyncio + async def test_the_slack_payload_turns_previews_off(self): + """Slack and Mattermost unfurl the URLs in `text` the same way + Telegram builds a preview card.""" + service = NotificationService() + client = _Client() + service._http_client = client + + ok, _ = await service._send_webhook( + {"webhook_url": "https://hooks.slack.example.com/services/T/B/x", "payload_format": "slack"}, + "How did your print come out?", + "X1C: bracket.3mf", + event_type="print_confirm_request", + ) + + assert ok + assert client.calls[0]["unfurl_links"] is False + assert client.calls[0]["unfurl_media"] is False + + @pytest.mark.asyncio + async def test_every_other_event_keeps_its_previews(self): + """Only the outcome prompt's links are capabilities. The slack payload + never attaches image bytes — the base64 attach is generic-format only — + so the unfurl is the only way a {finish_photo_url} in a print_complete + body ever becomes a photo in the channel, and there is no setting that + turns it back on.""" + service = NotificationService() + client = _Client() + service._http_client = client + + ok, _ = await service._send_webhook( + {"webhook_url": "https://hooks.slack.example.com/services/T/B/x", "payload_format": "slack"}, + "Print complete", + "X1C: bracket.3mf\nhttps://farm.example.com/api/v1/archives/7/photos/finish_a.jpg", + event_type="print_complete", + ) + + assert ok + assert "unfurl_links" not in client.calls[0] + assert "unfurl_media" not in client.calls[0] + + +class TestTelegramDoesNotAskForAPreview: + @pytest.mark.asyncio + async def test_send_message_disables_the_link_preview(self): + """Telegram's servers GET the first URL in the text to build the + preview card. That fetch is the one that answered the prompt.""" + service = NotificationService() + client = _Client() + service._http_client = client + + ok, _ = await service._send_telegram( + CONFIG, + "*How did your print come out?*\nX1C: bracket.3mf\nGood: https://host/api/v1/archives/confirm/tok/good", + link_preview=False, + ) + + assert ok + assert client.calls[0]["disable_web_page_preview"] is True + + @pytest.mark.asyncio + async def test_the_inline_buttons_variant_disables_it_too(self): + """The shape the outcome prompt actually sends on a camera-less + printer: no photo, so sendMessage rather than sendPhoto.""" + service = NotificationService() + client = _Client() + service._http_client = client + + buttons = [ + {"text": "Good", "url": "https://host/api/v1/archives/confirm/tok/good"}, + {"text": "Reject", "url": "https://host/api/v1/archives/confirm/tok/reject"}, + ] + ok, _ = await service._send_telegram(CONFIG, "*T*\nbody", buttons=buttons, link_preview=False) + + assert ok + assert client.calls[0]["disable_web_page_preview"] is True + assert client.calls[0]["reply_markup"] == {"inline_keyboard": [buttons]} + + @pytest.mark.asyncio + async def test_every_other_message_keeps_its_preview(self): + """When the finish photo is too large to attach, the preview card is + how a {finish_photo_url} in a print_complete body still shows up as a + photo in the chat. Only the outcome prompt gives that up.""" + service = NotificationService() + client = _Client() + service._http_client = client + + ok, _ = await service._send_telegram( + CONFIG, "*Print complete*\nX1C: bracket.3mf\nhttps://farm.example.com/api/v1/archives/7/photos/finish_a.jpg" + ) + + assert ok + assert "disable_web_page_preview" not in client.calls[0] + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("event_type", "expected"), + [("print_confirm_request", False), ("print_complete", True), (None, True)], + ) + async def test_only_the_outcome_prompt_is_sent_without_a_preview(self, event_type, expected): + service = NotificationService() + captured: dict = {} + + async def _fake_telegram(config, message, image_data=None, buttons=None, link_preview=True): + captured["link_preview"] = link_preview + return True, "ok" + + service._send_telegram = _fake_telegram + provider = NotificationProvider(name="farm", provider_type="telegram", config=json.dumps(CONFIG)) + + ok, _ = await service._send_to_provider(provider, "Title", "body", event_type=event_type) + + assert ok + assert captured["link_preview"] is expected diff --git a/backend/tests/unit/test_confirm_token_retirement_migration_1898.py b/backend/tests/unit/test_confirm_token_retirement_migration_1898.py new file mode 100644 index 000000000..7daaf3827 --- /dev/null +++ b/backend/tests/unit/test_confirm_token_retirement_migration_1898.py @@ -0,0 +1,226 @@ +"""Migration coverage for the one-tap token columns, index and prompt body (#1898). + +``confirm_token_used_at`` is what turns a spent link into "already answered" +instead of a 404, and ``user_verdict_source`` is what the hint next to the +verdict badge reads — both have to reach an install that upgraded rather than +one created fresh from the models. So does the index on ``confirm_token``: the +route that reads it runs with no authentication, so without the index anyone +who can reach the host turns a stream of invented tokens into a stream of full +scans of print_archives. +""" + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine + +import backend.app.models # noqa: F401 - populate Base.metadata +import backend.app.models.external_link # noqa: F401 - required by a legacy ALTER in run_migrations +import backend.app.models.print_log # noqa: F401 - required by a legacy ALTER in run_migrations +import backend.app.models.virtual_printer # noqa: F401 - required by a legacy ALTER in run_migrations +from backend.app.core.database import Base, run_migrations + + +@pytest.fixture(autouse=True) +def force_sqlite_dialect(monkeypatch): + """run_migrations branches on the global dialect, not on the connection, so + a dev config pointing at Postgres would run Postgres-only syntax against the + SQLite engine below. Same fixture as test_billing_run_id_migration.py.""" + from backend.app.core import db_dialect + + monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True) + monkeypatch.setattr(db_dialect, "is_postgres", lambda: False) + # database.py imported is_sqlite at module load time — patch there too. + from backend.app.core import database as database_module + + monkeypatch.setattr(database_module, "is_sqlite", lambda: True) + + +async def _archive_columns(conn) -> set[str]: + return {row[1] for row in (await conn.execute(text("PRAGMA table_info(print_archives)"))).all()} + + +@pytest.mark.asyncio +async def test_retirement_columns_are_added_and_migration_is_idempotent(tmp_path): + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'confirm-retirement.db'}") + try: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + # Simulate the pre-#1898-follow-up schema: the columns only exist + # here because create_all built the table from today's models. + await conn.execute(text("ALTER TABLE print_archives DROP COLUMN confirm_token_used_at")) + await conn.execute(text("ALTER TABLE print_archives DROP COLUMN user_verdict_source")) + assert "confirm_token_used_at" not in await _archive_columns(conn) + + await run_migrations(conn) + columns = await _archive_columns(conn) + assert "confirm_token_used_at" in columns + assert "user_verdict_source" in columns + + # Re-running the migrations on an already-migrated install is a + # no-op, not an error: _safe_execute swallows the duplicate ALTER. + await run_migrations(conn) + assert await _archive_columns(conn) >= {"confirm_token_used_at", "user_verdict_source"} + + # ...with the SQLite-flavoured types the ALTERs declare, so a + # stamp round-trips as a datetime rather than as opaque text. + types = { + row[1]: row[2].upper() for row in (await conn.execute(text("PRAGMA table_info(print_archives)"))).all() + } + assert types["confirm_token_used_at"] == "DATETIME" + assert types["user_verdict_source"].startswith("VARCHAR") + finally: + await engine.dispose() + + +async def _archive_indexes(conn) -> dict[str, bool]: + """Index name -> whether it is UNIQUE, for print_archives.""" + rows = (await conn.execute(text("PRAGMA index_list(print_archives)"))).all() + return {row[1]: bool(row[2]) for row in rows} + + +def test_the_model_declares_the_index(): + """A fresh install gets its schema from the models, not from + run_migrations, so the declaration is half the fix.""" + from backend.app.models.archive import PrintArchive + + column = PrintArchive.__table__.c.confirm_token + assert column.index is True + assert column.unique is True + + +@pytest.mark.asyncio +async def test_confirm_requested_is_nullable_on_both_kinds_of_install(tmp_path): + """A fresh database and an upgraded one must describe the column the same way. + + ``ALTER TABLE ... ADD COLUMN confirm_requested BOOLEAN DEFAULT FALSE`` cannot + carry NOT NULL, so an upgraded install has a nullable column. The model has + to agree, or every install created from it has a stricter table than every + install that grew into it -- and the difference only ever shows up as an + IntegrityError on somebody else's machine. + """ + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'confirm-nullable.db'}") + try: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + def notnull(rows): + return {row[1]: row[3] for row in rows} + + fresh = notnull((await conn.execute(text("PRAGMA table_info(print_archives)"))).all()) + assert fresh["confirm_requested"] == 0 + + # ...and the column an upgrade adds, for comparison. + await conn.execute(text("ALTER TABLE print_archives DROP COLUMN confirm_requested")) + await run_migrations(conn) + upgraded = notnull((await conn.execute(text("PRAGMA table_info(print_archives)"))).all()) + assert upgraded["confirm_requested"] == fresh["confirm_requested"] + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_the_confirm_token_index_reaches_an_upgraded_install(tmp_path): + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'confirm-token-index.db'}") + try: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + # An install that upgraded from before the index: the column is + # there, the index is not. + await conn.execute(text("DROP INDEX ix_print_archives_confirm_token")) + assert "ix_print_archives_confirm_token" not in await _archive_indexes(conn) + + await run_migrations(conn) + indexes = await _archive_indexes(conn) + assert "ix_print_archives_confirm_token" in indexes + assert indexes["ix_print_archives_confirm_token"] is True, "must be UNIQUE" + + # Re-running is a no-op, not an error. + await run_migrations(conn) + assert "ix_print_archives_confirm_token" in await _archive_indexes(conn) + + # Most archives never get a token, so the unique index has to + # tolerate any number of NULLs — otherwise the second archive on a + # fresh install would fail to insert. + from backend.app.models.archive import PrintArchive + + rows = [ + {"filename": "a.3mf", "file_path": "a", "file_size": 1, "status": "completed"}, + {"filename": "b.3mf", "file_path": "b", "file_size": 1, "status": "completed"}, + ] + await conn.execute(PrintArchive.__table__.insert(), rows) + stored = (await conn.execute(text("SELECT confirm_token FROM print_archives"))).scalars().all() + assert stored == [None, None] + finally: + await engine.dispose() + + +OLD_CONFIRM_BODY = "{printer}: {filename}\nGood: {good_url}\nReject: {reject_url}" + + +async def _confirm_body(conn) -> str | None: + return ( + await conn.execute( + text("SELECT body_template FROM notification_templates WHERE event_type = 'print_confirm_request'") + ) + ).scalar_one_or_none() + + +@pytest.mark.asyncio +async def test_the_capability_urls_leave_the_prompt_body_on_an_upgraded_install(tmp_path): + """Seeding only inserts templates that are missing, so an install already + running this feature would keep the shape the review found: both single-use + verdict URLs as plain text in every channel's message body, where the + unfurler that answers the prompt reads them.""" + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'confirm-body.db'}") + try: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await conn.execute( + text( + "INSERT INTO notification_templates (event_type, name, title_template, body_template, is_default) " + "VALUES ('print_confirm_request', 'Print Outcome Confirmation', " + "'How did your print come out?', :body, 1)" + ), + {"body": OLD_CONFIRM_BODY}, + ) + + await run_migrations(conn) + + body = await _confirm_body(conn) + assert body is not None + assert "{good_url}" not in body + assert "{reject_url}" not in body + assert "{confirm_url}" in body + + # Idempotent, and it agrees with what a fresh install seeds. + await run_migrations(conn) + from backend.app.models.notification_template import DEFAULT_TEMPLATES + + seeded = next(t for t in DEFAULT_TEMPLATES if t["event_type"] == "print_confirm_request") + assert await _confirm_body(conn) == seeded["body_template"] + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_a_template_the_admin_edited_is_left_alone(tmp_path): + """Same guard as the two template renames: match the old default verbatim + or do not touch the row.""" + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'confirm-body-custom.db'}") + custom = "Plate off {printer}? {good_url}" + try: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await conn.execute( + text( + "INSERT INTO notification_templates (event_type, name, title_template, body_template, is_default) " + "VALUES ('print_confirm_request', 'Mine', 'Well?', :body, 0)" + ), + {"body": custom}, + ) + + await run_migrations(conn) + + assert await _confirm_body(conn) == custom + finally: + await engine.dispose() diff --git a/backend/tests/unit/test_external_spool_usage_3166.py b/backend/tests/unit/test_external_spool_usage_3166.py new file mode 100644 index 000000000..80ac3989f --- /dev/null +++ b/backend/tests/unit/test_external_spool_usage_3166.py @@ -0,0 +1,230 @@ +"""External-spool usage charged to an AMS spool (#3166). + +The firmware rejects 254/255 in a print command's flat ``ams_mapping``, so +BambuStudio and our own dispatch both write an external spool there as -1 and +put the real target in ``ams_mapping2``. The MQTT client captured only the flat +list, so a print fed from the external spool reached the usage tracker as +``[-1]`` -- and the tracker's position-based fallback then charged it to the +first loaded AMS tray. The reporter's H2D logged this for every external-spool +print: 225 g of ABS from the right external spool deducted from the PLA spool +in AMS slot 1. + +The command payloads below are verbatim from that reporter's support bundle +(H2D, firmware 01.03.00.00, one AMS 2 Pro). +""" + +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.app.models.archive import PrintArchive +from backend.app.models.spool import Spool +from backend.app.services.bambu_mqtt import BambuMQTTClient, resolve_external_spools_in_mapping +from backend.app.services.usage_tracker import _track_from_3mf + +pytestmark = pytest.mark.unit + + +def project_file(ams_mapping, ams_mapping2=None) -> dict: + print_data = { + "sequence_id": "20000", + "command": "project_file", + "param": "Metadata/plate_1.gcode", + "url": "ftp://Small_Spool_Adapter_(PLA)_A_Side.3mf", + "ams_mapping": ams_mapping, + } + if ams_mapping2 is not None: + print_data["ams_mapping2"] = ams_mapping2 + return {"print": print_data} + + +EXT_RIGHT = {"ams_id": 255, "slot_id": 0} +EXT_LEFT = {"ams_id": 254, "slot_id": 0} +UNMAPPED = {"ams_id": 255, "slot_id": 255} + + +class TestResolvingTheExternalSpool: + def test_dual_nozzle_right_external(self): + assert resolve_external_spools_in_mapping([-1], [EXT_RIGHT], is_dual_nozzle=True) == [255] + + def test_dual_nozzle_left_external(self): + assert resolve_external_spools_in_mapping([-1], [EXT_LEFT], is_dual_nozzle=True) == [254] + + def test_single_nozzle_external_is_254_whatever_the_wire_says(self): + # BambuStudio always sends ams_id 255 for the one external spool of a + # single-nozzle printer; Bambuddy knows that spool as global tray 254. + assert resolve_external_spools_in_mapping([-1], [EXT_RIGHT], is_dual_nozzle=False) == [254] + + def test_mixed_mapping_only_the_external_entry_changes(self): + mapping2 = [ + {"ams_id": 0, "slot_id": 0}, + EXT_RIGHT, + {"ams_id": 0, "slot_id": 2}, + {"ams_id": 0, "slot_id": 3}, + ] + assert resolve_external_spools_in_mapping([0, -1, 2, 3], mapping2, is_dual_nozzle=True) == [0, 255, 2, 3] + + def test_an_unmapped_slot_stays_unmapped(self): + assert resolve_external_spools_in_mapping([0, -1], [{"ams_id": 0, "slot_id": 0}, UNMAPPED], True) == [0, -1] + + @pytest.mark.parametrize("mapping2", [None, "junk", [EXT_RIGHT, EXT_RIGHT]]) + def test_without_a_usable_mapping2_the_capture_is_untouched(self, mapping2): + assert resolve_external_spools_in_mapping([-1], mapping2, is_dual_nozzle=True) == [-1] + + def test_non_list_mapping_is_returned_as_is(self): + assert resolve_external_spools_in_mapping(None, [EXT_RIGHT], is_dual_nozzle=True) is None + + +class TestTheCapturedMapping: + def test_h2d_command_captures_the_right_external_spool(self): + client = BambuMQTTClient(ip_address="10.0.0.7", serial_number="H2D3166", access_code="12345678", model="H2D") + client._handle_request_message(project_file([-1], [EXT_RIGHT])) + assert client._captured_ams_mapping == [255] + + def test_h2d_mixed_command(self): + client = BambuMQTTClient(ip_address="10.0.0.7", serial_number="H2D3166", access_code="12345678", model="H2D") + mapping2 = [ + {"ams_id": 0, "slot_id": 0}, + EXT_RIGHT, + {"ams_id": 0, "slot_id": 2}, + {"ams_id": 0, "slot_id": 3}, + ] + client._handle_request_message(project_file([0, -1, 2, 3], mapping2)) + assert client._captured_ams_mapping == [0, 255, 2, 3] + + def test_p2s_command_captures_tray_254(self): + client = BambuMQTTClient(ip_address="10.0.0.7", serial_number="P2S3166", access_code="12345678", model="P2S") + client._handle_request_message(project_file([-1], [EXT_RIGHT])) + assert client._captured_ams_mapping == [254] + + def test_command_without_mapping2_is_captured_as_before(self): + client = BambuMQTTClient(ip_address="10.0.0.7", serial_number="X1C3166", access_code="12345678", model="X1C") + client._handle_request_message(project_file([-1])) + assert client._captured_ams_mapping == [-1] + + +# --- The usage tracker --------------------------------------------------------- + + +def _spool(spool_id: int): + spool = MagicMock() + spool.id = spool_id + spool.label_weight = 1000 + spool.weight_used = 0 + spool.cost_per_kg = None + spool.material = "ABS" + spool.rgba = None + return spool + + +def _db(spools: dict[int, MagicMock]): + """Answer the tracker's archive and spool lookups by what they select.""" + archive = MagicMock() + archive.id = 142 + archive.file_path = "archives/142/test.3mf" + archive.extra_data = None + archive.plate_id = None + selected: list = [] + + async def execute(stmt, *args, **kwargs): + entity = stmt.column_descriptions[0].get("entity") + result = MagicMock() + value = None + if entity is PrintArchive: + value = archive + elif entity is Spool: + spool_id = stmt.whereclause.right.value + value = spools.get(spool_id) + selected.append(spool_id) + result.scalar_one_or_none.return_value = value + result.scalars.return_value.first.return_value = None + result.scalar.return_value = None + return result + + db = AsyncMock() + db.execute = execute + db.add = MagicMock() + return db + + +# The reporter's printer: PLA in AMS slot 1 (spool 20), ABS on the right +# external spool (spool 4). The AMS slot is loaded, so the old position-based +# fallback had a tray to land on. +ASSIGNED = {(0, 0): 20, (255, 1): 4} +RAW_DATA = { + "ams": [{"id": "0", "tray": [{"id": "0", "tray_type": "PLA"}]}], + "vt_tray": [{"id": "255", "tray_type": "ABS"}], +} + + +async def _track(ams_mapping, filament_usage, tray_now_at_start=-1, tray_now=255): + charged: list[tuple[int, int]] = [] + + async def resolve(printer_id, ams_id, tray_id, **kwargs): + charged.append((ams_id, tray_id)) + return ASSIGNED.get((ams_id, tray_id)) + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + progress=100, + layer_num=50, + tray_now=tray_now, + last_loaded_tray=-1, + tray_change_log=[], + raw_data=RAW_DATA, + ) + with ( + patch("backend.app.core.config.settings") as mock_settings, + patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage), + patch("backend.app.services.usage_tracker._resolve_spool_id_for_tray", side_effect=resolve), + ): + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_settings.base_dir = MagicMock() + mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path) + results = await _track_from_3mf( + printer_id=1, + archive_id=142, + status="completed", + print_name="Daft_Punk_Helmet_Back", + handled_trays=set(), + printer_manager=printer_manager, + db=_db({20: _spool(20), 4: _spool(4)}), + ams_mapping=ams_mapping, + tray_now_at_start=tray_now_at_start, + print_started_at=datetime.now(timezone.utc), + ) + return results, charged + + +ABS_ONLY = [{"slot_id": 1, "used_g": 225.14, "type": "ABS", "color": "#000000"}] + + +class TestTheTrackerCharges: + @pytest.mark.asyncio + async def test_the_resolved_mapping_charges_the_external_spool(self): + results, charged = await _track([255], ABS_ONLY) + assert [r["spool_id"] for r in results] == [4] + assert charged == [(255, 1)] + + @pytest.mark.asyncio + async def test_an_unmapped_slot_does_not_fall_back_to_an_ams_tray(self): + # A two-filament print where the mapping leaves slot 2 unfed. The old + # fallback handed slot 2 the second loaded tray instead. + usage = [ + {"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#000000"}, + {"slot_id": 2, "used_g": 5.0, "type": "ABS", "color": "#000000"}, + ] + results, charged = await _track([0, -1], usage) + assert [r["spool_id"] for r in results] == [20] + assert charged == [(0, 0)] + + @pytest.mark.asyncio + async def test_an_all_unmapped_capture_defers_to_tray_now(self): + # A command without ams_mapping2 still captures [-1]. That names no + # tray, so the printer's own tray_now decides -- not AMS slot 1. + results, charged = await _track([-1], ABS_ONLY, tray_now_at_start=255) + assert (0, 0) not in charged + assert [r["spool_id"] for r in results] == [4] diff --git a/backend/tests/unit/test_print_log.py b/backend/tests/unit/test_print_log.py index 67ffa0571..848fc112d 100644 --- a/backend/tests/unit/test_print_log.py +++ b/backend/tests/unit/test_print_log.py @@ -180,6 +180,7 @@ class TestSchemaValidatesFromOrmRow: # Non-null so `test_every_declared_field_is_carried` can assert that # nothing falls back to its default. row.failure_reason = "warping" + row.user_verdict = "good" row.thumbnail_path = "archives/1/x/thumbnail.png" row.created_by_id = 2 row.created_by_username = "martin" diff --git a/backend/tests/unit/test_route_auth_coverage.py b/backend/tests/unit/test_route_auth_coverage.py index e4494c824..e834ae4f4 100644 --- a/backend/tests/unit/test_route_auth_coverage.py +++ b/backend/tests/unit/test_route_auth_coverage.py @@ -91,6 +91,12 @@ _PUBLIC_ROUTES: frozenset[tuple[str, str]] = frozenset( # Attached archive media target — validates a token bound to the # archive ID before returning the local timelapse. ("GET", "/api/v1/archives/{archive_id}/media/dl/{token}/{filename}"), + # Outcome-confirmation link (#1898) — the single-use capability token in + # the path is the credential; it is retired on the first verdict and + # only ever sets one verdict on one archive. The GET renders the + # confirmation page and writes nothing; the POST behind it records. + ("GET", "/api/v1/archives/confirm/{token}/{verdict}"), + ("POST", "/api/v1/archives/confirm/{token}/{verdict}"), # Obico cached frame — one-time nonce embedded in tags. ("GET", "/api/v1/obico/cached-frame/{nonce}"), # MakerWorld thumbnail proxy — fetches external URL; no Bambuddy data exposed. diff --git a/backend/tests/unit/test_telegram_outcome_buttons_1898.py b/backend/tests/unit/test_telegram_outcome_buttons_1898.py new file mode 100644 index 000000000..cd6f0a250 --- /dev/null +++ b/backend/tests/unit/test_telegram_outcome_buttons_1898.py @@ -0,0 +1,103 @@ +"""The outcome prompt must arrive even when Telegram refuses its buttons (#1898). + +The Good / Reject affordance on Telegram is an inline URL keyboard. Telegram +validates every button URL and rejects the whole ``sendMessage`` when one of +them is not a URL it accepts -- which is what an install without a public +``external_url`` produces. Losing the buttons is survivable; losing the message +the user is waiting for is not, and "I never got a message" is precisely the +report this feature keeps drawing. +""" + +import httpx +import pytest + +from backend.app.services.notification_service import NotificationService + +CONFIG = {"bot_token": "123456:AAbbCC", "chat_id": "-1002520100736"} +BUTTONS = [ + {"text": "\U0001f44d Good", "url": "http://bambuddy.local:8000/api/v1/archives/confirm/tok/good"}, + {"text": "\U0001f44e Reject", "url": "http://bambuddy.local:8000/api/v1/archives/confirm/tok/reject"}, +] +PNG = b"\x89PNG\r\n\x1a\n" + + +class _Client: + """Stand-in for httpx.AsyncClient that rejects any send carrying buttons.""" + + def __init__(self, reject_buttons: bool): + self.is_closed = False + self.reject_buttons = reject_buttons + self.calls: list[dict] = [] + + async def post(self, url, data=None, files=None, json=None): + body = json if json is not None else (data or {}) + self.calls.append(body) + if self.reject_buttons and "reply_markup" in body: + return httpx.Response( + 400, json={"ok": False, "description": "Bad Request: inline keyboard button URL is invalid"} + ) + return httpx.Response(200, json={"ok": True, "result": {}}) + + +def _service(reject_buttons: bool) -> tuple[NotificationService, _Client]: + service = NotificationService() + client = _Client(reject_buttons) + service._http_client = client # bypass real HTTP + return service, client + + +@pytest.mark.asyncio +async def test_a_rejected_keyboard_falls_back_to_a_plain_message(): + service, client = _service(reject_buttons=True) + + ok, detail = await service._send_telegram(CONFIG, "*How did your print come out?*\nbody", buttons=BUTTONS) + + assert ok, detail + assert len(client.calls) == 2, "expected one attempt with buttons and one without" + assert "reply_markup" in client.calls[0] + assert "reply_markup" not in client.calls[1] + assert client.calls[1]["text"].endswith("body") + + +@pytest.mark.asyncio +async def test_the_photo_variant_falls_back_too(): + """The finish photo rides along with the prompt, so this is the shape most + completed prints actually send.""" + service, client = _service(reject_buttons=True) + + ok, _ = await service._send_telegram(CONFIG, "*T*\nbody", image_data=PNG, buttons=BUTTONS) + + assert ok + assert len(client.calls) == 2 + assert "reply_markup" not in client.calls[1] + + +@pytest.mark.asyncio +async def test_buttons_are_kept_when_telegram_accepts_them(): + service, client = _service(reject_buttons=False) + + ok, _ = await service._send_telegram(CONFIG, "*T*\nbody", buttons=BUTTONS) + + assert ok + assert len(client.calls) == 1 + assert client.calls[0]["reply_markup"] == {"inline_keyboard": [BUTTONS]} + + +@pytest.mark.asyncio +async def test_a_buttonless_failure_is_still_reported(): + """The retry exists for the keyboard, not to paper over a bad token.""" + + class _AlwaysFails(_Client): + async def post(self, url, data=None, files=None, json=None): + self.calls.append(json if json is not None else (data or {})) + return httpx.Response(401, text="Unauthorized") + + service = NotificationService() + client = _AlwaysFails(reject_buttons=False) + service._http_client = client + + ok, detail = await service._send_telegram(CONFIG, "*T*\nbody") + + assert ok is False + assert "401" in detail + assert len(client.calls) == 1 diff --git a/frontend/src/__tests__/components/ConfirmOutcomeDialog.test.tsx b/frontend/src/__tests__/components/ConfirmOutcomeDialog.test.tsx new file mode 100644 index 000000000..98e898c68 --- /dev/null +++ b/frontend/src/__tests__/components/ConfirmOutcomeDialog.test.tsx @@ -0,0 +1,179 @@ +/** + * Tests for the post-print outcome confirmation dialog (#1898). + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from '../utils'; +import { ConfirmOutcomeDialog } from '../../components/ConfirmOutcomeDialog'; +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/server'; + +// The reject flow can hand off into the full PrintModal — not under test here. +vi.mock('../../components/PrintModal', () => ({ + PrintModal: () =>
, +})); + +const baseArchive = { + id: 42, + printer_id: 1, + filename: 'bracket.gcode.3mf', + file_path: 'archives/bracket.gcode.3mf', + file_size: 1024, + content_hash: null, + thumbnail_path: null, + timelapse_path: null, + print_name: 'Bracket', + print_time_seconds: 3600, + filament_used_grams: 20, + filament_type: 'PLA', + filament_color: null, + layer_height: 0.2, + total_layers: 100, + nozzle_diameter: 0.4, + bed_temperature: 60, + nozzle_temperature: 220, + status: 'completed', + started_at: null, + completed_at: null, + extra_data: null, + makerworld_url: null, + designer: null, + external_url: null, + is_favorite: false, + tags: null, + notes: null, + cost: null, + photos: ['finish_1.jpg'], + failure_reason: null, + user_verdict: null, + user_verdict_source: null, + confirm_requested: true, + quantity: 1, + energy_kwh: null, + energy_cost: null, + created_at: '2026-09-01T00:00:00Z', + created_by_id: null, + created_by_username: null, + run_count: 1, + last_run_at: null, + total_filament_actual_grams: null, + successful_run_count: 1, + failed_run_count: 0, +}; + +describe('ConfirmOutcomeDialog', () => { + let patchPayload: Record | null; + + beforeEach(() => { + patchPayload = null; + server.use( + http.get('/api/v1/archives/42', () => HttpResponse.json(baseArchive)), + http.patch('/api/v1/archives/42', async ({ request }) => { + patchPayload = (await request.json()) as Record; + return HttpResponse.json({ ...baseArchive, ...patchPayload }); + }), + http.get('/api/v1/settings/', () => HttpResponse.json({})), + ); + }); + + it('shows the finish photo and records a good verdict', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render(); + + await waitFor(() => { + expect(screen.getByText('Bracket')).toBeInTheDocument(); + }); + // Finish photo (photos[0]) is rendered + expect(screen.getByAltText('Bracket')).toBeInTheDocument(); + + await user.click(screen.getByText('Good')); + + await waitFor(() => { + expect(patchPayload).toEqual({ user_verdict: 'good', user_verdict_source: 'dialog' }); + }); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('records a reject with an optional reason', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render(); + + await waitFor(() => { + expect(screen.getByText('Bracket')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Reject')); + // Reason step appears + const select = await screen.findByRole('combobox'); + await user.selectOptions(select, 'warping'); + await user.click(screen.getByText('Save')); + + await waitFor(() => { + expect(patchPayload).toEqual({ + user_verdict: 'reject', + failure_reason: 'warping', + user_verdict_source: 'dialog', + }); + }); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('reject-and-reprint hands off into the print modal after saving', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Bracket')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Reject')); + await user.click(await screen.findByText('Print again')); + + await waitFor(() => { + expect(patchPayload).toEqual({ user_verdict: 'reject', user_verdict_source: 'dialog' }); + }); + await waitFor(() => { + expect(screen.getByTestId('print-modal')).toBeInTheDocument(); + }); + }); + + it('shows the already-decided state instead of the buttons', async () => { + server.use( + http.get('/api/v1/archives/42', () => + HttpResponse.json({ ...baseArchive, user_verdict: 'reject' }) + ), + ); + render(); + + await waitFor(() => { + expect(screen.getByText('Already marked as rejected.')).toBeInTheDocument(); + }); + expect(screen.queryByText('Good')).not.toBeInTheDocument(); + // No source on file (an archive answered before #1898 recorded it): no hint. + expect(screen.queryByTestId('verdict-source-hint')).not.toBeInTheDocument(); + }); + + it('explains a verdict the plate-clear default recorded', async () => { + server.use( + http.get('/api/v1/archives/42', () => + HttpResponse.json({ ...baseArchive, user_verdict: 'good', user_verdict_source: 'plate_clear' }) + ), + ); + render(); + + await waitFor(() => { + expect(screen.getByText('Already confirmed as a good part.')).toBeInTheDocument(); + }); + expect(screen.getByTestId('verdict-source-hint')).toHaveTextContent( + 'Recorded when the plate was cleared.' + ); + }); +}); diff --git a/frontend/src/__tests__/components/EditArchiveModal.test.tsx b/frontend/src/__tests__/components/EditArchiveModal.test.tsx index 535fb4a5b..4380aa8fc 100644 --- a/frontend/src/__tests__/components/EditArchiveModal.test.tsx +++ b/frontend/src/__tests__/components/EditArchiveModal.test.tsx @@ -588,4 +588,125 @@ describe('EditArchiveModal', () => { expect(Number(field.value)).toBeGreaterThanOrEqual(0); }); }); + + // Post-print outcome confirmation (#1898) + describe('outcome verdict source', () => { + const completed = { + ...mockArchive, + status: 'completed', + user_verdict: 'good', + confirm_requested: true, + }; + + it('explains a verdict the plate-clear default recorded', () => { + render( + , + ); + + expect(screen.getByTestId('verdict-source-hint')).toHaveTextContent( + 'Recorded when the plate was cleared.', + ); + }); + + it('shows no hint for a verdict with no recorded source', () => { + render( + , + ); + + expect(screen.queryByTestId('verdict-source-hint')).not.toBeInTheDocument(); + }); + + it('stamps the dialog as the source when the verdict is changed here', async () => { + const user = userEvent.setup(); + let seen: Record | null = null; + server.use( + http.patch('/api/v1/archives/:id', async ({ request }) => { + seen = (await request.json()) as Record; + return HttpResponse.json({ ...completed, ...seen }); + }), + ); + + render( + , + ); + + await user.selectOptions(screen.getByLabelText(/outcome verdict/i), 'reject'); + await user.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => expect(seen).not.toBeNull()); + expect(seen!.user_verdict).toBe('reject'); + expect(seen!.user_verdict_source).toBe('dialog'); + }); + }); + + describe('failure_reason on a machine status the dropdown cannot show', () => { + // The reject reason (#1898) reuses failure_reason, so the clearing branch + // grew "|| archive.failure_reason" to drop it when the verdict goes away. + // That clause also catches a 'cancelled' archive: print_queue writes + // status 'cancelled' with "Stopped by user (printer was offline)" when a + // stop is issued to an offline printer, the status dropdown has no + // 'cancelled' option, and so opening the editor and pressing Save with + // nothing touched wiped a reason the user could not put back. + const cancelled = { + ...mockArchive, + status: 'cancelled', + failure_reason: 'Stopped by user (printer was offline)', + user_verdict: null, + }; + + it('keeps the machine-written reason on an untouched save', async () => { + const user = userEvent.setup(); + let seen: Record | null = null; + server.use( + http.patch('/api/v1/archives/:id', async ({ request }) => { + seen = (await request.json()) as Record; + return HttpResponse.json({ ...cancelled, ...seen }); + }), + ); + + render(); + await user.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => expect(seen).not.toBeNull()); + expect(seen!).not.toHaveProperty('failure_reason'); + }); + + it('still drops the reason when a reject verdict is taken back', async () => { + // The behaviour the clause was written for, on the archive it was + // written for: a completed print whose reject reason no longer applies. + const user = userEvent.setup(); + let seen: Record | null = null; + const rejected = { + ...mockArchive, + status: 'completed', + user_verdict: 'reject', + failure_reason: 'warping', + }; + server.use( + http.patch('/api/v1/archives/:id', async ({ request }) => { + seen = (await request.json()) as Record; + return HttpResponse.json({ ...rejected, ...seen }); + }), + ); + + render(); + await user.selectOptions(screen.getByLabelText(/outcome verdict/i), 'good'); + await user.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => expect(seen).not.toBeNull()); + expect(seen!.failure_reason).toBeNull(); + }); + }); }); diff --git a/frontend/src/__tests__/components/Layout.test.tsx b/frontend/src/__tests__/components/Layout.test.tsx index 7dda8433f..6a2a5c355 100644 --- a/frontend/src/__tests__/components/Layout.test.tsx +++ b/frontend/src/__tests__/components/Layout.test.tsx @@ -3,7 +3,7 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { waitFor } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import { render } from '../utils'; import { Layout } from '../../components/Layout'; import { getAuthToken, setAuthToken } from '../../api/client'; @@ -731,4 +731,157 @@ describe('Layout', () => { expect(sidebarLink('/queue')).toBeNull(); }); }); + describe('outcome confirmation deep link (#1898)', () => { + // The URL a Pushover / Bark notification opens in a brand new tab. Layout + // is the PARENT of the page that renders at /archives, and React flushes a + // child's effects before its parent's — so while the page owned this deep + // link it dispatched `print-confirm-request` before Layout had added the + // listener, and then stripped the parameter, leaving nothing to recover. + // These tests pin the read where the dialog state lives. + const archive = { + id: 42, + printer_id: 1, + filename: 'bracket.gcode.3mf', + print_name: 'Bracket', + status: 'completed', + photos: null, + user_verdict: null, + user_verdict_source: null, + confirm_requested: true, + }; + + beforeEach(() => { + server.use( + http.get('/api/v1/archives/42', () => HttpResponse.json(archive)) + ); + }); + + afterEach(() => { + window.history.replaceState({}, '', '/'); + }); + + it('opens the dialog on a cold load, with no page mounted to relay the event', async () => { + window.history.replaceState({}, '', '/archives?confirm=42'); + + render(); + + expect(await screen.findByTestId('confirm-outcome-dialog')).toBeInTheDocument(); + }); + + it('strips the parameter but keeps the rest of the query', async () => { + window.history.replaceState({}, '', '/archives?confirm=42&status=completed'); + + render(); + + await screen.findByTestId('confirm-outcome-dialog'); + await waitFor(() => { + expect(window.location.search).toBe('?status=completed'); + }); + expect(window.location.pathname).toBe('/archives'); + }); + + it('ignores a confirm parameter that is not an archive id', async () => { + window.history.replaceState({}, '', '/archives?confirm=not-an-id'); + + render(); + + await waitFor(() => { + expect(document.querySelector('aside')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('confirm-outcome-dialog')).toBeNull(); + // Nothing was consumed, so nothing is rewritten either. + expect(window.location.search).toBe('?confirm=not-an-id'); + }); + + // Round 4: the dialog's only outcome for a user who may not record a + // verdict is a 403 from the PATCH, so it is gated like plate-not-empty — + // on archives:update_all / archives:update_own, the names the June + // permission migration left in the default groups. + describe('without permission to record a verdict', () => { + const withPermissions = (permissions: string[]) => { + server.use( + http.get('/api/v1/auth/status', () => + HttpResponse.json({ auth_enabled: true, requires_setup: false }), + ), + http.get('/api/v1/auth/me', () => + HttpResponse.json({ + id: 1, + username: 'tester', + role: 'user', + is_active: true, + is_admin: false, + groups: [{ id: 2, name: 'Standard Users' }], + permissions, + created_at: '2026-01-01T00:00:00Z', + }), + ), + ); + // localStorage is a mock in this suite, so the token goes through the + // client the way the app sets it — AuthProvider will not fetch + // /auth/me without one, and the permissions would stay empty. + setAuthToken('test-token'); + }; + + afterEach(() => { + setAuthToken(null); + }); + + it('ignores the event', async () => { + withPermissions(['archives:read_all']); + render(); + + await waitFor(() => { + expect(document.querySelector('aside')).toBeInTheDocument(); + }); + window.dispatchEvent( + new CustomEvent('print-confirm-request', { detail: { archive_id: 42 } }) + ); + + await waitFor(() => { + expect(screen.queryByTestId('confirm-outcome-dialog')).toBeNull(); + }); + }); + + it('consumes the deep link without opening anything', async () => { + withPermissions(['archives:read_all']); + window.history.replaceState({}, '', '/archives?confirm=42'); + + render(); + + // The parameter still goes: a link that can open nothing should not + // survive a reload either. + await waitFor(() => { + expect(window.location.search).toBe(''); + }); + expect(screen.queryByTestId('confirm-outcome-dialog')).toBeNull(); + }); + + it('opens for a user who may update their own archives', async () => { + withPermissions(['archives:read_own', 'archives:update_own']); + render(); + + await waitFor(() => { + expect(document.querySelector('aside')).toBeInTheDocument(); + }); + window.dispatchEvent( + new CustomEvent('print-confirm-request', { detail: { archive_id: 42 } }) + ); + + expect(await screen.findByTestId('confirm-outcome-dialog')).toBeInTheDocument(); + }); + }); + + it('still opens from the WebSocket event, which carries no URL', async () => { + render(); + + await waitFor(() => { + expect(document.querySelector('aside')).toBeInTheDocument(); + }); + window.dispatchEvent( + new CustomEvent('print-confirm-request', { detail: { archive_id: 42 } }) + ); + + expect(await screen.findByTestId('confirm-outcome-dialog')).toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/__tests__/components/PrintModal.test.tsx b/frontend/src/__tests__/components/PrintModal.test.tsx index e87bcebb3..25d6eb478 100644 --- a/frontend/src/__tests__/components/PrintModal.test.tsx +++ b/frontend/src/__tests__/components/PrintModal.test.tsx @@ -1611,6 +1611,93 @@ describe('PrintModal', () => { expect(capturedBody?.cleanup_library_after_dispatch).toBeUndefined(); }); }); + + describe('ask-for-outcome pill (#1898 follow-up)', () => { + // The pill outside the collapsed Print Options panel and the row inside it + // edit the same printOptions.confirm_outcome, so flipping either must be + // reflected by the other and land in the submitted payload. + // Both carry the label "Ask for Outcome"; the pill is the only button with + // that name, the panel row is found through its description text. + const pill = () => screen.getByRole('button', { name: 'Ask for Outcome' }); + const panelRowDesc = 'Ask whether the print came out well after it completes'; + const panelRow = () => screen.getByText(panelRowDesc).closest('div')!.parentElement!; + + it('starts off and toggles on a click', async () => { + const user = userEvent.setup(); + render( + + ); + + expect(pill()).toHaveAttribute('aria-pressed', 'false'); + await user.click(pill()); + expect(pill()).toHaveAttribute('aria-pressed', 'true'); + }); + + it('shares its state with the row inside Print Options', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(pill()); + // The panel opens expanded when a printer is preselected; expand otherwise. + if (!screen.queryByText(panelRowDesc)) await user.click(screen.getByText('Print Options')); + const row = panelRow(); + expect(within(row).getByRole('button', { name: 'On' })).toHaveClass('bg-bambu-green'); + + await user.click(within(row).getByRole('button', { name: 'Off' })); + expect(pill()).toHaveAttribute('aria-pressed', 'false'); + }); + + it('submits confirm_outcome=true with the queue item when turned on', async () => { + let capturedBody: Record | null = null; + server.use( + http.post('/api/v1/queue/', async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ id: 1, status: 'pending' }); + }), + ); + + const user = userEvent.setup(); + render( + + ); + + await user.click(pill()); + await user.click(document.querySelector('button[type="submit"]') as HTMLElement); + + await waitFor(() => expect(capturedBody).not.toBeNull()); + expect(capturedBody!.confirm_outcome).toBe(true); + }); + + it('reflects the queue item value in edit mode and saves the change', async () => { + let capturedBody: Record | null = null; + server.use( + http.patch('/api/v1/queue/:id', async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ id: 1, status: 'pending' }); + }), + ); + + const user = userEvent.setup(); + render( + + ); + + expect(pill()).toHaveAttribute('aria-pressed', 'true'); + await user.click(pill()); + await user.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => expect(capturedBody).not.toBeNull()); + expect(capturedBody!.confirm_outcome).toBe(false); + }); + }); }); diff --git a/frontend/src/__tests__/pages/ArchivesPage.test.tsx b/frontend/src/__tests__/pages/ArchivesPage.test.tsx index 4d830e734..3f969df75 100644 --- a/frontend/src/__tests__/pages/ArchivesPage.test.tsx +++ b/frontend/src/__tests__/pages/ArchivesPage.test.tsx @@ -618,4 +618,92 @@ describe('ArchivesPage', () => { }); }); }); + + // #1898: the "outcome?" badge on a print still waiting for its verdict opens + // the dialog. The PATCH behind it checks archives:update against the owner, + // like the menu item beside it, so for anyone else the badge is a status only. + describe('awaiting-outcome badge', () => { + const awaiting = { ...mockArchives[0], confirm_requested: true, user_verdict: null, created_by_id: 7 }; + + const signInWith = (permissions: string[]) => { + setAuthToken('verdict-token', 'session'); + server.use( + http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })), + http.get('/api/v1/auth/me', () => HttpResponse.json({ + id: 7, + username: 'operator', + is_active: true, + is_admin: false, + groups: [], + permissions, + created_at: '2026-08-18T00:00:00Z', + })), + http.get('/api/v1/archives/', () => HttpResponse.json([awaiting])), + ); + }; + + afterEach(() => { + window.localStorage.removeItem('archiveViewMode'); + }); + + it('is a status only for a user who may not update the archive', async () => { + signInWith(['archives:read_all']); + render(); + + const badge = await screen.findByRole('button', { name: 'outcome?' }); + await waitFor(() => expect(badge).toBeDisabled()); + expect(badge).toHaveAttribute('title', 'You do not have permission to update archives'); + fireEvent.click(badge); + expect(screen.queryByTestId('confirm-outcome-dialog')).toBeNull(); + }); + + it('is a status only in the list view too', async () => { + window.localStorage.setItem('archiveViewMode', 'list'); + signInWith(['archives:read_all']); + render(); + + const badge = await screen.findByRole('button', { name: 'outcome?' }); + await waitFor(() => expect(badge).toBeDisabled()); + expect(badge).toHaveAttribute('title', 'You do not have permission to update archives'); + }); + + it('is disabled on somebody else\'s print for a user who may update only their own', async () => { + signInWith(['archives:read_all', 'archives:update_own']); + server.use(http.get('/api/v1/archives/', () => HttpResponse.json([{ ...awaiting, created_by_id: 8 }]))); + render(); + + const badge = await screen.findByRole('button', { name: 'outcome?' }); + await waitFor(() => expect(badge).toBeDisabled()); + }); + + it('opens the dialog for the owner', async () => { + signInWith(['archives:read_all', 'archives:update_own']); + render(); + + const badge = await screen.findByRole('button', { name: 'outcome?' }); + await waitFor(() => expect(badge).toBeEnabled()); + fireEvent.click(badge); + expect(await screen.findByTestId('confirm-outcome-dialog')).toBeInTheDocument(); + }); + }); + + describe('unconfirmed filter', () => { + afterEach(() => { + window.localStorage.removeItem('archiveFilterUnconfirmed'); + }); + + it('is cleared by Reset like every other top filter', async () => { + render(); + await screen.findByText('Benchy'); + + fireEvent.click(screen.getByTitle('Show only prints still waiting for their outcome verdict')); + // Neither fixture archive is waiting for a verdict, so the filter empties the list. + await waitFor(() => expect(screen.queryByText('Benchy')).not.toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: 'Reset' })); + + expect(await screen.findByText('Benchy')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Reset' })).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/__tests__/pages/PrintersPageCardVerdict.test.tsx b/frontend/src/__tests__/pages/PrintersPageCardVerdict.test.tsx new file mode 100644 index 000000000..2503a41ba --- /dev/null +++ b/frontend/src/__tests__/pages/PrintersPageCardVerdict.test.tsx @@ -0,0 +1,188 @@ +/** + * Good / Reject on the printer card (#1898). + * + * The card offers the verdict where the operator is standing: the plate-clear + * gate is up, the print that just finished asked for a verdict, and answering + * it should not cost a trip to the archive. + * + * What is pinned here is which button does what. Good is answered on the spot; + * Reject wants a reason and possibly a reprint, so it opens the dialog and + * records nothing by itself. The card's mutation therefore only ever sends + * "good", and its type says so — a verdict it cannot send must not be able to + * reach a success message that calls it good. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { render } from '../utils'; +import { server } from '../mocks/server'; +import { PrintersPage } from '../../pages/PrintersPage'; +import { setAuthToken } from '../../api/client'; + +const printer = { + id: 1, + name: 'H2S-1', + ip_address: '192.168.20.10', + model: 'H2S', + serial_number: '00M09A350100001', + access_code: '12345678', + enabled: true, + is_active: true, + nozzle_diameter: 0.4, + nozzle_type: 'hardened_steel', + location: 'Workshop', + auto_archive: true, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', +}; + +const status = { + connected: true, + state: 'FINISH', + awaiting_plate_clear: true, + progress: 100, + layer_num: 0, + total_layers: 0, + temperatures: { nozzle: 25, bed: 25, chamber: 25 }, + remaining_time: 0, + filename: 'bracket.gcode.3mf', + wifi_signal: -50, + vt_tray: [], +}; + +const uiPreferences = { + ams_humidity_good: 40, + ams_humidity_fair: 60, + ams_temp_good: 30, + ams_temp_fair: 35, + require_plate_clear: true, +}; + +const finishedPrint = { + id: 42, + printer_id: 1, + filename: 'bracket.gcode.3mf', + print_name: 'Bracket', + status: 'completed', + confirm_requested: true, + user_verdict: null, + user_verdict_source: null, + photos: null, + created_by_id: 7, +}; + +describe('PrintersPage — verdict on the card', () => { + let patched: Array>; + + beforeEach(() => { + patched = []; + server.use( + http.get('/api/v1/printers/', () => HttpResponse.json([printer])), + http.get('/api/v1/printers/:id/status', () => HttpResponse.json(status)), + http.get('/api/v1/settings/ui-preferences', () => HttpResponse.json(uiPreferences)), + http.get('/api/v1/settings/', () => HttpResponse.json({ ...uiPreferences, auto_archive: true })), + http.get('/api/v1/archives/', () => HttpResponse.json([finishedPrint])), + http.patch('/api/v1/archives/:id', async ({ request }) => { + const body = (await request.json()) as Record; + patched.push(body); + return HttpResponse.json({ ...finishedPrint, ...body }); + }), + ); + }); + + it('records good from the card and says so', async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByTitle('Good')); + + await waitFor(() => expect(patched).toHaveLength(1)); + expect(patched[0].user_verdict).toBe('good'); + expect(patched[0].user_verdict_source).toBe('printer_card'); + expect(await screen.findByText('Marked as good part')).toBeInTheDocument(); + }); + + it('sends the reject to the dialog and records nothing on its own', async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByTitle('Reject')); + + expect(await screen.findByTestId('confirm-outcome-dialog')).toBeInTheDocument(); + expect(patched).toHaveLength(0); + expect(screen.queryByText('Marked as good part')).toBeNull(); + }); + + // The PATCH checks archives:update against the archive's owner, so the pair + // is only offered to whoever it can succeed for. Mark cleared, which has its + // own permission, stays either way. + describe('with auth enabled', () => { + // Before /auth/me has answered, every permission check says yes, so an + // absence is only worth asserting once both it and the archive are in. + let meServed: boolean; + let archiveServed: boolean; + const settled = async () => { + await waitFor(() => expect(meServed && archiveServed).toBe(true)); + expect(await screen.findByRole('button', { name: 'Mark plate as cleared' })).toBeInTheDocument(); + }; + + const signInWith = (permissions: string[], archive = finishedPrint) => { + meServed = false; + archiveServed = false; + setAuthToken('card-verdict-token', 'session'); + server.use( + http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })), + http.get('/api/v1/auth/me', () => { + meServed = true; + return HttpResponse.json({ + id: 7, + username: 'operator', + is_active: true, + is_admin: false, + groups: [], + permissions, + created_at: '2026-08-18T00:00:00Z', + }); + }), + http.get('/api/v1/archives/', () => { + archiveServed = true; + return HttpResponse.json([archive]); + }), + ); + }; + + afterEach(() => { + setAuthToken(null); + }); + + it('offers no verdict to a user who may not update archives', async () => { + signInWith(['printers:read', 'printers:clear_plate', 'archives:read_all']); + render(); + + await settled(); + await waitFor(() => expect(screen.queryByTitle('Good')).toBeNull()); + expect(screen.queryByTitle('Reject')).toBeNull(); + }); + + it('offers no verdict on somebody else\'s print to a user who may update only their own', async () => { + signInWith( + ['printers:read', 'printers:clear_plate', 'archives:read_all', 'archives:update_own'], + { ...finishedPrint, created_by_id: 8 }, + ); + render(); + + await settled(); + await waitFor(() => expect(screen.queryByTitle('Good')).toBeNull()); + }); + + it('offers it to the owner', async () => { + signInWith(['printers:read', 'printers:clear_plate', 'archives:read_all', 'archives:update_own']); + render(); + + expect(await screen.findByTitle('Good')).toBeInTheDocument(); + expect(screen.getByTitle('Reject')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/__tests__/pages/QueuePage.test.tsx b/frontend/src/__tests__/pages/QueuePage.test.tsx index 16726ba92..f0f3d2130 100644 --- a/frontend/src/__tests__/pages/QueuePage.test.tsx +++ b/frontend/src/__tests__/pages/QueuePage.test.tsx @@ -811,6 +811,29 @@ describe('QueuePage', () => { }); }); + describe('outcome prompt badge (#1898)', () => { + it('shows the badge only on items with confirm_outcome set', async () => { + const items = mockQueueItems.map((item, i) => + i === 0 ? { ...item, confirm_outcome: true } : { ...item, confirm_outcome: false } + ); + server.use(http.get('/api/v1/queue/', () => HttpResponse.json(items))); + + render(); + + await waitFor(() => expect(screen.getByText('Test Print 1')).toBeInTheDocument()); + const badges = screen.getAllByText('Outcome prompt'); + expect(badges).toHaveLength(1); + expect(badges[0].closest('span')).toHaveAttribute('title', 'Asks how the print came out after it completes'); + }); + + it('renders nothing when no item asks for an outcome', async () => { + render(); + + await waitFor(() => expect(screen.getByText('Test Print 1')).toBeInTheDocument()); + expect(screen.queryByText('Outcome prompt')).not.toBeInTheDocument(); + }); + }); + describe('filament-short ▶ flow (#1496)', () => { /** * The dispatch pre-flight flags a queue item as filament_short. The user @@ -1030,4 +1053,51 @@ describe('QueuePage', () => { expect('gcode_injection' in patchBody!).toBe(false); }); }); + + describe('bulk edit ask-for-outcome (#1898)', () => { + const openBulkEdit = async () => { + render(); + await waitFor(() => expect(screen.getByText('Test Print 1')).toBeInTheDocument()); + await userEvent.click(screen.getByText('Select All')); + await userEvent.click(await screen.findByTitle('Edit Selected')); + return screen.getByText('Edit 1 Item').closest('div')!.parentElement!; + }; + + const captureBulkPatch = () => { + const captured: { body: Record | null } = { body: null }; + server.use( + http.patch('/api/v1/queue/bulk', async ({ request }) => { + captured.body = (await request.json()) as Record; + return HttpResponse.json({ updated_count: 1, skipped_count: 0, message: 'Updated 1 items' }); + }), + ); + return captured; + }; + + it('sends confirm_outcome with the bulk PATCH once toggled', async () => { + const captured = captureBulkPatch(); + const dialog = await openBulkEdit(); + + const row = within(dialog).getByText('Ask for outcome').closest('div')!; + await userEvent.click(within(row).getByText('On')); + await userEvent.click(within(dialog).getByText('Apply Changes')); + + await waitFor(() => expect(captured.body).not.toBeNull()); + expect(captured.body!.item_ids).toEqual([1]); + expect(captured.body!.confirm_outcome).toBe(true); + }); + + it('leaves confirm_outcome out of the PATCH while it stays on "no change"', async () => { + const captured = captureBulkPatch(); + const dialog = await openBulkEdit(); + + const timelapseRow = within(dialog).getByText('Timelapse').closest('div')!; + await userEvent.click(within(timelapseRow).getByText('On')); + await userEvent.click(within(dialog).getByText('Apply Changes')); + + await waitFor(() => expect(captured.body).not.toBeNull()); + expect(captured.body!.timelapse).toBe(true); + expect('confirm_outcome' in captured.body!).toBe(false); + }); + }); }); diff --git a/frontend/src/__tests__/pages/SettingsPage.test.tsx b/frontend/src/__tests__/pages/SettingsPage.test.tsx index 5ce6d1619..2f59dc9a4 100644 --- a/frontend/src/__tests__/pages/SettingsPage.test.tsx +++ b/frontend/src/__tests__/pages/SettingsPage.test.tsx @@ -1617,6 +1617,93 @@ describe('SettingsPage', () => { }); }); }); + + // -------------------------------------------------------------------- + // Ask for the outcome of prints Bambuddy did not start (#1898) + // -------------------------------------------------------------------- + describe('outcome prompt for external prints (#1898)', () => { + const externalLabel = 'Also ask for prints started outside Bambuddy'; + + const openDefaultPrintOptions = async () => { + render(); + const user = userEvent.setup(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Workflow' })).toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'Workflow' })); + return user; + }; + + it('offers the toggle alongside the per-job outcome default', async () => { + await openDefaultPrintOptions(); + + expect(await screen.findByText(externalLabel)).toBeInTheDocument(); + expect(screen.getByText('Ask for Outcome')).toBeInTheDocument(); + }); + + it('shows it off when the backend has no value for it', async () => { + // Default false: an install that never touches it keeps today's + // behaviour, where only queued prints are asked about. + await openDefaultPrintOptions(); + + const label = await screen.findByText(externalLabel); + const row = label.closest('div')!.parentElement!; + expect(within(row).getByRole('checkbox')).not.toBeChecked(); + }); + + it('sends the new value on save', async () => { + let saved: Record | null = null; + server.use( + http.put('/api/v1/settings/', async ({ request }) => { + saved = (await request.json()) as Record; + return HttpResponse.json({ ...mockSettings, ...saved }); + }) + ); + const user = await openDefaultPrintOptions(); + + const label = await screen.findByText(externalLabel); + // The page suppresses auto-save for 100ms after the settings load. + await new Promise((resolve) => setTimeout(resolve, 200)); + const row = label.closest('div')!.parentElement!; + await user.click(within(row).getByRole('checkbox')); + + await waitFor(() => { + expect(saved).not.toBeNull(); + }, { timeout: 3000 }); + expect(saved!.confirm_outcome_external_prints).toBe(true); + }); + + it('reflects a value the backend already has', async () => { + server.use( + http.get('/api/v1/settings/', () => + HttpResponse.json({ ...mockSettings, confirm_outcome_external_prints: true }) + ) + ); + await openDefaultPrintOptions(); + + const label = await screen.findByText(externalLabel); + const row = label.closest('div')!.parentElement!; + expect(within(row).getByRole('checkbox')).toBeChecked(); + }); + }); + + // -------------------------------------------------------------------- + // The plate-clear default answers outcome prompts on its own (#1898), so + // its help text has to name that consequence — the farm case was a user + // who had it on and could not work out why Telegram said "already used". + // -------------------------------------------------------------------- + describe('plate-clear outcome default help text (#1898)', () => { + it('warns that a later Telegram or link answer only shows the result', async () => { + render(); + const user = userEvent.setup(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Workflow' })).toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'Workflow' })); + + const label = await screen.findByText('Count unanswered outcomes as good on plate release'); + const row = label.closest('div')!; + expect(row).toHaveTextContent( + /a Telegram or link answer after that only shows the recorded result/i, + ); + }); + }); }); /** diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a0c0b5f90..7dba84dd0 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -790,6 +790,9 @@ export interface ArchivePrinterMedia { warnings: Array<'printer_missing' | 'timelapse_unavailable' | 'ipcam_unavailable' | 'printer_files_forbidden'>; } +/** How a post-print outcome verdict reached the archive (#1898). */ +export type VerdictSource = 'dialog' | 'link' | 'plate_clear' | 'printer_card' | 'api' | 'reaction'; + export interface Archive { id: number; printer_id: number | null; @@ -836,6 +839,13 @@ export interface Archive { cost: number | null; photos: string[] | null; failure_reason: string | null; + // Post-print outcome confirmation (#1898) + user_verdict: 'good' | 'reject' | null; + // How the verdict arrived; 'reaction' is written by the Telegram reaction + // handler (#3046) and is labelled here so it reads correctly once that lands. + user_verdict_source: VerdictSource | null; + user_verdict_at: string | null; + confirm_requested: boolean; quantity: number; energy_kwh: number | null; energy_cost: number | null; @@ -930,6 +940,11 @@ export interface FailureAnalysis { total_prints: number; failed_prints: number; failure_rate: number; + // Quality dimension (#1898): completed prints the user rejected. Optional + // so a frontend build against an older backend degrades gracefully. + rejected_prints?: number; + yield_rate?: number; + rejects_by_reason?: Record; failures_by_reason: Record; failures_by_filament: Record; failures_by_printer: Record; @@ -1457,6 +1472,12 @@ export interface AppSettings { default_layer_inspect: boolean; default_timelapse: boolean; default_nozzle_offset_cali: CalibrationMode; + // Default for the per-job "ask for outcome afterwards" toggle (#1898) + default_confirm_outcome: boolean; + // Also ask for prints Bambuddy archived but did not dispatch (#1898) + confirm_outcome_external_prints: boolean; + // Count unanswered outcome prompts as "good" when the plate is released (#1898) + confirm_default_good_on_plate_clear: boolean; // Staggered batch start defaults stagger_group_size: number; stagger_interval_minutes: number; @@ -2596,6 +2617,8 @@ export interface PrintQueueItem { timelapse: boolean; use_ams: boolean; nozzle_offset_cali: CalibrationMode; + // Ask for a post-print outcome verdict when this job completes (#1898) + confirm_outcome: boolean; preheat_override: 'inherit' | 'on' | 'off'; preheat_chamber_target_override: number | null; status: 'pending' | 'printing' | 'completed' | 'failed' | 'skipped' | 'cancelled'; @@ -2726,6 +2749,8 @@ export interface PrintQueueItemCreate { timelapse?: boolean; use_ams?: boolean; nozzle_offset_cali?: CalibrationMode; + // Ask for a post-print outcome verdict when this job completes (#1898) + confirm_outcome?: boolean; preheat_override?: 'inherit' | 'on' | 'off'; preheat_chamber_target_override?: number | null; // Auto-print G-code injection @@ -2814,6 +2839,8 @@ export interface PrintQueueItemUpdate { timelapse?: boolean; use_ams?: boolean; nozzle_offset_cali?: CalibrationMode; + // Ask for a post-print outcome verdict when this job completes (#1898) + confirm_outcome?: boolean; preheat_override?: 'inherit' | 'on' | 'off'; preheat_chamber_target_override?: number | null; // Auto-print G-code injection @@ -2841,6 +2868,8 @@ export interface PrintQueueBulkUpdate { timelapse?: boolean; use_ams?: boolean; nozzle_offset_cali?: CalibrationMode; + // Ask for a post-print outcome verdict when this job completes (#1898) + confirm_outcome?: boolean; preheat_override?: 'inherit' | 'on' | 'off'; preheat_chamber_target_override?: number | null; // Auto-print G-code injection @@ -2981,6 +3010,8 @@ export interface NotificationProvider { // Build plate detection on_plate_not_empty: boolean; on_plate_clear_required: boolean; + // Post-print outcome confirmation (#1898) + on_print_confirm_request: boolean; // Bed cooled on_bed_cooled: boolean; on_ha_sensor_alert: boolean; @@ -3045,6 +3076,8 @@ export interface NotificationProviderCreate { // Build plate detection on_plate_not_empty?: boolean; on_plate_clear_required?: boolean; + // Post-print outcome confirmation (#1898) + on_print_confirm_request?: boolean; // Bed cooled on_bed_cooled?: boolean; on_ha_sensor_alert?: boolean; @@ -3102,6 +3135,8 @@ export interface NotificationProviderUpdate { // Build plate detection on_plate_not_empty?: boolean; on_plate_clear_required?: boolean; + // Post-print outcome confirmation (#1898) + on_print_confirm_request?: boolean; // Bed cooled on_bed_cooled?: boolean; on_ha_sensor_alert?: boolean; @@ -5105,6 +5140,11 @@ export const api = { quantity?: number; external_url?: string | null; filament_used_grams?: number | null; + // Post-print outcome verdict (#1898); null clears it + user_verdict?: 'good' | 'reject' | null; + // Which surface answered. The backend only accepts the sources a client + // can honestly claim and defaults to 'api' when none is given. + user_verdict_source?: 'dialog' | 'printer_card'; }) => request(`/archives/${id}`, { method: 'PATCH', diff --git a/frontend/src/components/AddNotificationModal.tsx b/frontend/src/components/AddNotificationModal.tsx index 3d02b980d..c152ecbf9 100644 --- a/frontend/src/components/AddNotificationModal.tsx +++ b/frontend/src/components/AddNotificationModal.tsx @@ -45,6 +45,9 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal const [onStockReorderAlert, setOnStockReorderAlert] = useState(provider?.on_stock_reorder_alert ?? false); const [onStockBreakAlert, setOnStockBreakAlert] = useState(provider?.on_stock_break_alert ?? false); const [onPlateClearRequired, setOnPlateClearRequired] = useState(provider?.on_plate_clear_required ?? false); + // Post-print outcome confirmation (#1898). Defaults ON — it only fires for + // prints that opted in per-job, so the toggle exists to mute a channel. + const [onPrintConfirmRequest, setOnPrintConfirmRequest] = useState(provider?.on_print_confirm_request ?? true); const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false); const [onHaSensorAlert, setOnHaSensorAlert] = useState(provider?.on_ha_sensor_alert ?? false); const [onLocationHaSensorAlert, setOnLocationHaSensorAlert] = useState( @@ -205,6 +208,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal on_stock_reorder_alert: onStockReorderAlert, on_stock_break_alert: onStockBreakAlert, on_plate_clear_required: onPlateClearRequired, + on_print_confirm_request: onPrintConfirmRequest, on_bed_cooled: onBedCooled, on_ha_sensor_alert: onHaSensorAlert, on_location_ha_sensor_alert: onLocationHaSensorAlert, @@ -625,6 +629,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
+
+
+ {t('notifications.printConfirmRequest')} + {t('notifications.printConfirmRequestDescription')} +
+ +
{t('notifications.bedCooled')} @@ -714,6 +725,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal if (onPrintProgress) enabledEvents.push({ key: 'on_print_progress', label: t('notifications.progress') }); if (onBillingChargeFailed) enabledEvents.push({ key: 'on_billing_charge_failed', label: t('notifications.billingChargeFailedLabel') }); if (onPlateClearRequired) enabledEvents.push({ key: 'on_plate_clear_required', label: t('notifications.plateClearRequired') }); + if (onPrintConfirmRequest) enabledEvents.push({ key: 'on_print_confirm_request', label: t('notifications.printConfirmRequest') }); if (onBedCooled) enabledEvents.push({ key: 'on_bed_cooled', label: t('notifications.bedCooled') }); if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') }); if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') }); diff --git a/frontend/src/components/ConfirmOutcomeDialog.tsx b/frontend/src/components/ConfirmOutcomeDialog.tsx new file mode 100644 index 000000000..a0bd5285b --- /dev/null +++ b/frontend/src/components/ConfirmOutcomeDialog.tsx @@ -0,0 +1,208 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { ThumbsUp, ThumbsDown, X, RotateCcw, ImageOff } from 'lucide-react'; +import { api } from '../api/client'; +import { FAILURE_REASON_KEYS } from './EditArchiveModal'; +import { verdictSourceKey } from '../utils/verdictSource'; +import { PrintModal } from './PrintModal'; +import { useToast } from '../contexts/ToastContext'; + +interface ConfirmOutcomeDialogProps { + archiveId: number; + onClose: () => void; +} + +/** + * Post-print outcome confirmation (#1898), Formlabs-style: finish photo plus + * Good / Reject. Opens from the print_confirm_request WebSocket event (via + * Layout), from the pending badge on an archive card, and from the + * ?confirm= deep link a push notification carries. Rejecting offers an + * optional reason and a one-click reprint through the regular PrintModal. + */ +export function ConfirmOutcomeDialog({ archiveId, onClose }: ConfirmOutcomeDialogProps) { + const { t } = useTranslation(); + const { showToast } = useToast(); + const queryClient = useQueryClient(); + const [rejecting, setRejecting] = useState(false); + const [rejectReason, setRejectReason] = useState(''); + const [showReprint, setShowReprint] = useState(false); + + const { data: archive } = useQuery({ + queryKey: ['archive', archiveId], + queryFn: () => api.getArchive(archiveId), + }); + + const verdictMutation = useMutation({ + mutationFn: (data: { user_verdict: 'good' | 'reject'; failure_reason?: string }) => + api.updateArchive(archiveId, { ...data, user_verdict_source: 'dialog' }), + onSuccess: (_updated, variables) => { + queryClient.invalidateQueries({ queryKey: ['archives'] }); + queryClient.invalidateQueries({ queryKey: ['archive', archiveId] }); + showToast( + variables.user_verdict === 'good' + ? t('confirmOutcome.savedGood') + : t('confirmOutcome.savedReject'), + 'success', + ); + if (variables.user_verdict === 'good') { + onClose(); + } + }, + onError: () => showToast(t('confirmOutcome.saveFailed'), 'error'), + }); + + const name = archive?.print_name || archive?.filename || ''; + // The finish photo is prepended to archive.photos at print end (#1397); + // fall back to the thumbnail when no camera shot exists. + const photoFilename = archive?.photos?.[0] ?? null; + const photoUrl = photoFilename ? api.getArchivePhotoUrl(archiveId, photoFilename) : null; + const alreadyDecided = archive?.user_verdict != null; + // Why this print already has a verdict — the plate-clear default answers + // prompts on its own, and without this the answer looks like it appeared + // from nowhere (#1898). + const sourceKey = verdictSourceKey(archive?.user_verdict_source); + + const handleGood = () => verdictMutation.mutate({ user_verdict: 'good' }); + const handleRejectSave = (reprint: boolean) => { + verdictMutation.mutate( + { user_verdict: 'reject', ...(rejectReason ? { failure_reason: rejectReason } : {}) }, + { + onSuccess: () => { + if (reprint) { + setShowReprint(true); + } else { + onClose(); + } + }, + }, + ); + }; + + if (showReprint && archive) { + return ( + + ); + } + + return ( +
+
+ {/* Header */} +
+

{t('confirmOutcome.title')}

+ +
+ + {/* Photo */} +
+ {photoUrl ? ( + {name} + ) : ( +
+ + {t('confirmOutcome.noPhoto')} +
+ )} +
+ +
+

{name}

+ + {alreadyDecided ? ( + <> +

+ {archive?.user_verdict === 'good' + ? t('confirmOutcome.alreadyGood') + : t('confirmOutcome.alreadyRejected')} +

+ {sourceKey && ( +

+ {t(sourceKey)} +

+ )} + + ) : !rejecting ? ( +
+ + +
+ ) : ( +
+ + +
+ + +
+ +
+ )} + + {!alreadyDecided && !rejecting && ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/EditArchiveModal.tsx b/frontend/src/components/EditArchiveModal.tsx index 9211b0e7c..e689c5bdd 100644 --- a/frontend/src/components/EditArchiveModal.tsx +++ b/frontend/src/components/EditArchiveModal.tsx @@ -8,6 +8,7 @@ import { Button } from './Button'; import { PrintLogTable } from './PrintLogTable'; import { invalidateArchiveAndProjectViews } from '../utils/projectQueries'; import { assignableProjects } from '../utils/projectTree'; +import { verdictSourceKey } from '../utils/verdictSource'; // Keys for failure reasons - translated at render time. // Exported so the Print Log per-row classification editor (#1687 part 4) @@ -89,6 +90,9 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr ? failureReason : null; const [status, setStatus] = useState(archive.status); + // Post-print quality verdict (#1898): '' = none, 'good' / 'reject' + const [userVerdict, setUserVerdict] = useState(archive.user_verdict ?? ''); + const verdictSourceHintKey = verdictSourceKey(archive.user_verdict_source); const [quantity, setQuantity] = useState(archive.quantity ?? 1); // Kept as a string so the field can be genuinely empty: a print archived // without its 3MF has no figure at all, and "" has to stay distinguishable @@ -246,6 +250,12 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr updateData.status = status; } + // Verdict only when touched, same reasoning as status; '' clears it. + if (userVerdict !== (archive.user_verdict ?? '')) { + updateData.user_verdict = userVerdict === '' ? null : (userVerdict as 'good' | 'reject'); + updateData.user_verdict_source = 'dialog'; + } + // Sent only when the user actually touched it, so an ordinary save of an // archive that has its 3MF cannot overwrite the sliced figure with a // rounded one from the input. @@ -262,11 +272,19 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr updateData.filament_used_grams = parsedGrams; } - // Handle failure_reason based on status + // Handle failure_reason based on status. A completed-but-rejected print + // (#1898) uses the same field as its reject reason. if (status === 'failed' || status === 'aborted') { updateData.failure_reason = failureReason || undefined; - } else if (archive.status === 'failed' || archive.status === 'aborted') { - // Clear failure_reason when changing from failed/aborted to another status + } else if (status === 'completed' && userVerdict === 'reject') { + updateData.failure_reason = failureReason || undefined; + } else if (archive.status === 'failed' || archive.status === 'aborted' || archive.user_verdict === 'reject') { + // Clear failure_reason when neither a failure nor a reject carries one. + // Gated on the archive having held a reject verdict rather than on the + // field merely being populated: a 'cancelled' or 'stopped' archive is + // neither failed nor aborted, its reason is written by the machine + // ("Stopped by user (printer was offline)"), and the status dropdown has + // no option that would let the user put it back. updateData.failure_reason = null; } @@ -529,8 +547,37 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
- {/* Failure Reason - only show for failed/aborted prints */} - {(status === 'failed' || status === 'aborted') && ( + {/* Outcome verdict (#1898) - only meaningful on completed prints */} + {status === 'completed' && ( +
+ + + {/* How the stored verdict got here (#1898) — hidden once the + select no longer shows that verdict, since the hint would + then describe an answer the user is replacing. */} + {verdictSourceHintKey && userVerdict === (archive.user_verdict ?? '') && ( +

+ {t(verdictSourceHintKey)} +

+ )} +
+ )} + + {/* Failure Reason - shown for failed/aborted prints and for + completed-but-rejected ones (#1898: the reject reason reuses + this field and vocabulary) */} + {(status === 'failed' || status === 'aborted' || (status === 'completed' && userVerdict === 'reject')) && (
updateSetting('confirm_outcome_external_prints', e.target.checked)} + className="sr-only peer" + /> +
+ +
@@ -5021,6 +5049,27 @@ export function SettingsPage() {
+ {/* Post-print outcome confirmation (#1898): default unanswered + prompts to "good" the moment the plate is released. */} +
+
+

+ {t('settings.confirmDefaultGoodOnPlateClear', 'Count unanswered outcomes as good on plate release')} +

+

+ {t('settings.confirmDefaultGoodOnPlateClearDescription', 'When the plate is released (manually or by the next queued print) and the print\'s outcome prompt is still unanswered, record it as a good part automatically. The print then counts as good as soon as the plate is released; a Telegram or link answer after that only shows the recorded result.')} +

+
+ +
diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index 1f17ad5b3..ebb97a73f 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -787,6 +787,19 @@ function FailureAnalysisWidget({ size = 1, dateFrom, dateTo, createdById }: {
{t('stats.failedPrintsCount', { failed: analysis.failed_prints, total: analysis.total_prints })}
+ {/* Quality dimension (#1898): completed prints the user rejected. + Only rendered once at least one verdict exists — installs without + the confirmation workflow see the widget unchanged. */} + {(analysis.rejected_prints ?? 0) > 0 && ( +
+ + {t('stats.rejectedPrintsCount', { rejected: analysis.rejected_prints })} + + + {t('stats.yieldRate', { rate: (analysis.yield_rate ?? 0).toFixed(1) })} + +
+ )} {/* Trend indicator */} {analysis.trend && analysis.trend.length >= 2 && (
= 2 ? 'mt-4' : 'mt-2 pt-2 border-t border-bambu-dark-tertiary'}`}> diff --git a/frontend/src/utils/verdictSource.ts b/frontend/src/utils/verdictSource.ts new file mode 100644 index 000000000..0dabc2d2d --- /dev/null +++ b/frontend/src/utils/verdictSource.ts @@ -0,0 +1,27 @@ +// How a post-print outcome verdict reached an archive (#1898). The backend +// stamps it wherever a verdict is written, so a "good" badge nobody remembers +// clicking can say where it came from — the live case being the plate-clear +// default answering a prompt before the operator reached the phone. +// +// 'reaction' is written by the Telegram reaction handler (#3046), which lands +// separately; it is mapped here so it reads correctly the day it does. + +import type { VerdictSource } from '../api/client'; + +const SOURCE_KEYS: Record = { + dialog: 'confirmOutcome.sources.dialog', + link: 'confirmOutcome.sources.link', + plate_clear: 'confirmOutcome.sources.plateClear', + printer_card: 'confirmOutcome.sources.printerCard', + api: 'confirmOutcome.sources.api', + reaction: 'confirmOutcome.sources.reaction', +}; + +/** + * i18n key for a verdict source, or null when the source is unknown — archives + * answered before this shipped carry no source and must show no hint at all. + */ +export function verdictSourceKey(source: string | null | undefined): string | null { + if (!source) return null; + return SOURCE_KEYS[source as VerdictSource] ?? null; +}