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..a3e8b2f71 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3397,6 +3397,159 @@ 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: + 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 + except Exception as e: + logger.warning("[CALLBACK] Could not decide the outcome prompt for printer %s: %s", printer_id, e) + # A failed statement deactivates the transaction, so without this the + # caller's own add()/commit() would raise PendingRollbackError and the + # print would go unarchived over a question that answers "no". + try: + await db.rollback() + except Exception: + pass + return False + + +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 +3874,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 +4694,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 +4828,25 @@ 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. + 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) + try: + await db.rollback() + except Exception: + pass + # 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 +6407,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 +7883,17 @@ 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: + try: + await dispatch_outcome_confirmation( + db, printer_id, printer_name, data, archive_id, archive_data + ) + except Exception as e: + logger.error("[NOTIFY-BG] Outcome-confirmation dispatch failed: %s", e, exc_info=True) + # Send user-specific email notification if archive_data: created_by_id = archive_data.get("created_by_id") @@ -9504,6 +9717,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 +9852,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/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..70811b0f9 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,19 @@ 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, + ) -> 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. + """ bot_token = config.get("bot_token", "").strip() chat_id = config.get("chat_id", "").strip() @@ -548,36 +585,64 @@ 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", + # Telegram's servers GET the first URL in the text to build a + # preview card. The outcome prompt (#1898) carries single-use + # verdict links in its body, so that fetch would answer the + # question before the operator saw it. Bambuddy's messages are + # status text; a preview card adds nothing to any of them. + "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 +842,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 +1027,73 @@ 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)}, + ] + return await self._send_telegram( + config, f"*{title}*\n{message}", image_data=image_data, buttons=tg_buttons + ) 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 +1111,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 +1459,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/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..40f051e27 --- /dev/null +++ b/backend/tests/integration/test_external_print_confirmation_1898.py @@ -0,0 +1,562 @@ +"""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) -> None: + """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), + ] + + with ExitStack() as stack: + for p in patches: + 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}) + + +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 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_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/test_confirm_link_unattended_fetch_1898.py b/backend/tests/unit/test_confirm_link_unattended_fetch_1898.py new file mode 100644 index 000000000..f6caef99a --- /dev/null +++ b/backend/tests/unit/test_confirm_link_unattended_fetch_1898.py @@ -0,0 +1,323 @@ +"""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): + 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", + ) + + 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) + + assert ok + assert client.calls[0]["disable_web_page_preview"] is True + assert client.calls[0]["reply_markup"] == {"inline_keyboard": [buttons]} 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_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..201709118 100644 --- a/frontend/src/__tests__/pages/ArchivesPage.test.tsx +++ b/frontend/src/__tests__/pages/ArchivesPage.test.tsx @@ -618,4 +618,72 @@ 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(); + }); + }); }); 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; +}