Fix external-spool usage charged to an AMS spool (#3166)

Print commands carry the external spool as -1 in the flat ams_mapping
    (the firmware rejects 254/255 there) and the real target only in
    ams_mapping2. We captured only the flat list, so external-spool prints
    looked unmapped and the usage tracker's position-based fallback
    charged them to the first loaded AMS tray.

    - Resolve external spools from ams_mapping2 when capturing a
      project_file (dual-nozzle keeps 254/255, single-nozzle -> 254)
    - Tracker: an explicit -1 no longer falls back to a positional tray;
      a mapping naming no tray for any used slot defers to tray_now
    - Keep the #1822 H2S tray_now override working with resolved mappings
This commit is contained in:
maziggy
2026-09-30 16:14:15 +02:00
80 changed files with 6280 additions and 83 deletions
+2
View File
@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
## [1.2.6b1] - Unreleased
### Added
- **Post-print outcome confirmation: say whether a completed print is actually a good part (#1898, requested by @FedericoPuntelli, contributed by @Thomansky in #3047)** — The printer can only report that a print *completed*; a warped, out-of-tolerance or wrong-colour part counted as a success anyway. A print can now ask for a verdict: turn on **Ask for Outcome** in the print dialog, or set it as the default under Settings → Workflow → Queue & Dispatch → Default Print Options, and when the print finishes Bambuddy asks "How did your print come out?" with the finish photo and **Good** / **Reject**. It can be answered in the dialog that opens in the web UI, from the printer card next to the plate-clear button, or from the phone: the new **Outcome Confirmation** notification event puts Good/Reject buttons into ntfy and Telegram notifications and a link to the dialog into every other channel. A reject takes an optional reason from the failure-reason list and offers **Print again**. *Ask me later* leaves an amber **outcome?** badge on the archive card, and the Archives page gets an **Unconfirmed** filter; the verdict stays editable from the card's menu or Edit Archive, and says where it came from (in the app, a link, the printer card, or a plate release). Two further options, both off by default: **Also ask for prints started outside Bambuddy** gives prints started at the printer, in Bambu Studio or in the Handy app the same prompt, and **Count unanswered outcomes as good on plate release** (Plate-Clear Confirmation card) records a still-open prompt as good when the plate is released. Rejected prints don't touch the machine failure rate; Failure Analysis shows them as their own line with a yield figure, and project part counts only count parts that were not rejected. A one-tap link shows a confirmation page and records nothing on a plain open, so a link preview, a mail scanner or a browser prefetch can't answer the question for you; only the notification buttons record in one tap. Nothing changes for anyone who doesn't turn it on: the option is off by default, and the notification event only fires for prints that asked.
- **Connected apps: other applications can sign people in with their Bambuddy account** — Settings → API Keys → Connected Apps registers an app with one exact callback URL and gives it a client ID and a secret, shown once. The app sends the browser to `/connect/authorize`; the user confirms once ("Allow"), and after that sign-in is automatic, so an app opened from the sidebar needs no second login. The app's server then swaps a single-use code for the user's name, email, groups and permissions at `POST /api/v1/connect/token`. It is the OAuth 2.0 authorization-code flow with PKCE (S256 only), kept to what an app on the same network needs: a code lives 60 seconds, works once, is bound to its app, callback URL and PKCE challenge, and is redeemable only with the app's secret; codes and secrets are stored hashed, failed exchanges are rate-limited per app and per IP, and the page never redirects to a callback URL the backend has not matched against the registration. The app never receives the user's password or Bambuddy session, and an API key cannot sign its owner in to an app. Connected apps require authentication to be enabled; with it off, both endpoints answer `auth_disabled` so an app falls back to its own login instead of letting everyone in. The consent page may be shown inside Bambuddy's own frames (`frame-ancestors 'self'`), so an app opened from the sidebar can sign in there; every other page still refuses all framing. See the wiki page *Connected Apps*.
- **A batch can record the external order it fulfils, so an integration can't queue the same order twice** — `POST /queue/batches` accepts `external_source` (e.g. `shopify`) and `external_ref`, a reference to the record in that system; both are returned on every batch and can be filtered on with `GET /queue/batches?external_source=&external_ref=`. The pair is unique: a second create for the same record answers 409 instead of making a second batch, so a connector that retries after losing the response can't print an order twice. Both fields are optional and must be given together; batches created without them behave exactly as before.
- **PDFs in the File Manager get their thumbnail the moment they arrive (#2976)** — A PDF's grid thumbnail used to exist only once somebody had opened its preview, because the browser rendered it and posted it back; until then the card was blank. Page one is now rendered on the server with PDFium (`pypdfium2`, a new dependency whose wheels carry the PDFium binary for every platform Bambuddy ships, so no system package is needed) on upload, inside an extracted ZIP and when an external folder is scanned. **Generate Thumbnails** covers PDFs too, so existing ones can be backfilled in one click; its tooltip and its nothing-to-do toast say so in all 15 languages. The thumbnail has the same shape as the browser's (a 256 px square, page centred on white), so old and new cards match. PDFium is not thread-safe, so renders are serialised behind a lock and run off the event loop; the render scale comes from the page's own size, so a PDF declaring a huge page cannot make it allocate a huge bitmap. A PDF it cannot read (damaged, encrypted) gets no thumbnail and still lands in the library, and the browser preview remains the fallback.
@@ -38,6 +39,7 @@ All notable changes to Bambuddy will be documented in this file.
- **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
### Fixed
- **Filament printed from the external spool was deducted from an AMS spool (#3166)** — The firmware doesn't accept the external spool in a print command's `ams_mapping` list. So BambuStudio and Bambuddy's own dispatch both write it there as -1, the same value as an unused slot, and put the real target in `ams_mapping2`. Bambuddy read only the first list, so an external-spool print looked unmapped, and usage tracking fell back to guessing by position: the first loaded AMS slot. The reporter's H2D charged every external-spool print that way, including 225 g of ABS taken from the PLA spool in AMS slot 1; the same was reported on H2S and P2S. The external spool is now read from `ams_mapping2` (left or right on dual-nozzle printers). A slot the mapping marks as unused is no longer given a tray by position. When a mapping names no tray at all, the tray the printer reports decides instead. The last change also stops an unused slot from taking over the external spool that another slot really used and dropping that slot's weight (#2880).
- **Upgrading from Bambuddy 0.2.4.0 or older no longer crashes at startup** — Since 1.2.5.4, startup first converts old failure-reason labels to the new vocabulary (#2974), in both the archives and the print log. A database from before the print log had a failure-reason column (#1378) reached that conversion before the migration that adds the column, and Bambuddy stopped with `no such column: failure_reason` instead of starting. The conversion now skips a table that doesn't have the column yet; it can't hold old labels anyway. On SQLite it also rebuilds the archive search index before converting archives, because archives created before that index existed were never added to it, and updating one of them failed with `database disk image is malformed`. It only rebuilds when there is something to convert, and it skips both tables entirely when nothing needs converting, so a normal startup does less work than before.
- **A connection check that overruns in the support bundle now says which step hung, and keeps what finished (#3164)** — The support bundle gives each printer's connection diagnostic 15 seconds and used to discard the whole result when it overran, recording only `timed_out`. A 14-printer farm's bundle came back with that marker on every printer and nothing else, so it could not show which check was slow or whether the printers were reachable at all. The diagnostic now records each check as it finishes and the step it is on; a timed-out entry carries `stalled_in` (the step that was still running), `elapsed_s`, and the checks that completed before it.
- **PDF previews in the File Manager failed on any browser older than Chrome 145 or Firefox 144 (#2976)** — Every PDF showed "This file cannot be previewed." pdf.js 6 calls `Map.prototype.getOrInsertComputed` and other recent APIs directly, and the preview loaded its standard build, which assumes them. It now loads pdf.js's legacy build, which bundles polyfills for both the page and the worker. The worker is also bundled through Vite (`?worker&url`) instead of being copied as-is: the copied file kept a class static block that Safari 16.0-16.3 cannot parse, and `build.target` now lowers it like the rest of the app. The Safari 16 baseline check missed that file because it only scanned `.js` output; it now scans `.mjs` too. Checked in a real Chromium without the API, under the app's own Content-Security-Policy: a two-page text PDF renders, pages, and reopens.
+270 -1
View File
@@ -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 (
"<!doctype html><html><head><meta name='viewport' content='width=device-width, initial-scale=1'>"
"<title>Bambuddy</title></head>"
"<body style='font-family: system-ui, sans-serif; background:#1a1d21; color:#fff; display:flex;"
" align-items:center; justify-content:center; min-height:90vh; margin:0'>"
f"<div style='text-align:center; padding:0 1.5rem; max-width:32rem'>"
f"<div style='font-size:3rem'>{glyph}</div><h2>{heading}</h2>{body}</div></body></html>"
)
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 <strong>{label}</strong>"
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 = "&#10003;" if archive.user_verdict == "good" else "&#10007;" if archive.user_verdict else "&#8505;"
return _confirm_page(
glyph,
"Already answered",
f"<p style='color:#9ca3af'>{name}</p>"
f"<p style='color:#9ca3af'>{recorded}</p>"
f"<p><a style='color:#00ae42' href='{link}'>Open this print in Bambuddy</a> to change it.</p>",
)
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 = (
"<form method='post' id='confirm-form'>"
"<button type='submit' style='font: inherit; font-size:1.1rem; padding:0.9rem 2rem;"
" border:0; border-radius:0.5rem; background:#00ae42; color:#fff'>Yes, record it</button>"
"</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"<script nonce='{html_escape(nonce, quote=True)}'>"
"document.getElementById('confirm-form').submit();</script>"
)
return _confirm_page(
"&#63;",
"Confirm this outcome",
f"<p style='color:#9ca3af'>{name}</p>"
f"<p style='color:#9ca3af'>Record this print as <strong>{label}</strong>?</p>"
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(
"&#10003;" if verdict == "good" else "&#10007;",
label,
f"<p style='color:#9ca3af'>{html_escape(name)}</p>"
"<p style='color:#9ca3af'>Saved &mdash; you can close this page.</p>",
)
)
# ============================================
# QR Code Endpoint
# ============================================
+6
View File
@@ -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.
+4
View File
@@ -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
+5
View File
@@ -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()
+2
View File
@@ -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,
+15
View File
@@ -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"}
+21 -4
View File
@@ -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)
)
+20 -8
View File
@@ -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",
+4
View File
@@ -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.
+65
View File
@@ -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).
+10
View File
@@ -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(
+261
View File
@@ -3397,6 +3397,190 @@ def _schedule_fallback_3mf_retry(
)
async def _ask_outcome_for_external_print(db, printer_id: int, observed_name: str | None = None) -> bool:
"""Whether an archive created here should ask for the print's outcome (#1898).
Only a print Bambuddy did not dispatch reaches the archive-*creating*
branches below: a queued job already has its archive and takes the
expected-print branch, where the queue item's own ``confirm_outcome``
decides. The queue is still consulted, because a restart mid-print empties
``_expected_prints`` — without the check the setting could override a queue
item that deliberately has the flag off. Any failure answers "don't ask":
an unwanted prompt is worse than a missing one, and this must never be the
reason a print goes unarchived.
A ``printing`` row is not proof on its own, though: Bambuddy deliberately
leaves one behind when a completion cannot be matched to it
(``_completion_belongs_to_queue_item``), and the scheduler's stranded sweep
only takes it back once the printer has sat connected and terminal for
minutes. Until then every screen-started print on that printer would
silently lose its prompt, so the row is held against ``observed_name`` by
the same comparison a completion uses: a positive disagreement means the row
is about some other run and this print is external after all.
"""
logger = logging.getLogger(__name__)
try:
# A savepoint rather than a rollback of the caller's transaction on
# failure: a failed statement leaves the transaction unusable, but a
# full rollback also expires every object the caller has loaded (the
# printer, the archive it just created), and on an async session the
# next attribute read then raises instead of reloading. Everything here
# is a read, so undoing the savepoint loses nothing.
async with db.begin_nested():
return await _decide_outcome_for_external_print(db, printer_id, observed_name, logger)
except Exception as e:
logger.warning("[CALLBACK] Could not decide the outcome prompt for printer %s: %s", printer_id, e)
return False
async def _decide_outcome_for_external_print(db, printer_id: int, observed_name: str | None, logger) -> bool:
"""The reads behind ``_ask_outcome_for_external_print``; see there."""
from backend.app.api.routes.settings import get_setting, setting_is_true
if not setting_is_true(await get_setting(db, "confirm_outcome_external_prints")):
return False
from backend.app.models.print_queue import PrintQueueItem
dispatched_here = await db.scalar(
select(PrintQueueItem)
.where(
PrintQueueItem.printer_id == printer_id,
PrintQueueItem.status == "printing",
)
.limit(1)
)
if dispatched_here is None:
return True
expected = await _queue_item_dispatched_name(db, dispatched_here)
observed = (observed_name or "").strip()
if not expected or not observed or _subtask_names_match(expected, observed):
logger.info(
"[CALLBACK] Not asking for the outcome on printer %s: queue item %s is still printing, so "
"Bambuddy dispatched this run and the item's own ask-for-outcome flag decides.",
printer_id,
dispatched_here.id,
)
return False
logger.info(
"[CALLBACK] Queue item %s is still marked printing on printer %s but was dispatched as %r, not "
"%r; treating this as an externally started print.",
dispatched_here.id,
printer_id,
expected,
observed,
)
return True
async def _dispatch_outcome_confirmation_safely(
db,
printer_id: int,
printer_name: str,
data: dict,
archive_id: int,
archive_data: dict | None = None,
) -> None:
"""``dispatch_outcome_confirmation`` for a caller that has more to do on ``db``.
The completion task sends the per-user print email on the same session
right after the prompt. A failed statement in the prompt leaves that
session needing a rollback, and without one the email step fails with
PendingRollbackError; the prompt is the optional part, so it must not be
the reason the email never goes out.
"""
try:
await dispatch_outcome_confirmation(db, printer_id, printer_name, data, archive_id, archive_data)
except Exception as e:
logging.getLogger(__name__).error("[NOTIFY-BG] Outcome-confirmation dispatch failed: %s", e, exc_info=True)
try:
await db.rollback()
except Exception:
pass
async def dispatch_outcome_confirmation(
db,
printer_id: int,
printer_name: str,
data: dict,
archive_id: int,
archive_data: dict | None = None,
) -> bool:
"""Emit the post-print outcome prompt for a completed archive (#1898).
Gated on the archive itself: ``confirm_requested`` is the opt-in (from the
queue item, or from ``confirm_outcome_external_prints`` for a print
Bambuddy did not start) and ``user_verdict`` being unset is what makes the
question still open. Mints the per-archive capability token the one-tap
verdict links carry.
Lives here rather than inline in ``on_print_complete``'s notification task
because that task swallows every exception: extracted, the gate and the two
emissions can be driven by a test, which is the only thing standing between
a regression here and a farm that quietly stops asking.
Returns whether a prompt was sent.
"""
logger = logging.getLogger(__name__)
from backend.app.models.archive import PrintArchive
confirm_archive = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
if not (confirm_archive and confirm_archive.confirm_requested and confirm_archive.user_verdict is None):
return False
import secrets as _secrets
# A spent token stays on the row so the one-tap route can recognise it, so
# "has a token" no longer means "answerable". A prompt going out now needs a
# live one: mint a new token whenever the stored one is already spent, or
# every button in the new message would land on the already-answered page.
if not confirm_archive.confirm_token or confirm_archive.confirm_token_used_at is not None:
confirm_archive.confirm_token = _secrets.token_urlsafe(32)
confirm_archive.confirm_token_used_at = None
await db.commit()
from backend.app.api.routes.settings import get_external_base_url, get_setting
base = await get_external_base_url(db)
if not await get_setting(db, "external_url"):
# Both the Telegram inline keyboard and the ntfy action buttons need an
# absolute URL, so an unconfigured install used to get a message body
# with two unusable relative paths and no buttons at all. The shared
# fallback at least produces tappable links; say which setting makes
# them resolve from a phone.
logger.warning(
"[#1898] No external_url configured — the outcome prompt's Good/Reject links point at %s. "
"Set Settings → External URL so they resolve away from this host.",
base,
)
token = confirm_archive.confirm_token
good_url = f"{base}/api/v1/archives/confirm/{token}/good"
reject_url = f"{base}/api/v1/archives/confirm/{token}/reject"
confirm_url = f"{base}/archives?confirm={archive_id}"
await ws_manager.send_print_confirm_request(
printer_id,
{
"archive_id": archive_id,
"print_name": confirm_archive.print_name or confirm_archive.filename,
},
)
await notification_service.on_print_confirm_request(
printer_id,
printer_name,
data,
db,
archive_data=archive_data,
good_url=good_url,
reject_url=reject_url,
confirm_url=confirm_url,
)
return True
async def on_print_start(printer_id: int, data: dict):
"""Handle print start - archive the 3MF file immediately."""
logger = logging.getLogger(__name__)
@@ -3721,6 +3905,18 @@ async def on_print_start(printer_id: int, data: dict):
archive.status = "printing"
archive.started_at = datetime.now(timezone.utc)
# The previous run's answer is still on this row and the
# completion prompt is gated on ``user_verdict is None`` (#1898),
# so without a reset the second run inherits the first run's
# verdict: no prompt at all, and a green "good" badge on a run
# nobody ever judged.
if archive.confirm_requested:
archive.user_verdict = None
archive.user_verdict_source = None
archive.user_verdict_at = None
archive.confirm_token = None
archive.confirm_token_used_at = None
# Reprint of an archive reuses the source row. Without resetting
# ``timelapse_path`` _scan_for_timelapse_with_retries early-returns
# ("already has timelapse") and _capture_finish_photo_from_timelapse
@@ -4529,6 +4725,7 @@ async def on_print_start(printer_id: int, data: dict):
status="printing",
started_at=datetime.now(timezone.utc),
subtask_id=subtask_id,
confirm_requested=await _ask_outcome_for_external_print(db, printer_id, subtask_name),
filament_type=mqtt_filament_meta.get("filament_type"),
filament_color=mqtt_filament_meta.get("filament_color"),
extra_data={
@@ -4662,6 +4859,35 @@ async def on_print_start(printer_id: int, data: dict):
)
if archive:
# Ask-for-outcome for a print Bambuddy did not dispatch (#1898).
# Set on the row rather than passed to archive_print, which also
# serves the queue dispatcher — there the queue item decides.
# Guarded because this branch has no ``except``: an unhandled
# write error would take the _active_prints registration, the
# start notification, the energy reading and the timelapse
# baseline below it with it, and a missing prompt is by far the
# cheaper failure.
archive_id = archive.id
try:
if await _ask_outcome_for_external_print(db, printer_id, subtask_name):
archive.confirm_requested = True
await db.commit()
except Exception as e:
logger.warning("Could not flag archive %s for the outcome prompt: %s", archive_id, e)
# The rollback expires every loaded object, and on an async
# session reading one afterwards raises instead of
# reloading, so the two this branch goes on to use are
# fetched again. The archive itself was committed by
# archive_print; only the flag is lost.
try:
await db.rollback()
archive = await db.get(PrintArchive, archive_id)
printer = await db.get(Printer, printer_id)
except Exception as reload_error:
logger.warning(
"Could not reload archive %s after the failed flag write: %s", archive_id, reload_error
)
# Track this active print (use both original filename and downloaded filename)
_active_prints[(printer_id, downloaded_filename)] = archive.id
if filename and filename != downloaded_filename:
@@ -6222,6 +6448,23 @@ def _subtask_names_match(expected: str, observed: str) -> bool:
return False
async def _queue_item_dispatched_name(db, item) -> str:
"""The subtask name *item* was dispatched under, or "" when unknowable.
A row with no archive, or an archive with no file name, is unverifiable
rather than wrong; every caller answers that with its permissive branch.
"""
if item.archive_id is None:
return ""
from backend.app.models.archive import PrintArchive
archive = await db.get(PrintArchive, item.archive_id)
if archive is None or not archive.filename:
return ""
return _subtask_name_from_filename(archive.filename)
async def _completion_belongs_to_queue_item(db, item, data: dict) -> bool:
"""Whether this completion event is plausibly about *item*'s print.
@@ -7681,6 +7924,14 @@ async def on_print_complete(printer_id: int, data: dict):
else:
logger.info("[NOTIFY-BG] Skipped duplicate kill-switch provider notification")
# Post-print outcome confirmation (#1898). Runs in this
# background task so the finish photo fetched above rides
# along with the prompt.
if print_status == "completed" and archive_id:
await _dispatch_outcome_confirmation_safely(
db, printer_id, printer_name, data, archive_id, archive_data
)
# Send user-specific email notification
if archive_data:
created_by_id = archive_data.get("created_by_id")
@@ -9504,6 +9755,12 @@ PUBLIC_API_PREFIXES = [
"/api/v1/ws",
# OIDC authorize redirects — include provider_id in path
"/api/v1/auth/oidc/authorize/",
# One-tap outcome-verdict links from push notifications (#1898). Tapped on
# a phone with no session, so no header can carry a JWT — the single-use
# capability token in the path IS the credential (same reasoning as the
# /dl/ slicer downloads below). The route grants nothing beyond writing
# good/reject on the one archive the token was minted for.
"/api/v1/archives/confirm/",
]
# Route patterns that are public (read-only display data)
@@ -9633,6 +9890,10 @@ async def security_headers_middleware(request, call_next):
# script passes the policy without us needing 'unsafe-inline'. See
# https://developers.cloudflare.com/cloudflare-challenges/challenge-types/javascript-detections/#if-you-have-a-content-security-policy-csp
csp_nonce = secrets.token_urlsafe(16)
# Routes that render their own HTML need it too, or their inline script is
# blocked by the policy below. The outcome-confirmation page (#1898) is the
# one that does: it submits its own form so a verdict still costs one tap.
request.state.csp_nonce = csp_nonce
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
# X-Frame-Options is the legacy cross-origin embedding control. Modern
+35
View File
@@ -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
+5
View File
@@ -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
@@ -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",
+4
View File
@@ -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))
+3
View File
@@ -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
+14
View File
@@ -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
+9
View File
@@ -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
+1
View File
@@ -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
+5
View File
@@ -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
+21
View File
@@ -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
+52 -6
View File
@@ -179,6 +179,40 @@ def a2l_lite_wire_ids(ams_id: int, tray_id: int) -> tuple[int, int, int] | None:
)
def resolve_external_spools_in_mapping(ams_mapping: object, ams_mapping2: object, is_dual_nozzle: bool) -> object:
"""Put the external spool back into a captured flat ``ams_mapping`` (#3166).
The firmware rejects 254/255 in the flat list, so BambuStudio and our own
dispatch both write an external spool there as -1 -- the same value as a
slot that isn't fed at all -- and carry the real target only in
``ams_mapping2`` as ``{ams_id: 254|255, slot_id: 0}``. Capturing the flat
list alone turns "printed from the external spool" into "unmapped", and
usage then lands on whatever tray the fallback guesses.
Each -1 whose ``ams_mapping2`` entry names an external spool becomes the
global tray the rest of Bambuddy uses: the ams_id itself on dual-nozzle
printers (254 = left/deputy, 255 = right/main), 254 on single-nozzle
printers, which have one external spool that the wire always calls 255.
``{255, 255}`` is the unmapped marker and stays -1. Every other entry is
left exactly as captured. Without a usable ``ams_mapping2`` the input is
returned unchanged.
"""
if not isinstance(ams_mapping, list) or not isinstance(ams_mapping2, list):
return ams_mapping
if len(ams_mapping2) != len(ams_mapping):
return ams_mapping
resolved = list(ams_mapping)
for i, (flat, detail) in enumerate(zip(ams_mapping, ams_mapping2, strict=True)):
if flat != -1 or not isinstance(detail, dict):
continue
ams_id = detail.get("ams_id")
slot_id = detail.get("slot_id")
if ams_id not in (254, 255) or slot_id == 255:
continue
resolved[i] = ams_id if is_dual_nozzle else 254
return resolved
def apply_tray_exist_bits(
units: list,
tray_exist_bits_str: str | int | None,
@@ -1988,7 +2022,7 @@ class BambuMQTTClient:
self.state.current_project_url = url
self.state.last_project_url = url
if "ams_mapping" in print_data:
self._captured_ams_mapping = print_data["ams_mapping"]
self._captured_ams_mapping = self._resolve_captured_mapping(print_data)
logger.info(
"[%s] Captured ams_mapping from print command: %s",
self.serial_number,
@@ -2068,13 +2102,24 @@ class BambuMQTTClient:
# already captured this print's mapping that copy is the slicer's own,
# and the echo can arrive without the field at all.
if self._captured_ams_mapping is None and isinstance(print_data.get("ams_mapping"), list):
self._captured_ams_mapping = print_data["ams_mapping"]
self._captured_ams_mapping = self._resolve_captured_mapping(print_data)
logger.info(
"[%s] Captured ams_mapping from print response: %s",
self.serial_number,
self._captured_ams_mapping,
)
def _resolve_captured_mapping(self, print_data: dict) -> object:
"""The ``ams_mapping`` of a project_file, with external spools resolved
from its ``ams_mapping2`` (#3166)."""
from backend.app.utils.printer_models import is_dual_nozzle_model
return resolve_external_spools_in_mapping(
print_data.get("ams_mapping"),
print_data.get("ams_mapping2"),
self._is_dual_nozzle or is_dual_nozzle_model(self.model),
)
@staticmethod
def _project_file_key(print_data: dict) -> str:
"""Identity of a project_file dispatch, for telling ours from a slicer's.
@@ -3185,15 +3230,16 @@ class BambuMQTTClient:
# slot (typically 0) when the active feed is actually the
# external spool. X1C / P1S / A1 correctly report 254 in
# that case; H2S does not. When the slicer-captured
# ams_mapping is all-external (every entry == -1), the
# print can only be feeding from the external spool, so
# promote tray_now to 254. Mixed (e.g. [5, -1]) and
# ams_mapping is all-external (every entry is 254, or -1
# when the command carried no ams_mapping2 to resolve it
# from, #3166), the print can only be feeding from the
# external spool, so promote tray_now to 254. Mixed (e.g. [5, 254]) and
# AMS-only mappings are NOT overridden — there's no
# evidence the firmware misreports in those cases. Prints
# started without a captured mapping (printer-screen start,
# or before Bambuddy connected) fall through unchanged.
captured = self._captured_ams_mapping
if captured and all(s == -1 for s in captured):
if captured and all(s in (-1, 254, 255) for s in captured):
if self.state.tray_now != 254:
logger.debug(
f"[{self.serial_number}] tray_now external-spool override (#1822): "
+26
View File
@@ -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,
+248 -34
View File
@@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.notification import NotificationDigestQueue, NotificationLog, NotificationProvider
from backend.app.models.notification_template import NotificationTemplate
from backend.app.services.print_confirmation import one_tap_url
logger = logging.getLogger(__name__)
@@ -318,11 +319,13 @@ class NotificationService:
else:
return False, f"HTTP {response.status_code}: {response.text[:200]}"
async def _send_bark(self, config: dict, title: str, message: str) -> tuple[bool, str]:
async def _send_bark(self, config: dict, title: str, message: str, url: str | None = None) -> tuple[bool, str]:
"""Send notification via Bark, the self-hostable iOS push service (#1495).
POSTs JSON to {server}/push. Defaults to the official api.day.app
relay; a self-hosted bark-server works by overriding the server URL.
``url`` opens on tap — the outcome confirmation (#1898) deep-links
into the archive's confirmation dialog with it.
"""
server = (config.get("server") or "https://api.day.app").strip().rstrip("/")
device_key = (config.get("device_key") or "").strip()
@@ -348,6 +351,8 @@ class NotificationService:
level = (config.get("level") or "").strip()
if level in ("active", "timeSensitive", "critical", "passive"):
payload["level"] = level
if url:
payload["url"] = url
client = await self._get_client()
response = await client.post(f"{server}/push", json=payload)
@@ -375,8 +380,14 @@ class NotificationService:
message: str,
image_data: bytes | None = None,
event_type: str | None = None,
actions: str | None = None,
) -> tuple[bool, str]:
"""Send notification via ntfy."""
"""Send notification via ntfy.
``actions`` is a pre-built value for ntfy's Actions header (simple
format), used by the outcome-confirmation event (#1898) to put
one-tap Good/Reject buttons directly into the push notification.
"""
server = config.get("server", "https://ntfy.sh").rstrip("/")
topic = config.get("topic", "").strip()
auth_token = config.get("auth_token", "").strip()
@@ -420,6 +431,9 @@ class NotificationService:
if auth_token:
headers["Authorization"] = f"Bearer {auth_token}"
if actions:
headers["Actions"] = actions
client = await self._get_client()
if image_data:
@@ -452,7 +466,13 @@ class NotificationService:
return False, _opaque_http_failure(response, label="ntfy server")
async def _send_pushover(
self, config: dict, title: str, message: str, image_data: bytes | None = None
self,
config: dict,
title: str,
message: str,
image_data: bytes | None = None,
url: str | None = None,
url_title: str | None = None,
) -> tuple[bool, str]:
"""Send notification via Pushover.
@@ -461,6 +481,8 @@ class NotificationService:
title: Notification title
message: Notification body
image_data: Optional JPEG image bytes to attach (max 2.5MB)
url: Optional supplementary URL shown under the message
url_title: Optional label for that URL
"""
user_key = config.get("user_key", "").strip()
app_token = config.get("app_token", "").strip()
@@ -472,7 +494,7 @@ class NotificationService:
if not user_key or not app_token:
return False, "User key and app token are required"
url = "https://api.pushover.net/1/messages.json"
api_url = "https://api.pushover.net/1/messages.json"
data = {
"token": app_token,
"user": user_key,
@@ -480,6 +502,10 @@ class NotificationService:
"message": message,
"priority": priority,
}
if url:
data["url"] = url
if url_title:
data["url_title"] = url_title
# Emergency priority (2) keeps re-alerting until acknowledged, so
# Pushover *requires* retry (how often, >= 30s) and expire (when to
@@ -502,9 +528,9 @@ class NotificationService:
if image_data:
# Pushover supports image attachments via multipart form-data
files = {"attachment": ("photo.jpg", image_data, "image/jpeg")}
response = await client.post(url, data=data, files=files)
response = await client.post(api_url, data=data, files=files)
else:
response = await client.post(url, data=data)
response = await client.post(api_url, data=data)
if response.status_code == 200:
return True, "Message sent successfully"
@@ -516,8 +542,21 @@ class NotificationService:
except Exception:
return False, f"HTTP {response.status_code}: {response.text[:200]}"
async def _send_telegram(self, config: dict, message: str, image_data: bytes | None = None) -> tuple[bool, str]:
"""Send notification via Telegram bot."""
async def _send_telegram(
self,
config: dict,
message: str,
image_data: bytes | None = None,
buttons: list[dict] | None = None,
link_preview: bool = True,
) -> tuple[bool, str]:
"""Send notification via Telegram bot.
``buttons`` is one row of inline URL buttons (``{"text", "url"}``
entries), used by the outcome-confirmation event (#1898) to put
one-tap Good/Reject under the message. ``link_preview=False`` asks
Telegram not to fetch the first URL in the text for a preview card.
"""
bot_token = config.get("bot_token", "").strip()
chat_id = config.get("chat_id", "").strip()
@@ -548,36 +587,60 @@ class NotificationService:
client = await self._get_client()
if image_data:
# Use sendPhoto to attach the thumbnail with the caption
url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
form: dict[str, Any] = {"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"}
if message_thread_id is not None:
form["message_thread_id"] = message_thread_id
response = await client.post(
url,
data=form,
files={"photo": ("photo.jpg", image_data, "image/jpeg")},
)
else:
async def _post(with_buttons: bool):
if image_data:
# Use sendPhoto to attach the thumbnail with the caption
url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
form: dict[str, Any] = {"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"}
if message_thread_id is not None:
form["message_thread_id"] = message_thread_id
if with_buttons:
# Multipart form fields are strings — reply_markup goes JSON-encoded.
form["reply_markup"] = json.dumps({"inline_keyboard": [buttons]})
return await client.post(
url,
data=form,
files={"photo": ("photo.jpg", image_data, "image/jpeg")},
)
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
data: dict[str, Any] = {
payload: dict[str, Any] = {
"chat_id": chat_id,
"text": message,
"parse_mode": "Markdown",
}
if not link_preview:
payload["disable_web_page_preview"] = True
if message_thread_id is not None:
data["message_thread_id"] = message_thread_id
response = await client.post(url, json=data)
payload["message_thread_id"] = message_thread_id
if with_buttons:
payload["reply_markup"] = {"inline_keyboard": [buttons]}
return await client.post(url, json=payload)
if response.status_code == 200:
result = response.json()
def _failure(resp) -> str | None:
"""What Telegram objected to, or None when the send went through."""
if resp.status_code != 200:
return f"HTTP {resp.status_code}: {resp.text[:200]}"
result = resp.json()
if result.get("ok"):
return True, "Message sent successfully"
else:
return False, f"Telegram error: {result.get('description', 'Unknown error')}"
else:
return False, f"HTTP {response.status_code}: {response.text[:200]}"
return None
return f"Telegram error: {result.get('description', 'Unknown error')}"
response = await _post(bool(buttons))
failure = _failure(response)
if failure and buttons:
# Telegram validates every inline-keyboard URL and refuses the whole
# send when one of them is not a URL it accepts — which is what an
# install without a public external_url produces for the #1898
# verdict links. Dropping the buttons is survivable; dropping the
# message the user is waiting for is not.
logger.warning("Telegram refused the message with inline buttons (%s); retrying without them", failure)
response = await _post(False)
failure = _failure(response)
if failure:
return False, failure
return True, "Message sent successfully"
async def _send_email(
self,
@@ -777,6 +840,21 @@ class NotificationService:
if payload_format == "slack":
# Slack/Mattermost format - just text field
data = {"text": f"*{title}*\n{message}"}
if event_type == "print_confirm_request":
# Slack and Mattermost fetch every URL in the text to build
# preview cards, and the outcome prompt (#1898) is the one
# message whose links are single-use capabilities — that fetch
# would be a machine answering the operator's question. Off
# here for the same reason Telegram's preview is.
#
# Only here: the slack payload never attaches image bytes (the
# base64 attach below is generic-format only), so unfurling is
# the only way a {finish_photo_url} in a print_complete body
# can render as a photo in the channel. Switching it off for
# every event would quietly take that away with no setting to
# get it back.
data["unfurl_links"] = False
data["unfurl_media"] = False
else:
# Generic format with custom field names
custom_field_title = config.get("field_title", "title").strip() or "title"
@@ -947,11 +1025,85 @@ class NotificationService:
if provider.provider_type == "callmebot":
return await self._send_callmebot(config, f"{title}\n{message}")
elif provider.provider_type == "ntfy":
return await self._send_ntfy(config, title, message, image_data=image_data, event_type=event_type)
# Outcome confirmation (#1898): render the verdict capability
# links as one-tap buttons on the notification itself. http so
# no browser needs to open; POST because that is the method
# that records — a GET only opens the confirmation page, which
# is what keeps unfurlers from answering the prompt.
# clear=true dismisses the notification once a button was tapped.
ntfy_actions = None
good_url = (variables or {}).get("good_url")
reject_url = (variables or {}).get("reject_url")
# Buttons need absolute URLs; without a configured external_url
# the links are relative, and the body's deep link into the
# archive has to do.
if (
event_type == "print_confirm_request"
and good_url
and reject_url
and good_url.startswith("http")
and reject_url.startswith("http")
):
ntfy_actions = (
f"http, Good, {good_url}, method=POST, clear=true; "
f"http, Reject, {reject_url}, method=POST, clear=true"
)
return await self._send_ntfy(
config, title, message, image_data=image_data, event_type=event_type, actions=ntfy_actions
)
elif provider.provider_type == "pushover":
return await self._send_pushover(config, title, message, image_data=image_data)
# Outcome confirmation (#1898): Pushover has no arbitrary
# buttons, but supports one supplementary URL — deep-link into
# the archive's confirmation dialog.
supplement_url = None
supplement_url_title = None
_confirm_url = (variables or {}).get("confirm_url")
if event_type == "print_confirm_request" and _confirm_url and _confirm_url.startswith("http"):
supplement_url = _confirm_url
supplement_url_title = "Confirm print outcome"
return await self._send_pushover(
config, title, message, image_data=image_data, url=supplement_url, url_title=supplement_url_title
)
elif provider.provider_type == "telegram":
return await self._send_telegram(config, f"*{title}*\n{message}", image_data=image_data)
# Outcome confirmation (#1898): inline URL buttons under the
# message — one tap records the verdict via the capability
# link. Same absolute-URL requirement as the ntfy actions.
# Telegram has no way to POST, so this is the one affordance
# that opens a browser, and it is the one that gets the one-tap
# marker: the page submits itself only for a URL that came off
# a button. Telegram does not fetch inline-keyboard URLs and
# nothing else can read them, so the marker never reaches a
# scanner — which is the difference between this and trusting
# the User-Agent.
tg_buttons = None
_tg_good = (variables or {}).get("good_url")
_tg_reject = (variables or {}).get("reject_url")
if (
event_type == "print_confirm_request"
and _tg_good
and _tg_reject
and _tg_good.startswith("http")
and _tg_reject.startswith("http")
):
tg_buttons = [
{"text": "\U0001f44d Good", "url": one_tap_url(_tg_good)},
{"text": "\U0001f44e Reject", "url": one_tap_url(_tg_reject)},
]
# Telegram's servers GET the first URL in the text to build a
# preview card. An outcome prompt whose edited body still
# carries {good_url} would have that fetch answer the question
# before the operator saw it, so the preview is off for this
# event. Only for this one, for the same reason as the Slack
# unfurl in _send_webhook: when the finish photo is too large to attach,
# the preview is how a {finish_photo_url} in a print_complete
# body still shows up as a photo in the chat.
return await self._send_telegram(
config,
f"*{title}*\n{message}",
image_data=image_data,
buttons=tg_buttons,
link_preview=event_type != "print_confirm_request",
)
elif provider.provider_type == "email":
# finish_photo_url is pulled from the rendered template variables
# so _send_email can detect whether the template referenced the
@@ -969,7 +1121,13 @@ class NotificationService:
elif provider.provider_type == "homeassistant":
return await self._send_homeassistant(config, title, message, db=db)
elif provider.provider_type == "bark":
return await self._send_bark(config, title, message)
# Outcome confirmation (#1898): Bark opens one URL on tap —
# deep-link into the confirmation dialog, like Pushover.
bark_url = None
_bark_confirm = (variables or {}).get("confirm_url")
if event_type == "print_confirm_request" and _bark_confirm and _bark_confirm.startswith("http"):
bark_url = _bark_confirm
return await self._send_bark(config, title, message, url=bark_url)
else:
return False, f"Unknown provider type: {provider.provider_type}"
except Exception as e:
@@ -1311,6 +1469,62 @@ class NotificationService:
variables=variables,
)
async def on_print_confirm_request(
self,
printer_id: int,
printer_name: str,
data: dict,
db: AsyncSession,
archive_data: dict | None = None,
good_url: str | None = None,
reject_url: str | None = None,
confirm_url: str | None = None,
):
"""Ask for a post-print outcome verdict (#1898).
Fires only for completed prints whose queue item opted in — the
provider-level toggle exists to mute a channel, not to enable the
feature. good_url / reject_url are the one-tap capability links
(rendered as ntfy action buttons), confirm_url deep-links into the
archive's confirmation dialog in the web UI.
"""
providers = await self._get_providers_for_event(db, "on_print_confirm_request", printer_id)
if not providers:
return
subtask_name = data.get("subtask_name")
if subtask_name:
filename = subtask_name.replace("_", " ")
else:
filename = self._clean_filename(data.get("filename", "Unknown"))
variables = {"printer": printer_name, "filename": filename}
if good_url:
variables["good_url"] = good_url
if reject_url:
variables["reject_url"] = reject_url
if confirm_url:
variables["confirm_url"] = confirm_url
image_data = None
if archive_data:
if archive_data.get("finish_photo_url"):
variables["finish_photo_url"] = archive_data["finish_photo_url"]
image_data = archive_data.get("image_data")
title, message = await self._build_message_from_template(db, "print_confirm_request", variables)
await self._send_to_providers(
providers,
title,
message,
db,
"print_confirm_request",
printer_id,
printer_name,
image_data=image_data,
variables=variables,
)
async def on_print_progress(
self,
printer_id: int,
+1
View File
@@ -72,6 +72,7 @@ CLONED_SETTING_COLUMNS = (
"timelapse",
"use_ams",
"nozzle_offset_cali",
"confirm_outcome",
"preheat_override",
"preheat_chamber_target_override",
"skip_filament_check",
+193
View File
@@ -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"))
+18
View File
@@ -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
+30
View File
@@ -1507,6 +1507,24 @@ async def _track_from_3mf(
mapping_source or "none",
)
# A mapping that names no tray for any slot the print used carries no
# evidence at all -- typically a flat ams_mapping whose external spool is
# -1 and came without an ams_mapping2 to resolve it (#3166). Drop it so the
# tray_now evidence below decides, rather than leaving every slot unfed.
if slot_to_tray:
used_slots = [u.get("slot_id", 0) for u in filament_usage if u.get("used_g", 0) > 0]
if used_slots and all(
not (0 < s <= len(slot_to_tray) and isinstance(slot_to_tray[s - 1], int) and slot_to_tray[s - 1] >= 0)
for s in used_slots
):
logger.info(
"[UsageTracker] 3MF: mapping %s names no tray for used slots %s — ignoring it",
slot_to_tray,
used_slots,
)
slot_to_tray = None
mapping_source = None
# 5. For single-filament non-queue prints, use tray_now from printer state
# Priority: tray_change_log (multi-tray split) > tray_now_at_start > current tray_now
# > last_loaded_tray > vt_tray check
@@ -1779,6 +1797,18 @@ async def _track_from_3mf(
mapped = slot_to_tray[slot_id - 1]
if isinstance(mapped, int) and mapped >= 0:
global_tray_id = mapped
elif mapped == -1:
# The mapping says this slot isn't fed from any tray. The
# position-based guess below would hand it the Nth loaded
# tray anyway -- an AMS spool that never moved (#3166), or
# the tray another slot really used, which that slot then
# finds already handled and drops (#2880).
logger.info(
"[UsageTracker] 3MF: slot_id=%d is unmapped (-1) — nothing charged (used_g=%.1f)",
slot_id,
used_g,
)
continue
# Position-based default: sort available tray IDs so external spools (254/255)
# naturally follow standard AMS trays, matching slicer slot numbering.
#
@@ -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
@@ -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
@@ -0,0 +1,657 @@
"""Ask for the outcome of prints Bambuddy did not start (#1898 follow-up).
The ask-for-outcome flag rides from a queue item onto the archive at dispatch.
On a farm where most jobs are started at the printer's screen, in Bambu Studio
or in the Handy app there is no queue item to ride from, so every archive
``on_print_start`` created had ``confirm_requested`` false and the feature
looked broken. ``confirm_outcome_external_prints`` is the missing source.
Both archive-creating branches of ``on_print_start`` are driven here against a
real database: the no-3MF fallback (what a P1S/A1 farm actually hits) and the
normal path that archives a downloaded 3MF.
"""
from contextlib import ExitStack
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from backend.app.main import (
_active_prints,
_expected_print_creators,
_expected_print_registered_at,
_expected_prints,
_print_ams_mappings,
)
from backend.app.models.archive import PrintArchive
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.settings import Settings
DISPATCH = "/data/Metadata/plate_1.gcode"
SUBTASK = "Bracket_plate_1"
@pytest.fixture(autouse=True)
def _clear_print_state():
dicts = (
_expected_prints,
_expected_print_registered_at,
_expected_print_creators,
_print_ams_mappings,
_active_prints,
)
for d in dicts:
d.clear()
yield
for d in dicts:
d.clear()
class _StubArchiveService:
"""Stands in for ArchiveService on the downloaded-3MF branch.
Writes the row the real service would write, without needing a parseable
3MF on disk. Everything this test asserts happens *after* the row exists.
"""
def __init__(self, db):
self.db = db
async def archive_print(self, **kwargs):
archive = PrintArchive(
printer_id=kwargs.get("printer_id"),
filename=f"{SUBTASK}.gcode.3mf",
file_path=f"archives/test/{SUBTASK}.gcode.3mf",
file_size=2048,
print_name=SUBTASK,
status="printing",
started_at=datetime.now(timezone.utc),
)
self.db.add(archive)
await self.db.commit()
await self.db.refresh(archive)
return archive
async def _drive_print_start(test_engine, printer, *, download_ok: bool, extra_patches=()) -> dict:
"""Run ``on_print_start`` against the test database.
``download_ok`` picks the branch: False leaves the 3MF unreachable and the
no-3MF fallback archive is written inline; True takes the ArchiveService
branch.
"""
session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
state = MagicMock(
current_project_url=f"ftp://{SUBTASK}.gcode.3mf",
sdcard=True,
sdcard_reported=True,
)
patches = [
patch("backend.app.main.async_session", session_maker),
patch("backend.app.core.database.async_session", session_maker),
patch("backend.app.main.download_file_async", new=AsyncMock(return_value=download_ok)),
patch("backend.app.main.download_file_try_paths_async", new=AsyncMock(return_value=None)),
patch("backend.app.main.get_cached_3mf", return_value=None),
patch("backend.app.main.cache_3mf_download"),
patch("backend.app.main.peek_plate_index_in_3mf", return_value=None),
patch("backend.app.main.ArchiveService", _StubArchiveService),
# Imported inside the function, so patching it anywhere else lets the
# directory walk open real sockets.
patch("backend.app.services.bambu_ftp.list_files_async", new=AsyncMock(return_value=[])),
patch("backend.app.main.ftps_handshake_blocked", return_value=False),
patch("backend.app.main.get_ftp_retry_settings", new=AsyncMock(return_value=(False, 3, 2.0, 30))),
patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
patch("backend.app.main._maybe_start_layer_timelapse"),
patch("backend.app.main._capture_timelapse_baseline_at_start", new_callable=AsyncMock),
# Real, it would spawn a task that outlives the test by a minute.
patch("backend.app.main._schedule_fallback_3mf_retry"),
patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
# Imported inside on_print_start; it would try to persist the mocked
# printer state's AMS trays and fail on the MagicMock values.
patch("backend.app.services.usage_tracker.on_print_start", new_callable=AsyncMock),
]
mocks: dict = {}
with ExitStack() as stack:
for p in [*patches, *extra_patches]:
mocks[p.attribute] = stack.enter_context(p)
notif = stack.enter_context(patch("backend.app.main.notification_service"))
plug = stack.enter_context(patch("backend.app.main.smart_plug_manager"))
ws = stack.enter_context(patch("backend.app.main.ws_manager"))
relay = stack.enter_context(patch("backend.app.main.mqtt_relay"))
pm = stack.enter_context(patch("backend.app.main.printer_manager"))
notif.on_print_start = AsyncMock()
plug.on_print_start = AsyncMock()
ws.send_print_start = AsyncMock()
ws.send_archive_created = AsyncMock()
ws.send_archive_updated = AsyncMock()
relay.on_print_start = AsyncMock()
relay.on_archive_created = AsyncMock()
pm.get_status = MagicMock(return_value=state)
pm.get_printer = MagicMock(return_value=MagicMock(serial_number="TEST1898"))
from backend.app.main import on_print_start
await on_print_start(printer.id, {"filename": DISPATCH, "subtask_name": SUBTASK})
return mocks
async def _set_setting(db_session, key: str, value: str) -> None:
db_session.add(Settings(key=key, value=value))
await db_session.commit()
async def _created_archive(db_session, printer_id: int) -> PrintArchive:
db_session.expire_all()
archive = await db_session.scalar(
select(PrintArchive).where(PrintArchive.printer_id == printer_id).order_by(PrintArchive.id.desc()).limit(1)
)
assert archive is not None, "on_print_start created no archive"
return archive
class TestExternalPrintGetsTheOutcomePrompt:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_no_3mf_fallback_asks_when_the_setting_is_on(self, test_engine, db_session, printer_factory):
"""The P1S/A1 case: the 3MF cannot be fetched, the archive is written
inline — and that is the row the completion path reads the flag off."""
printer = await printer_factory()
await _set_setting(db_session, "confirm_outcome_external_prints", "true")
await _drive_print_start(test_engine, printer, download_ok=False)
archive = await _created_archive(db_session, printer.id)
assert archive.extra_data.get("no_3mf_available") is True
assert archive.confirm_requested is True
# What the completion path gates the prompt on.
assert archive.user_verdict is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_the_archive_reaches_the_rest_of_the_1898_machinery(self, test_engine, db_session, printer_factory):
"""An externally started print is now a pending confirmation like any
other: the prompt the completion path emits is gated on exactly these
two fields, and the plate-release default resolves the same row."""
from backend.app.services.print_confirmation import resolve_pending_confirmation_as_good
printer = await printer_factory()
printer_id = printer.id
await _set_setting(db_session, "confirm_outcome_external_prints", "true")
await _drive_print_start(test_engine, printer, download_ok=False)
archive = await _created_archive(db_session, printer_id)
archive.status = "completed"
await db_session.commit()
assert await resolve_pending_confirmation_as_good(db_session, printer_id) == archive.id
@pytest.mark.asyncio
@pytest.mark.integration
async def test_downloaded_3mf_archive_asks_when_the_setting_is_on(self, test_engine, db_session, printer_factory):
"""The other creation branch: the 3MF arrived and ArchiveService wrote
the row. The flag is set on the row afterwards rather than passed into
archive_print, which also serves the queue dispatcher."""
printer = await printer_factory()
await _set_setting(db_session, "confirm_outcome_external_prints", "true")
await _drive_print_start(test_engine, printer, download_ok=True)
archive = await _created_archive(db_session, printer.id)
assert archive.file_path.endswith(".3mf")
assert archive.confirm_requested is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_default_leaves_external_prints_alone(self, test_engine, db_session, printer_factory):
"""Default off: nothing about today's behaviour changes for an install
that never touches the new setting — no row for it at all."""
printer = await printer_factory()
await _drive_print_start(test_engine, printer, download_ok=False)
archive = await _created_archive(db_session, printer.id)
assert archive.confirm_requested is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_setting_off_does_not_ask(self, test_engine, db_session, printer_factory):
printer = await printer_factory()
await _set_setting(db_session, "confirm_outcome_external_prints", "false")
await _drive_print_start(test_engine, printer, download_ok=False)
assert (await _created_archive(db_session, printer.id)).confirm_requested is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_an_odd_stored_value_counts_as_off(self, test_engine, db_session, printer_factory):
"""Settings live in a VARCHAR column and every other reader treats
anything that is not "true" as off."""
printer = await printer_factory()
await _set_setting(db_session, "confirm_outcome_external_prints", "None")
await _drive_print_start(test_engine, printer, download_ok=False)
assert (await _created_archive(db_session, printer.id)).confirm_requested is False
class TestAFailureHereCostsOnlyThePrompt:
"""The prompt is optional; the rest of print start is not.
After a failed statement, a rollback of the whole transaction expires every
object the session holds, and on an async session the next read of one
raises instead of reloading. The print start below the check reads both
the printer and the archive, so the check runs in a savepoint, and the flag
write reloads what it used after its own rollback.
"""
@staticmethod
def _assert_print_start_finished(mocks, archive_id: int) -> None:
assert archive_id in _active_prints.values()
mocks["_record_energy_start"].assert_awaited()
assert mocks["_record_energy_start"].await_args.args[0].id == archive_id
mocks["_capture_timelapse_baseline_at_start"].assert_awaited()
assert mocks["_capture_timelapse_baseline_at_start"].await_args.kwargs["archive_id"] == archive_id
@pytest.mark.asyncio
@pytest.mark.integration
@pytest.mark.parametrize("download_ok", [True, False])
async def test_a_failing_query_in_the_check(self, test_engine, db_session, printer_factory, download_ok):
from sqlalchemy import text
from backend.app.api.routes import settings as settings_routes
real_get_setting = settings_routes.get_setting
async def _broken_for_this_key(db, key):
if key == "confirm_outcome_external_prints":
# A real failed statement, not just an exception: this is what
# leaves the transaction needing a rollback.
await db.execute(text("SELECT no_such_column FROM settings"))
return await real_get_setting(db, key)
printer = await printer_factory()
await _set_setting(db_session, "confirm_outcome_external_prints", "true")
mocks = await _drive_print_start(
test_engine,
printer,
download_ok=download_ok,
extra_patches=[patch.object(settings_routes, "get_setting", _broken_for_this_key)],
)
archive = await _created_archive(db_session, printer.id)
assert archive.confirm_requested is False
self._assert_print_start_finished(mocks, archive.id)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_failing_flag_write(self, test_engine, db_session, printer_factory):
async def _yes_but_poison_the_commit(db, printer_id, observed_name=None):
# Two rows for one unique key: the commit that carries the flag fails.
db.add(Settings(key="x_1898_duplicate", value="a"))
db.add(Settings(key="x_1898_duplicate", value="b"))
return True
printer = await printer_factory()
mocks = await _drive_print_start(
test_engine,
printer,
download_ok=True,
extra_patches=[patch("backend.app.main._ask_outcome_for_external_print", _yes_but_poison_the_commit)],
)
archive = await _created_archive(db_session, printer.id)
# archive_print committed the row; only the flag is lost.
assert archive.confirm_requested is False
self._assert_print_start_finished(mocks, archive.id)
class TestAQueuedPrintStillDecidesForItself:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_dispatched_job_is_not_overridden_by_the_setting(
self, test_engine, db_session, printer_factory, archive_factory
):
"""A queue item that deliberately has the ask-for-outcome flag off must
stay off. A restart mid-print empties the expected-print registry, so
the queue row — which the scheduler commits to "printing" before the
MQTT send — is the durable record that Bambuddy started this."""
printer = await printer_factory()
# Dispatched but not yet reported as started: the archive the scheduler
# attached to the row only turns "printing" once on_print_start runs, so
# the name-match resume above cannot find it and the queue row is the
# only record that Bambuddy sent this print.
source = await archive_factory(printer.id, filename=f"{SUBTASK}.gcode.3mf", status="pending", with_run=False)
db_session.add(
PrintQueueItem(
printer_id=printer.id,
archive_id=source.id,
status="printing",
confirm_outcome=False,
)
)
await db_session.commit()
await _set_setting(db_session, "confirm_outcome_external_prints", "true")
await _drive_print_start(test_engine, printer, download_ok=False)
archive = await _created_archive(db_session, printer.id)
assert archive.confirm_requested is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_queue_items_own_yes_survives_print_start(
self, test_engine, db_session, printer_factory, archive_factory
):
"""The dispatcher copies ``confirm_outcome`` onto the archive before
the print starts; the expected-print branch of ``on_print_start`` must
leave that alone even while the external-print setting is off."""
from backend.app.main import register_expected_print
printer = await printer_factory()
archive = await archive_factory(
printer.id,
filename=f"{SUBTASK}.gcode.3mf",
status="pending",
confirm_requested=True,
with_run=False,
)
db_session.add(
PrintQueueItem(
printer_id=printer.id,
archive_id=archive.id,
status="printing",
confirm_outcome=True,
)
)
await db_session.commit()
archive_id, printer_id = archive.id, printer.id
register_expected_print(printer_id, f"{SUBTASK}.gcode.3mf", archive_id)
await _drive_print_start(test_engine, printer, download_ok=False)
db_session.expire_all()
refreshed = await db_session.get(PrintArchive, archive_id)
assert refreshed.status == "printing"
assert refreshed.confirm_requested is True
# No second row for the same print.
rows = (await db_session.scalars(select(PrintArchive).where(PrintArchive.printer_id == printer_id))).all()
assert len(rows) == 1
class TestSettingsRoundTrip:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_defaults_to_off_and_round_trips(self, async_client):
response = await async_client.get("/api/v1/settings/")
assert response.status_code == 200
assert response.json()["confirm_outcome_external_prints"] is False
response = await async_client.put("/api/v1/settings/", json={"confirm_outcome_external_prints": True})
assert response.status_code == 200
assert response.json()["confirm_outcome_external_prints"] is True
assert (await async_client.get("/api/v1/settings/")).json()["confirm_outcome_external_prints"] is True
response = await async_client.put("/api/v1/settings/", json={"confirm_outcome_external_prints": False})
assert response.status_code == 200
assert (await async_client.get("/api/v1/settings/")).json()["confirm_outcome_external_prints"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_updating_it_leaves_the_per_job_default_alone(self, async_client):
"""Two different questions: one seeds the per-job toggle in the print
dialog, the other covers prints that never see that dialog."""
await async_client.put("/api/v1/settings/", json={"default_confirm_outcome": True})
body = (await async_client.put("/api/v1/settings/", json={"confirm_outcome_external_prints": True})).json()
assert body["default_confirm_outcome"] is True
assert body["confirm_outcome_external_prints"] is True
class TestAStrandedQueueRowDoesNotMuteTheSetting:
"""A ``printing`` row is not proof that Bambuddy started what is printing now.
``_completion_belongs_to_queue_item`` deliberately leaves a row open when a
completion's subtask name disagrees with the file it was dispatched with,
and the scheduler's stranded sweep only takes it back once the printer has
sat connected and terminal for minutes. Treating any such row as "we
dispatched this" switched the setting off for every screen-started print in
between -- the exact symptom the setting exists to cure.
"""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_row_left_open_for_another_file_still_asks(
self, test_engine, db_session, printer_factory, archive_factory
):
printer = await printer_factory()
stranded_for = await archive_factory(
printer.id, filename="SomeOtherJob.gcode.3mf", status="printing", with_run=False
)
db_session.add(
PrintQueueItem(
printer_id=printer.id,
archive_id=stranded_for.id,
status="printing",
confirm_outcome=False,
)
)
await db_session.commit()
await _set_setting(db_session, "confirm_outcome_external_prints", "true")
stranded_filename = stranded_for.filename
await _drive_print_start(test_engine, printer, download_ok=False)
archive = await _created_archive(db_session, printer.id)
assert archive.filename != stranded_filename
assert archive.confirm_requested is True
class TestAReprintAsksAgain:
"""The reprint reuses the archive row, and the completion prompt is gated on
``user_verdict is None`` -- so without a reset the second run inherits the
first run's answer and is never asked about."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_the_previous_runs_verdict_does_not_carry_over(
self, test_engine, db_session, printer_factory, archive_factory
):
from backend.app.main import register_expected_print
printer = await printer_factory()
archive = await archive_factory(
printer.id,
filename=f"{SUBTASK}.gcode.3mf",
status="completed",
confirm_requested=True,
user_verdict="good",
confirm_token="token-from-the-first-run",
with_run=False,
)
archive_id, printer_id = archive.id, printer.id
register_expected_print(printer_id, f"{SUBTASK}.gcode.3mf", archive_id)
await _drive_print_start(test_engine, printer, download_ok=False)
db_session.expire_all()
refreshed = await db_session.get(PrintArchive, archive_id)
assert refreshed.status == "printing"
assert refreshed.confirm_requested is True
assert refreshed.user_verdict is None
assert refreshed.confirm_token is None
# And the completion really does ask again, which is the point.
refreshed.status = "completed"
await db_session.commit()
sent, ws, notif = await _dispatch(db_session, printer_id, archive_id)
assert sent is True
ws.send_print_confirm_request.assert_awaited_once()
notif.on_print_confirm_request.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_an_archive_that_was_never_asked_about_keeps_its_verdict(
self, test_engine, db_session, printer_factory, archive_factory
):
"""The reset is scoped to rows that ask. An archive answered once and
later reprinted with the flag off must keep the answer it has."""
from backend.app.main import register_expected_print
printer = await printer_factory()
archive = await archive_factory(
printer.id,
filename=f"{SUBTASK}.gcode.3mf",
status="completed",
confirm_requested=False,
user_verdict="reject",
with_run=False,
)
archive_id = archive.id
register_expected_print(printer.id, f"{SUBTASK}.gcode.3mf", archive_id)
await _drive_print_start(test_engine, printer, download_ok=False)
db_session.expire_all()
assert (await db_session.get(PrintArchive, archive_id)).user_verdict == "reject"
async def _dispatch(db_session, printer_id: int, archive_id: int, **kwargs):
"""Run the completion path's outcome dispatch with the two emitters mocked."""
from backend.app.main import dispatch_outcome_confirmation
with (
patch("backend.app.main.ws_manager") as ws,
patch("backend.app.main.notification_service") as notif,
):
ws.send_print_confirm_request = AsyncMock()
notif.on_print_confirm_request = AsyncMock()
sent = await dispatch_outcome_confirmation(
db_session,
printer_id,
"Bench P1S",
{"subtask_name": SUBTASK},
archive_id,
**kwargs,
)
return sent, ws, notif
class TestTheCompletionEmitsThePrompt:
"""The half of the feature the user actually sees.
Everything else here asserts a column value; this drives the block
``on_print_complete``'s notification task runs -- which is wrapped in a bare
``except Exception`` that only logs, so a regression in it is invisible
unless something pins it.
"""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_an_external_print_gets_a_prompt_with_one_tap_links(self, test_engine, db_session, printer_factory):
printer = await printer_factory()
printer_id = printer.id
await _set_setting(db_session, "confirm_outcome_external_prints", "true")
await _set_setting(db_session, "external_url", "https://farm.example.com/")
await _drive_print_start(test_engine, printer, download_ok=False)
archive = await _created_archive(db_session, printer_id)
archive_id = archive.id
assert archive.confirm_requested is True
archive.status = "completed"
await db_session.commit()
sent, ws, notif = await _dispatch(db_session, printer_id, archive_id)
assert sent is True
ws.send_print_confirm_request.assert_awaited_once()
assert ws.send_print_confirm_request.await_args.args[1]["archive_id"] == archive_id
notif.on_print_confirm_request.assert_awaited_once()
kwargs = notif.on_print_confirm_request.await_args.kwargs
token = (await db_session.get(PrintArchive, archive_id)).confirm_token
assert token, "the one-tap links need a minted capability token"
assert kwargs["good_url"] == f"https://farm.example.com/api/v1/archives/confirm/{token}/good"
assert kwargs["reject_url"] == f"https://farm.example.com/api/v1/archives/confirm/{token}/reject"
assert kwargs["confirm_url"] == f"https://farm.example.com/archives?confirm={archive_id}"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_the_links_stay_absolute_without_an_external_url(self, test_engine, db_session, printer_factory):
"""Telegram's inline keyboard and ntfy's action buttons are both dropped
for a relative URL, so an install that never set external_url used to
get a message carrying two unusable paths and no buttons at all."""
printer = await printer_factory()
printer_id = printer.id
await _set_setting(db_session, "confirm_outcome_external_prints", "true")
await _drive_print_start(test_engine, printer, download_ok=False)
archive = await _created_archive(db_session, printer_id)
archive_id = archive.id
archive.status = "completed"
await db_session.commit()
_, _, notif = await _dispatch(db_session, printer_id, archive_id)
kwargs = notif.on_print_confirm_request.await_args.kwargs
assert kwargs["good_url"].startswith("http")
assert kwargs["reject_url"].startswith("http")
assert kwargs["confirm_url"].startswith("http")
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_failed_prompt_leaves_the_session_usable_for_the_email(self, db_session):
"""The completion task sends the per-user print email on the same
session right after the prompt, so a prompt that dies mid-flush must
not take the email with it."""
from backend.app.main import _dispatch_outcome_confirmation_safely
async def _dies_mid_flush(db, *args, **kwargs):
db.add(Settings(key="x_1898_duplicate", value="a"))
db.add(Settings(key="x_1898_duplicate", value="b"))
await db.flush()
with patch("backend.app.main.dispatch_outcome_confirmation", _dies_mid_flush):
await _dispatch_outcome_confirmation_safely(db_session, 1, "X1C", {}, 1, {})
# What _dispatch_user_print_email does next: read from the session.
# Without the rollback this raises PendingRollbackError.
await db_session.execute(select(Settings).limit(1))
@pytest.mark.asyncio
@pytest.mark.integration
async def test_an_already_answered_archive_is_not_asked_again(self, db_session, printer_factory, archive_factory):
printer = await printer_factory()
archive = await archive_factory(
printer.id, status="completed", confirm_requested=True, user_verdict="good", with_run=False
)
sent, ws, notif = await _dispatch(db_session, printer.id, archive.id)
assert sent is False
ws.send_print_confirm_request.assert_not_awaited()
notif.on_print_confirm_request.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_an_archive_that_never_opted_in_is_not_asked(self, db_session, printer_factory, archive_factory):
printer = await printer_factory()
archive = await archive_factory(printer.id, status="completed", confirm_requested=False, with_run=False)
sent, _, notif = await _dispatch(db_session, printer.id, archive.id)
assert sent is False
notif.on_print_confirm_request.assert_not_awaited()
@@ -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 "<form method='post'" in response.text, agent
await db_session.refresh(archive)
assert archive.user_verdict is None, agent
assert archive.confirm_token_used_at is None, f"{agent} must leave the capability spendable"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_the_forms_post_records_the_verdict(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""What the page's form does when it is submitted. Unfurlers, scanners
and prefetchers issue GET; none of them POSTs."""
printer = await printer_factory()
archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="unfurler-then-tap")
response = await async_client.post("/api/v1/archives/confirm/unfurler-then-tap/reject")
assert response.status_code == 200
assert "Rejected" in response.text
await db_session.refresh(archive)
assert archive.user_verdict == "reject"
assert archive.user_verdict_source == "link"
assert archive.confirm_token_used_at is not None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_phone_browser_still_records_in_one_tap(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""The split must not cost the feature its point. The page opened from
a notification button submits itself, so the tap on the notification is
still the only tap — and the script carries the CSP nonce, without
which the policy in main.py would block it and cost the operator a
second tap.
The ``?tap=1`` is what the Telegram inline keyboard carries. This test
used to load the URL without it and still expect the script, which was
the remaining hole rather than the feature: that unmarked URL is the
one a scanner gets out of a message body.
"""
printer = await printer_factory()
archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="phone-token")
response = await async_client.get(
"/api/v1/archives/confirm/phone-token/good?tap=1",
headers={
"user-agent": "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"
},
)
assert response.status_code == 200
assert "getElementById('confirm-form').submit()" in response.text
nonce = re.search(r"<script nonce='([^']+)'", response.text)
assert nonce, "the inline script needs the per-request nonce or the CSP blocks it"
assert f"'nonce-{nonce.group(1)}'" in response.headers["content-security-policy"]
# And the submit that script performs is the thing that records. The
# form has no action attribute, so a browser POSTs back to the URL it
# loaded — query string and all, which the route must not mind.
await db_session.refresh(archive)
assert archive.user_verdict is None
assert (await async_client.post("/api/v1/archives/confirm/phone-token/good?tap=1")).status_code == 200
await db_session.refresh(archive)
assert archive.user_verdict == "good"
assert archive.confirm_token_used_at is not None
@pytest.mark.asyncio
@pytest.mark.integration
@pytest.mark.parametrize(
"headers",
[
{"user-agent": "TelegramBot (like TwitterBot)"},
{
"user-agent": "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36",
"purpose": "prefetch",
},
],
)
async def test_an_unattended_fetch_gets_no_self_submitting_script(
self, async_client: AsyncClient, archive_factory, printer_factory, headers
):
"""Second layer, for the scanners that do run JavaScript: a request
that already looks automated is served the button without the script
that would press it."""
printer = await printer_factory()
archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="no-script-token")
assert archive.confirm_token == "no-script-token"
response = await async_client.get("/api/v1/archives/confirm/no-script-token/good?tap=1", headers=headers)
assert response.status_code == 200
assert "<form method='post'" in response.text
assert "<script" not in response.text
@pytest.mark.asyncio
@pytest.mark.integration
@pytest.mark.parametrize(
"agent",
[
# A mail-security sandbox detonating the link: renders HTML, runs
# JavaScript, and says it is Chrome — because as far as it is
# concerned it is.
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36",
# Not even hiding, and the User-Agent list still has no word for it.
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"HeadlessChrome/126.0.0.0 Safari/537.36",
],
)
async def test_a_link_out_of_the_message_body_never_submits_itself(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session, agent
):
"""The residual after taking the write off GET, now closed.
The User-Agent list catches the fetchers that announce themselves, and
none of those run JavaScript anyway. The ones that do run it — Safe
Links detonation, click-time sandboxing, a browser-isolation proxy —
send an ordinary Chrome string, so the list cannot name them and the
page used to press its own button for them.
What it can never have is the ``?tap=1`` off a notification button: a
verdict URL only reaches a scanner by travelling in message text, where
the marker is not. An install that kept ``{good_url}`` in its prompt
body (the migration leaves an edited body alone, on purpose) is covered
by this, which is the case the plain URL below stands for.
"""
printer = await printer_factory()
archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="detonated-token")
response = await async_client.get(
"/api/v1/archives/confirm/detonated-token/good", headers={"user-agent": agent}
)
assert response.status_code == 200
assert "Confirm this outcome" in response.text
assert "<form method='post'" in response.text
assert "submit()" not in response.text, "a body link must never press its own button"
await db_session.refresh(archive)
assert archive.user_verdict is None
assert archive.confirm_token_used_at is None, "the operator's tap must still be worth something"
# And the same browser, arriving from the button, is still one tap.
marked = await async_client.get(
"/api/v1/archives/confirm/detonated-token/good?tap=1", headers={"user-agent": agent}
)
assert "getElementById('confirm-form').submit()" in marked.text
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_head_request_records_nothing(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Mail scanners probe with HEAD. FastAPI's APIRoute does not widen a
GET route to HEAD the way a plain Starlette Route does, so the probe is
refused outright — and the GET it would widen to writes nothing now."""
printer = await printer_factory()
archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="head-token")
assert (await async_client.head("/api/v1/archives/confirm/head-token/good")).status_code == 405
await db_session.refresh(archive)
assert archive.user_verdict is None
assert archive.confirm_token_used_at is None
def test_confirm_links_exempt_from_auth_middleware(self):
"""The one-tap links are tapped on a phone with no session, so the
global auth middleware must step aside for them — the capability token
in the path is the credential. Pins the PUBLIC_API_PREFIXES entry;
without it, enabling authentication 401s every verdict link."""
from backend.app.main import PUBLIC_API_PREFIXES
assert "/api/v1/archives/confirm/" in PUBLIC_API_PREFIXES
class TestDefaultGoodOnPlateClear:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_helper_resolves_only_the_latest_pending(self, archive_factory, printer_factory, db_session):
"""Releasing the plate refers to the print that just came off it: only
the LATEST pending archive flips to good; older unanswered prompts and
already-answered ones stay untouched. The verdict mirrors to the run."""
from backend.app.services.print_confirmation import resolve_pending_confirmation_as_good
printer = await printer_factory()
older = await archive_factory(printer.id, confirm_requested=True)
newest_pending = await archive_factory(printer.id, confirm_requested=True, confirm_token="pending-token")
answered = await archive_factory(printer.id, confirm_requested=True, user_verdict="reject")
resolved = await resolve_pending_confirmation_as_good(db_session, printer.id)
await db_session.commit()
assert resolved == newest_pending.id
await db_session.refresh(newest_pending)
assert newest_pending.user_verdict == "good"
assert newest_pending.user_verdict_source == "plate_clear"
assert newest_pending.confirm_token == "pending-token"
assert newest_pending.confirm_token_used_at is not None
await db_session.refresh(older)
assert older.user_verdict is None
await db_session.refresh(answered)
assert answered.user_verdict == "reject"
entry = await db_session.scalar(
select(PrintLogEntry).where(PrintLogEntry.archive_id == newest_pending.id).order_by(PrintLogEntry.id.desc())
)
assert entry is not None and entry.user_verdict == "good"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_helper_noop_without_pending(self, archive_factory, printer_factory, db_session):
from backend.app.services.print_confirmation import resolve_pending_confirmation_as_good
printer = await printer_factory()
await archive_factory(printer.id) # completed, never asked
assert await resolve_pending_confirmation_as_good(db_session, printer.id) is None
class TestVerdictStatistics:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_failure_analysis_reports_rejects_separately(
self, async_client: AsyncClient, archive_factory, printer_factory
):
"""A completed-but-rejected print stays OUT of the machine failure
rate but shows up as rejected_prints and lowers the yield rate."""
printer = await printer_factory()
good = await archive_factory(printer.id, print_name="Good Part")
rejected = await archive_factory(printer.id, print_name="Scrap Part")
await archive_factory(printer.id, print_name="Machine Failure", status="failed", run_status="failed")
# Confirm one good, reject one — through the API so the mirror runs.
assert (
await async_client.patch(f"/api/v1/archives/{good.id}", json={"user_verdict": "good"})
).status_code == 200
assert (
await async_client.patch(
f"/api/v1/archives/{rejected.id}", json={"user_verdict": "reject", "failure_reason": "other"}
)
).status_code == 200
response = await async_client.get("/api/v1/archives/analysis/failures?days=30")
assert response.status_code == 200
body = response.json()
# Machine numbers unchanged by the quality dimension:
assert body["failed_prints"] == 1
assert body["failure_rate"] == pytest.approx(33.3, abs=0.1)
# Quality dimension:
assert body["rejected_prints"] == 1
assert body["yield_rate"] == pytest.approx(33.3, abs=0.1)
assert body["rejects_by_reason"] == {"other": 1}
class TestVerdictSource:
"""How a verdict came in (#1898) — every writer on this branch stamps it."""
def test_every_source_has_a_phrase_for_the_already_answered_page(self):
"""Including 'reaction', which the Telegram reaction work (#3046) will
write from its own branch: the page that explains a spent link must not
fall silent the day it lands."""
from backend.app.api.routes.archives import _VERDICT_SOURCE_PHRASES
from backend.app.services.print_confirmation import VERDICT_SOURCES
assert set(VERDICT_SOURCES) == set(_VERDICT_SOURCE_PHRASES)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_response_exposes_the_source(self, async_client: AsyncClient, archive_factory, printer_factory):
printer = await printer_factory()
archive = await archive_factory(printer.id, confirm_requested=True)
assert (await async_client.get(f"/api/v1/archives/{archive.id}")).json()["user_verdict_source"] is None
assert (
await async_client.patch(
f"/api/v1/archives/{archive.id}", json={"user_verdict": "good", "user_verdict_source": "dialog"}
)
).status_code == 200
body = (await async_client.get(f"/api/v1/archives/{archive.id}")).json()
assert body["user_verdict"] == "good"
assert body["user_verdict_source"] == "dialog"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_printer_card_and_bare_patch_sources(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
printer = await printer_factory()
from_card = await archive_factory(printer.id, confirm_requested=True)
from_script = await archive_factory(printer.id, confirm_requested=True)
await async_client.patch(
f"/api/v1/archives/{from_card.id}", json={"user_verdict": "good", "user_verdict_source": "printer_card"}
)
# No source claimed: some script or integration did it.
await async_client.patch(f"/api/v1/archives/{from_script.id}", json={"user_verdict": "reject"})
await db_session.refresh(from_card)
await db_session.refresh(from_script)
assert from_card.user_verdict_source == "printer_card"
assert from_script.user_verdict_source == "api"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_server_owned_sources_cannot_be_claimed_over_the_api(
self, async_client: AsyncClient, archive_factory, printer_factory
):
"""'link', 'plate_clear' and 'reaction' are stamped by the paths that
own them — a PATCH may not forge them."""
printer = await printer_factory()
archive = await archive_factory(printer.id, confirm_requested=True)
for forged in ("link", "plate_clear", "reaction", "nonsense"):
response = await async_client.patch(
f"/api/v1/archives/{archive.id}", json={"user_verdict": "good", "user_verdict_source": forged}
)
assert response.status_code == 422, forged
@pytest.mark.asyncio
@pytest.mark.integration
async def test_the_page_dates_the_verdict_on_file_not_the_spent_token(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""A verdict changed later must not be reported with the older decision's time.
The live case: the plate-clear default answers a print, the operator
changes the verdict in the app an hour later, then the old Telegram link
is tapped. Reporting the new verdict with the old timestamp would
describe two different events as one.
"""
from datetime import datetime, timedelta, timezone
printer = await printer_factory()
archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="dates-token")
await async_client.patch(
f"/api/v1/archives/{archive.id}", json={"user_verdict": "good", "user_verdict_source": "dialog"}
)
await db_session.refresh(archive)
first_at = archive.user_verdict_at
assert first_at is not None
# Age the first decision so the two timestamps cannot coincide.
archive.user_verdict_at = first_at - timedelta(hours=1)
archive.confirm_token_used_at = first_at - timedelta(hours=1)
await db_session.commit()
await async_client.patch(
f"/api/v1/archives/{archive.id}", json={"user_verdict": "reject", "user_verdict_source": "dialog"}
)
await db_session.refresh(archive)
assert archive.user_verdict == "reject"
assert archive.user_verdict_at > 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
+12 -3
View File
@@ -7039,11 +7039,12 @@ class TestTrayNowH2SExternalSpoolOverride:
instead of 254 when the active feed is the external spool.
Bambuddy detects the all-external case via the slicer-captured
ams_mapping (every entry == -1) and promotes tray_now to 254 so the
UI active-tray highlight matches the real feed.
ams_mapping (every entry 254, or -1 when the command carried no
ams_mapping2 to resolve it from, #3166) and promotes tray_now to 254 so
the UI active-tray highlight matches the real feed.
The override is intentionally narrow:
* only fires when ams_mapping is captured AND every entry is -1
* only fires when ams_mapping is captured AND every entry is external
* does not touch mixed prints ([5, -1]) or AMS-only prints ([5])
* does not fire when no ams_mapping is captured (printer-screen start)
"""
@@ -7072,6 +7073,14 @@ class TestTrayNowH2SExternalSpoolOverride:
mqtt_client._process_message(_ams_payload(0))
assert mqtt_client.state.tray_now == 254
def test_resolved_external_mapping_promotes(self, mqtt_client):
"""Since #3166 the capture resolves the external spool from
ams_mapping2, so a single-nozzle all-external print arrives as [254]
rather than [-1]. The override must still fire."""
mqtt_client._captured_ams_mapping = [254]
mqtt_client._process_message(_ams_payload(0))
assert mqtt_client.state.tray_now == 254
def test_ams_only_mapping_does_not_override(self, mqtt_client):
"""ams_mapping=[5] (AMS slot 5 only) -> firmware value trusted as-is.
Without the all-external guard, this would falsely override real
@@ -0,0 +1,361 @@
"""A machine must not be able to spend a one-tap verdict token (#1898).
Telegram and Slack fetch the URLs in a message to build a preview card, mail
gateways detonate links before delivery, and browsers prefetch. While the
verdict URLs went out as plain text in the notification body -- which is what
``DEFAULT_TEMPLATES['print_confirm_request']`` used to ship -- any one of those
GETs recorded a verdict nobody chose and retired the token, so the operator's
real tap landed on "already answered" and a scrap part counted as good for the
rest of time.
Four defences, in the order they matter. The capability URLs no longer travel
in body text at all. Recording is a POST, so a GET from anything that walks a
URL changes nothing (the route side of that is in the integration tests). The
confirmation page submits its own form only for a URL carrying the one-tap
marker, which rides on the Telegram inline keyboard and on nothing a machine
can read — so the scanners that render HTML and run JavaScript, which send an
ordinary Chrome string and which no User-Agent list can name, get a page with a
button on it. And the channels that build previews are asked not to, on the one
message whose links are capabilities.
The unattended-fetch heuristic is the fifth and the weakest, which is why it is
no longer the only thing in front of the write.
"""
import json
import httpx
import pytest
from backend.app.models.notification import NotificationProvider
from backend.app.models.notification_template import DEFAULT_TEMPLATES
from backend.app.services.notification_service import NotificationService
from backend.app.services.print_confirmation import is_one_tap_request, is_unattended_fetch, one_tap_url
CONFIG = {"bot_token": "123456:AAbbCC", "chat_id": "-1002520100736"}
# Real link-preview and mail-security fetchers, plus the browsers that must
# keep working. The phone UAs are the ones a notification tap actually opens.
BROWSER_AGENTS = [
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Mobile Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0",
# The ntfy app performs its action buttons itself, with an HTTP client UA.
"okhttp/4.12.0",
"python-httpx/0.27.0",
]
MACHINE_AGENTS = [
"TelegramBot (like TwitterBot)",
"Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)",
"Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)",
"facebookexternalhit/1.1",
"Twitterbot/1.0",
"WhatsApp/2.23.20.0 A",
"Mozilla/5.0 (compatible; SkypeUriPreview Preview/0.5)",
"Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
"Barracuda Sentinel (EE)",
"Mimecast-Link-Protect",
]
class _Client:
"""Stand-in for httpx.AsyncClient that records what would be sent."""
def __init__(self):
self.is_closed = False
self.calls: list[dict] = []
async def post(self, url, data=None, files=None, json=None, headers=None):
self.calls.append(json if json is not None else (data or {}))
return httpx.Response(200, json={"ok": True, "result": {}})
class TestUnattendedFetchDetection:
@pytest.mark.parametrize("agent", BROWSER_AGENTS)
def test_a_real_browser_is_left_alone(self, agent):
assert is_unattended_fetch("GET", {"user-agent": agent}) is False
@pytest.mark.parametrize("agent", MACHINE_AGENTS)
def test_link_fetchers_are_recognised(self, agent):
assert is_unattended_fetch("GET", {"user-agent": agent}) is True
def test_prefetch_hints_count_even_from_a_browser_ua(self):
"""Chrome and Firefox preload links before they are clicked; Safari's
preview sends the same hint. A finger on a button never does."""
browser = BROWSER_AGENTS[1]
assert is_unattended_fetch("GET", {"user-agent": browser, "purpose": "prefetch"}) is True
assert is_unattended_fetch("GET", {"user-agent": browser, "x-purpose": "preview"}) is True
assert is_unattended_fetch("GET", {"user-agent": browser, "x-moz": "prefetch"}) is True
assert is_unattended_fetch("GET", {"user-agent": browser, "sec-purpose": "prefetch;prerender"}) is True
def test_head_is_never_a_tap(self):
"""Only the GET route renders the page, so only a GET can be widened
to HEAD by a future router change. A probe must not come back looking
like a page load."""
assert is_unattended_fetch("HEAD", {"user-agent": BROWSER_AGENTS[0]}) is True
def test_a_missing_user_agent_is_not_held_against_the_caller(self):
"""Some notification clients send none; the token is still the
credential, and guessing here would cost real verdicts."""
assert is_unattended_fetch("GET", {}) is False
class TestPromptKeepsTheCapabilityOutOfBodyText:
"""This file used to pin the opposite: that both verdict URLs were in the
body. They were, and that was the defect — the body is the one part of a
notification that every unfurler, gateway and proxy reads. The capability
links now travel only in affordances nothing prefetches."""
def test_the_default_template_carries_only_the_deep_link(self):
template = next(t for t in DEFAULT_TEMPLATES if t["event_type"] == "print_confirm_request")
assert "{good_url}" not in template["body_template"]
assert "{reject_url}" not in template["body_template"]
assert "{confirm_url}" in template["body_template"]
class TestNtfyButtonsRecordOverPost:
@pytest.mark.asyncio
async def test_the_action_buttons_use_post(self):
"""The ntfy app issues the action itself, so it can use the method that
records. A GET would only open the confirmation page — which is the
point of the split, and is what every crawler gets."""
service = NotificationService()
captured: dict = {}
async def _fake_ntfy(config, title, message, image_data=None, event_type=None, actions=None):
captured["actions"] = actions
return True, "ok"
service._send_ntfy = _fake_ntfy
provider = NotificationProvider(
name="farm", provider_type="ntfy", config=json.dumps({"server": "https://ntfy.sh", "topic": "farm"})
)
ok, _ = await service._send_to_provider(
provider,
"How did your print come out?",
"X1C: bracket.3mf",
event_type="print_confirm_request",
variables={
"good_url": "https://farm.example.com/api/v1/archives/confirm/tok/good",
"reject_url": "https://farm.example.com/api/v1/archives/confirm/tok/reject",
},
)
assert ok
assert "method=POST" in captured["actions"]
assert "method=GET" not in captured["actions"]
class TestTheOneTapMarkerRidesOnlyOnButtons:
"""The marker is what decides whether the confirmation page submits itself,
and it is on the Telegram inline keyboard and nowhere else.
The User-Agent list above catches the fetchers that say what they are, and
none of those run JavaScript. The ones that do — a mail-security sandbox
detonating the link, a browser-isolation proxy — send an ordinary Chrome
string, so no list can name them. What they cannot have is a URL that never
appeared in any text they can read.
"""
def test_a_marked_url_is_the_only_thing_that_opens_the_script(self):
assert is_one_tap_request({"tap": "1"}) is True
assert is_one_tap_request({}) is False, "a link out of a message body must not qualify"
assert is_one_tap_request({"tap": "0"}) is False
assert is_one_tap_request({"tap": "yes"}) is False
def test_marking_a_url_that_already_carries_a_query(self):
assert one_tap_url("https://host/api/v1/archives/confirm/tok/good") == (
"https://host/api/v1/archives/confirm/tok/good?tap=1"
)
assert one_tap_url("https://host/confirm/tok/good?from=ntfy") == "https://host/confirm/tok/good?from=ntfy&tap=1"
@pytest.mark.asyncio
async def test_the_telegram_buttons_are_marked_and_the_body_link_is_not(self):
"""Telegram cannot POST, so its buttons are the one affordance that
opens a browser — and the one that needs the page to submit itself.
Telegram never fetches an inline-keyboard URL, so the marker does not
leak into anything a machine reads."""
service = NotificationService()
captured: dict = {}
async def _fake_telegram(config, message, image_data=None, buttons=None, link_preview=True):
captured["buttons"] = buttons
captured["message"] = message
return True, "ok"
service._send_telegram = _fake_telegram
provider = NotificationProvider(name="farm", provider_type="telegram", config=json.dumps(CONFIG))
ok, _ = await service._send_to_provider(
provider,
"How did your print come out?",
"X1C: bracket.3mf\nGood: https://farm.example.com/api/v1/archives/confirm/tok/good",
event_type="print_confirm_request",
variables={
"good_url": "https://farm.example.com/api/v1/archives/confirm/tok/good",
"reject_url": "https://farm.example.com/api/v1/archives/confirm/tok/reject",
},
)
assert ok
assert [b["url"] for b in captured["buttons"]] == [
"https://farm.example.com/api/v1/archives/confirm/tok/good?tap=1",
"https://farm.example.com/api/v1/archives/confirm/tok/reject?tap=1",
]
# An install that kept {good_url} in its edited body still sends the
# plain URL in the text — and that is exactly the one that must not
# press its own button when a scanner opens it.
assert "?tap=1" not in captured["message"]
@pytest.mark.asyncio
async def test_the_ntfy_actions_stay_unmarked(self):
"""They POST, so no page is rendered and there is nothing to submit.
Marking them would put the marker in an Authorization-free HTTP header
for no gain."""
service = NotificationService()
captured: dict = {}
async def _fake_ntfy(config, title, message, image_data=None, event_type=None, actions=None):
captured["actions"] = actions
return True, "ok"
service._send_ntfy = _fake_ntfy
provider = NotificationProvider(
name="farm", provider_type="ntfy", config=json.dumps({"server": "https://ntfy.sh", "topic": "farm"})
)
ok, _ = await service._send_to_provider(
provider,
"How did your print come out?",
"X1C: bracket.3mf",
event_type="print_confirm_request",
variables={
"good_url": "https://farm.example.com/api/v1/archives/confirm/tok/good",
"reject_url": "https://farm.example.com/api/v1/archives/confirm/tok/reject",
},
)
assert ok
assert "tap=1" not in captured["actions"]
class TestSlackDoesNotUnfurl:
@pytest.mark.asyncio
async def test_the_slack_payload_turns_previews_off(self):
"""Slack and Mattermost unfurl the URLs in `text` the same way
Telegram builds a preview card."""
service = NotificationService()
client = _Client()
service._http_client = client
ok, _ = await service._send_webhook(
{"webhook_url": "https://hooks.slack.example.com/services/T/B/x", "payload_format": "slack"},
"How did your print come out?",
"X1C: bracket.3mf",
event_type="print_confirm_request",
)
assert ok
assert client.calls[0]["unfurl_links"] is False
assert client.calls[0]["unfurl_media"] is False
@pytest.mark.asyncio
async def test_every_other_event_keeps_its_previews(self):
"""Only the outcome prompt's links are capabilities. The slack payload
never attaches image bytes — the base64 attach is generic-format only —
so the unfurl is the only way a {finish_photo_url} in a print_complete
body ever becomes a photo in the channel, and there is no setting that
turns it back on."""
service = NotificationService()
client = _Client()
service._http_client = client
ok, _ = await service._send_webhook(
{"webhook_url": "https://hooks.slack.example.com/services/T/B/x", "payload_format": "slack"},
"Print complete",
"X1C: bracket.3mf\nhttps://farm.example.com/api/v1/archives/7/photos/finish_a.jpg",
event_type="print_complete",
)
assert ok
assert "unfurl_links" not in client.calls[0]
assert "unfurl_media" not in client.calls[0]
class TestTelegramDoesNotAskForAPreview:
@pytest.mark.asyncio
async def test_send_message_disables_the_link_preview(self):
"""Telegram's servers GET the first URL in the text to build the
preview card. That fetch is the one that answered the prompt."""
service = NotificationService()
client = _Client()
service._http_client = client
ok, _ = await service._send_telegram(
CONFIG,
"*How did your print come out?*\nX1C: bracket.3mf\nGood: https://host/api/v1/archives/confirm/tok/good",
link_preview=False,
)
assert ok
assert client.calls[0]["disable_web_page_preview"] is True
@pytest.mark.asyncio
async def test_the_inline_buttons_variant_disables_it_too(self):
"""The shape the outcome prompt actually sends on a camera-less
printer: no photo, so sendMessage rather than sendPhoto."""
service = NotificationService()
client = _Client()
service._http_client = client
buttons = [
{"text": "Good", "url": "https://host/api/v1/archives/confirm/tok/good"},
{"text": "Reject", "url": "https://host/api/v1/archives/confirm/tok/reject"},
]
ok, _ = await service._send_telegram(CONFIG, "*T*\nbody", buttons=buttons, link_preview=False)
assert ok
assert client.calls[0]["disable_web_page_preview"] is True
assert client.calls[0]["reply_markup"] == {"inline_keyboard": [buttons]}
@pytest.mark.asyncio
async def test_every_other_message_keeps_its_preview(self):
"""When the finish photo is too large to attach, the preview card is
how a {finish_photo_url} in a print_complete body still shows up as a
photo in the chat. Only the outcome prompt gives that up."""
service = NotificationService()
client = _Client()
service._http_client = client
ok, _ = await service._send_telegram(
CONFIG, "*Print complete*\nX1C: bracket.3mf\nhttps://farm.example.com/api/v1/archives/7/photos/finish_a.jpg"
)
assert ok
assert "disable_web_page_preview" not in client.calls[0]
@pytest.mark.asyncio
@pytest.mark.parametrize(
("event_type", "expected"),
[("print_confirm_request", False), ("print_complete", True), (None, True)],
)
async def test_only_the_outcome_prompt_is_sent_without_a_preview(self, event_type, expected):
service = NotificationService()
captured: dict = {}
async def _fake_telegram(config, message, image_data=None, buttons=None, link_preview=True):
captured["link_preview"] = link_preview
return True, "ok"
service._send_telegram = _fake_telegram
provider = NotificationProvider(name="farm", provider_type="telegram", config=json.dumps(CONFIG))
ok, _ = await service._send_to_provider(provider, "Title", "body", event_type=event_type)
assert ok
assert captured["link_preview"] is expected
@@ -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()
@@ -0,0 +1,230 @@
"""External-spool usage charged to an AMS spool (#3166).
The firmware rejects 254/255 in a print command's flat ``ams_mapping``, so
BambuStudio and our own dispatch both write an external spool there as -1 and
put the real target in ``ams_mapping2``. The MQTT client captured only the flat
list, so a print fed from the external spool reached the usage tracker as
``[-1]`` -- and the tracker's position-based fallback then charged it to the
first loaded AMS tray. The reporter's H2D logged this for every external-spool
print: 225 g of ABS from the right external spool deducted from the PLA spool
in AMS slot 1.
The command payloads below are verbatim from that reporter's support bundle
(H2D, firmware 01.03.00.00, one AMS 2 Pro).
"""
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.models.archive import PrintArchive
from backend.app.models.spool import Spool
from backend.app.services.bambu_mqtt import BambuMQTTClient, resolve_external_spools_in_mapping
from backend.app.services.usage_tracker import _track_from_3mf
pytestmark = pytest.mark.unit
def project_file(ams_mapping, ams_mapping2=None) -> dict:
print_data = {
"sequence_id": "20000",
"command": "project_file",
"param": "Metadata/plate_1.gcode",
"url": "ftp://Small_Spool_Adapter_(PLA)_A_Side.3mf",
"ams_mapping": ams_mapping,
}
if ams_mapping2 is not None:
print_data["ams_mapping2"] = ams_mapping2
return {"print": print_data}
EXT_RIGHT = {"ams_id": 255, "slot_id": 0}
EXT_LEFT = {"ams_id": 254, "slot_id": 0}
UNMAPPED = {"ams_id": 255, "slot_id": 255}
class TestResolvingTheExternalSpool:
def test_dual_nozzle_right_external(self):
assert resolve_external_spools_in_mapping([-1], [EXT_RIGHT], is_dual_nozzle=True) == [255]
def test_dual_nozzle_left_external(self):
assert resolve_external_spools_in_mapping([-1], [EXT_LEFT], is_dual_nozzle=True) == [254]
def test_single_nozzle_external_is_254_whatever_the_wire_says(self):
# BambuStudio always sends ams_id 255 for the one external spool of a
# single-nozzle printer; Bambuddy knows that spool as global tray 254.
assert resolve_external_spools_in_mapping([-1], [EXT_RIGHT], is_dual_nozzle=False) == [254]
def test_mixed_mapping_only_the_external_entry_changes(self):
mapping2 = [
{"ams_id": 0, "slot_id": 0},
EXT_RIGHT,
{"ams_id": 0, "slot_id": 2},
{"ams_id": 0, "slot_id": 3},
]
assert resolve_external_spools_in_mapping([0, -1, 2, 3], mapping2, is_dual_nozzle=True) == [0, 255, 2, 3]
def test_an_unmapped_slot_stays_unmapped(self):
assert resolve_external_spools_in_mapping([0, -1], [{"ams_id": 0, "slot_id": 0}, UNMAPPED], True) == [0, -1]
@pytest.mark.parametrize("mapping2", [None, "junk", [EXT_RIGHT, EXT_RIGHT]])
def test_without_a_usable_mapping2_the_capture_is_untouched(self, mapping2):
assert resolve_external_spools_in_mapping([-1], mapping2, is_dual_nozzle=True) == [-1]
def test_non_list_mapping_is_returned_as_is(self):
assert resolve_external_spools_in_mapping(None, [EXT_RIGHT], is_dual_nozzle=True) is None
class TestTheCapturedMapping:
def test_h2d_command_captures_the_right_external_spool(self):
client = BambuMQTTClient(ip_address="10.0.0.7", serial_number="H2D3166", access_code="12345678", model="H2D")
client._handle_request_message(project_file([-1], [EXT_RIGHT]))
assert client._captured_ams_mapping == [255]
def test_h2d_mixed_command(self):
client = BambuMQTTClient(ip_address="10.0.0.7", serial_number="H2D3166", access_code="12345678", model="H2D")
mapping2 = [
{"ams_id": 0, "slot_id": 0},
EXT_RIGHT,
{"ams_id": 0, "slot_id": 2},
{"ams_id": 0, "slot_id": 3},
]
client._handle_request_message(project_file([0, -1, 2, 3], mapping2))
assert client._captured_ams_mapping == [0, 255, 2, 3]
def test_p2s_command_captures_tray_254(self):
client = BambuMQTTClient(ip_address="10.0.0.7", serial_number="P2S3166", access_code="12345678", model="P2S")
client._handle_request_message(project_file([-1], [EXT_RIGHT]))
assert client._captured_ams_mapping == [254]
def test_command_without_mapping2_is_captured_as_before(self):
client = BambuMQTTClient(ip_address="10.0.0.7", serial_number="X1C3166", access_code="12345678", model="X1C")
client._handle_request_message(project_file([-1]))
assert client._captured_ams_mapping == [-1]
# --- The usage tracker ---------------------------------------------------------
def _spool(spool_id: int):
spool = MagicMock()
spool.id = spool_id
spool.label_weight = 1000
spool.weight_used = 0
spool.cost_per_kg = None
spool.material = "ABS"
spool.rgba = None
return spool
def _db(spools: dict[int, MagicMock]):
"""Answer the tracker's archive and spool lookups by what they select."""
archive = MagicMock()
archive.id = 142
archive.file_path = "archives/142/test.3mf"
archive.extra_data = None
archive.plate_id = None
selected: list = []
async def execute(stmt, *args, **kwargs):
entity = stmt.column_descriptions[0].get("entity")
result = MagicMock()
value = None
if entity is PrintArchive:
value = archive
elif entity is Spool:
spool_id = stmt.whereclause.right.value
value = spools.get(spool_id)
selected.append(spool_id)
result.scalar_one_or_none.return_value = value
result.scalars.return_value.first.return_value = None
result.scalar.return_value = None
return result
db = AsyncMock()
db.execute = execute
db.add = MagicMock()
return db
# The reporter's printer: PLA in AMS slot 1 (spool 20), ABS on the right
# external spool (spool 4). The AMS slot is loaded, so the old position-based
# fallback had a tray to land on.
ASSIGNED = {(0, 0): 20, (255, 1): 4}
RAW_DATA = {
"ams": [{"id": "0", "tray": [{"id": "0", "tray_type": "PLA"}]}],
"vt_tray": [{"id": "255", "tray_type": "ABS"}],
}
async def _track(ams_mapping, filament_usage, tray_now_at_start=-1, tray_now=255):
charged: list[tuple[int, int]] = []
async def resolve(printer_id, ams_id, tray_id, **kwargs):
charged.append((ams_id, tray_id))
return ASSIGNED.get((ams_id, tray_id))
printer_manager = MagicMock()
printer_manager.get_status.return_value = SimpleNamespace(
progress=100,
layer_num=50,
tray_now=tray_now,
last_loaded_tray=-1,
tray_change_log=[],
raw_data=RAW_DATA,
)
with (
patch("backend.app.core.config.settings") as mock_settings,
patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
patch("backend.app.services.usage_tracker._resolve_spool_id_for_tray", side_effect=resolve),
):
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_settings.base_dir = MagicMock()
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
results = await _track_from_3mf(
printer_id=1,
archive_id=142,
status="completed",
print_name="Daft_Punk_Helmet_Back",
handled_trays=set(),
printer_manager=printer_manager,
db=_db({20: _spool(20), 4: _spool(4)}),
ams_mapping=ams_mapping,
tray_now_at_start=tray_now_at_start,
print_started_at=datetime.now(timezone.utc),
)
return results, charged
ABS_ONLY = [{"slot_id": 1, "used_g": 225.14, "type": "ABS", "color": "#000000"}]
class TestTheTrackerCharges:
@pytest.mark.asyncio
async def test_the_resolved_mapping_charges_the_external_spool(self):
results, charged = await _track([255], ABS_ONLY)
assert [r["spool_id"] for r in results] == [4]
assert charged == [(255, 1)]
@pytest.mark.asyncio
async def test_an_unmapped_slot_does_not_fall_back_to_an_ams_tray(self):
# A two-filament print where the mapping leaves slot 2 unfed. The old
# fallback handed slot 2 the second loaded tray instead.
usage = [
{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#000000"},
{"slot_id": 2, "used_g": 5.0, "type": "ABS", "color": "#000000"},
]
results, charged = await _track([0, -1], usage)
assert [r["spool_id"] for r in results] == [20]
assert charged == [(0, 0)]
@pytest.mark.asyncio
async def test_an_all_unmapped_capture_defers_to_tray_now(self):
# A command without ams_mapping2 still captures [-1]. That names no
# tray, so the printer's own tray_now decides -- not AMS slot 1.
results, charged = await _track([-1], ABS_ONLY, tray_now_at_start=255)
assert (0, 0) not in charged
assert [r["spool_id"] for r in results] == [4]
+1
View File
@@ -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"
@@ -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 <img> tags.
("GET", "/api/v1/obico/cached-frame/{nonce}"),
# MakerWorld thumbnail proxy — fetches external URL; no Bambuddy data exposed.
@@ -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
@@ -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: () => <div data-testid="print-modal" />,
}));
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<string, unknown> | 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<string, unknown>;
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(<ConfirmOutcomeDialog archiveId={42} onClose={onClose} />);
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(<ConfirmOutcomeDialog archiveId={42} onClose={onClose} />);
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(<ConfirmOutcomeDialog archiveId={42} onClose={vi.fn()} />);
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(<ConfirmOutcomeDialog archiveId={42} onClose={vi.fn()} />);
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(<ConfirmOutcomeDialog archiveId={42} onClose={vi.fn()} />);
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.'
);
});
});
@@ -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(
<EditArchiveModal
archive={{ ...completed, user_verdict_source: 'plate_clear' }}
onClose={mockOnClose}
onSave={mockOnSave}
/>,
);
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(
<EditArchiveModal
archive={{ ...completed, user_verdict_source: null }}
onClose={mockOnClose}
onSave={mockOnSave}
/>,
);
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<string, unknown> | null = null;
server.use(
http.patch('/api/v1/archives/:id', async ({ request }) => {
seen = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ ...completed, ...seen });
}),
);
render(
<EditArchiveModal
archive={{ ...completed, user_verdict_source: 'plate_clear' }}
onClose={mockOnClose}
onSave={mockOnSave}
/>,
);
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<string, unknown> | null = null;
server.use(
http.patch('/api/v1/archives/:id', async ({ request }) => {
seen = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ ...cancelled, ...seen });
}),
);
render(<EditArchiveModal archive={cancelled} onClose={mockOnClose} onSave={mockOnSave} />);
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<string, unknown> | 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<string, unknown>;
return HttpResponse.json({ ...rejected, ...seen });
}),
);
render(<EditArchiveModal archive={rejected} onClose={mockOnClose} onSave={mockOnSave} />);
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();
});
});
});
@@ -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(<Layout />);
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(<Layout />);
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(<Layout />);
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(<Layout />);
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(<Layout />);
// 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(<Layout />);
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(<Layout />);
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();
});
});
});
@@ -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(
<PrintModal mode="create" archiveId={1} archiveName="Benchy" initialSelectedPrinterIds={[1]} onClose={mockOnClose} />
);
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(
<PrintModal mode="create" archiveId={1} archiveName="Benchy" initialSelectedPrinterIds={[1]} onClose={mockOnClose} />
);
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<string, unknown> | null = null;
server.use(
http.post('/api/v1/queue/', async ({ request }) => {
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ id: 1, status: 'pending' });
}),
);
const user = userEvent.setup();
render(
<PrintModal mode="create" archiveId={1} archiveName="Benchy" initialSelectedPrinterIds={[1]} onClose={mockOnClose} />
);
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<string, unknown> | null = null;
server.use(
http.patch('/api/v1/queue/:id', async ({ request }) => {
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ id: 1, status: 'pending' });
}),
);
const user = userEvent.setup();
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={createMockQueueItem({ confirm_outcome: true })}
onClose={mockOnClose}
/>
);
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);
});
});
});
@@ -618,4 +618,92 @@ describe('ArchivesPage', () => {
});
});
});
// #1898: the "outcome?" badge on a print still waiting for its verdict opens
// the dialog. The PATCH behind it checks archives:update against the owner,
// like the menu item beside it, so for anyone else the badge is a status only.
describe('awaiting-outcome badge', () => {
const awaiting = { ...mockArchives[0], confirm_requested: true, user_verdict: null, created_by_id: 7 };
const signInWith = (permissions: string[]) => {
setAuthToken('verdict-token', 'session');
server.use(
http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
http.get('/api/v1/auth/me', () => HttpResponse.json({
id: 7,
username: 'operator',
is_active: true,
is_admin: false,
groups: [],
permissions,
created_at: '2026-08-18T00:00:00Z',
})),
http.get('/api/v1/archives/', () => HttpResponse.json([awaiting])),
);
};
afterEach(() => {
window.localStorage.removeItem('archiveViewMode');
});
it('is a status only for a user who may not update the archive', async () => {
signInWith(['archives:read_all']);
render(<ArchivesPage />);
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(<ArchivesPage />);
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(<ArchivesPage />);
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(<ArchivesPage />);
const badge = await screen.findByRole('button', { name: 'outcome?' });
await waitFor(() => expect(badge).toBeEnabled());
fireEvent.click(badge);
expect(await screen.findByTestId('confirm-outcome-dialog')).toBeInTheDocument();
});
});
describe('unconfirmed filter', () => {
afterEach(() => {
window.localStorage.removeItem('archiveFilterUnconfirmed');
});
it('is cleared by Reset like every other top filter', async () => {
render(<ArchivesPage />);
await screen.findByText('Benchy');
fireEvent.click(screen.getByTitle('Show only prints still waiting for their outcome verdict'));
// Neither fixture archive is waiting for a verdict, so the filter empties the list.
await waitFor(() => expect(screen.queryByText('Benchy')).not.toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Reset' }));
expect(await screen.findByText('Benchy')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Reset' })).not.toBeInTheDocument();
});
});
});
@@ -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<Record<string, unknown>>;
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<string, unknown>;
patched.push(body);
return HttpResponse.json({ ...finishedPrint, ...body });
}),
);
});
it('records good from the card and says so', async () => {
const user = userEvent.setup();
render(<PrintersPage />);
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(<PrintersPage />);
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(<PrintersPage />);
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(<PrintersPage />);
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(<PrintersPage />);
expect(await screen.findByTitle('Good')).toBeInTheDocument();
expect(screen.getByTitle('Reject')).toBeInTheDocument();
});
});
});
@@ -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(<QueuePage />);
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(<QueuePage />);
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(<QueuePage />);
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<string, unknown> | null } = { body: null };
server.use(
http.patch('/api/v1/queue/bulk', async ({ request }) => {
captured.body = (await request.json()) as Record<string, unknown>;
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);
});
});
});
@@ -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(<SettingsPage />);
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<string, unknown> | null = null;
server.use(
http.put('/api/v1/settings/', async ({ request }) => {
saved = (await request.json()) as Record<string, unknown>;
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(<SettingsPage />);
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,
);
});
});
});
/**
+40
View File
@@ -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<string, number>;
failures_by_reason: Record<string, number>;
failures_by_filament: Record<string, number>;
failures_by_printer: Record<string, number>;
@@ -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<Archive>(`/archives/${id}`, {
method: 'PATCH',
@@ -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
</div>
<Toggle checked={onPlateClearRequired} onChange={setOnPlateClearRequired} />
</div>
<div className="flex items-center justify-between col-span-2">
<div>
<span className="text-sm text-white">{t('notifications.printConfirmRequest')}</span>
<span className="text-xs text-bambu-gray ml-1">{t('notifications.printConfirmRequestDescription')}</span>
</div>
<Toggle checked={onPrintConfirmRequest} onChange={setOnPrintConfirmRequest} />
</div>
<div className="flex items-center justify-between col-span-2">
<div>
<span className="text-sm text-white">{t('notifications.bedCooled')}</span>
@@ -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') });
@@ -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=<id> 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 (
<PrintModal
mode="create"
archiveId={archiveId}
archiveName={name}
onClose={onClose}
/>
);
}
return (
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4" data-testid="confirm-outcome-dialog">
<div className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-bambu-dark-tertiary">
<h3 className="text-white font-semibold">{t('confirmOutcome.title')}</h3>
<button onClick={onClose} className="text-bambu-gray hover:text-white transition-colors" title={t('common.close')}>
<X className="w-5 h-5" />
</button>
</div>
{/* Photo */}
<div className="bg-bambu-dark aspect-video flex items-center justify-center overflow-hidden">
{photoUrl ? (
<img src={photoUrl} alt={name} className="w-full h-full object-contain" />
) : (
<div className="flex flex-col items-center gap-2 text-bambu-gray/60">
<ImageOff className="w-10 h-10" />
<span className="text-xs">{t('confirmOutcome.noPhoto')}</span>
</div>
)}
</div>
<div className="p-4">
<p className="text-sm text-white text-center mb-1 truncate" title={name}>{name}</p>
{alreadyDecided ? (
<>
<p className="text-sm text-bambu-gray text-center">
{archive?.user_verdict === 'good'
? t('confirmOutcome.alreadyGood')
: t('confirmOutcome.alreadyRejected')}
</p>
{sourceKey && (
<p className="text-xs text-bambu-gray/70 text-center mt-1" data-testid="verdict-source-hint">
{t(sourceKey)}
</p>
)}
</>
) : !rejecting ? (
<div className="flex items-center justify-center gap-6 mt-3">
<button
type="button"
onClick={() => setRejecting(true)}
disabled={verdictMutation.isPending}
className="flex flex-col items-center gap-1.5 px-6 py-3 rounded-xl border border-red-500/40 text-red-500 hover:bg-red-500/10 transition-colors"
>
<ThumbsDown className="w-7 h-7" />
<span className="text-xs">{t('confirmOutcome.reject')}</span>
</button>
<button
type="button"
onClick={handleGood}
disabled={verdictMutation.isPending}
className="flex flex-col items-center gap-1.5 px-6 py-3 rounded-xl border border-bambu-green/50 text-bambu-green hover:bg-bambu-green/10 transition-colors"
>
<ThumbsUp className="w-7 h-7" />
<span className="text-xs">{t('confirmOutcome.good')}</span>
</button>
</div>
) : (
<div className="mt-3">
<label className="block text-xs text-bambu-gray mb-1">{t('confirmOutcome.rejectReason')}</label>
<select
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
>
<option value="">{t('confirmOutcome.noReason')}</option>
{FAILURE_REASON_KEYS.map((key) => (
<option key={key} value={key}>{t(`editArchive.failureReasons.${key}`)}</option>
))}
</select>
<div className="flex gap-2 mt-3">
<button
type="button"
onClick={() => handleRejectSave(false)}
disabled={verdictMutation.isPending}
className="flex-1 px-3 py-2 rounded-lg bg-red-500/80 hover:bg-red-500 text-white text-sm transition-colors"
>
{t('confirmOutcome.saveReject')}
</button>
<button
type="button"
onClick={() => handleRejectSave(true)}
disabled={verdictMutation.isPending}
className="flex-1 px-3 py-2 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green/60 text-white text-sm transition-colors flex items-center justify-center gap-1.5"
>
<RotateCcw className="w-4 h-4" />
{t('confirmOutcome.rejectAndReprint')}
</button>
</div>
<button
type="button"
onClick={() => setRejecting(false)}
className="w-full mt-2 text-xs text-bambu-gray hover:text-white transition-colors"
>
{t('common.back')}
</button>
</div>
)}
{!alreadyDecided && !rejecting && (
<button
type="button"
onClick={onClose}
className="w-full mt-4 text-xs text-bambu-gray hover:text-white transition-colors"
>
{t('confirmOutcome.later')}
</button>
)}
</div>
</div>
</div>
);
}
+52 -5
View File
@@ -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<string>(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
</select>
</div>
{/* Failure Reason - only show for failed/aborted prints */}
{(status === 'failed' || status === 'aborted') && (
{/* Outcome verdict (#1898) - only meaningful on completed prints */}
{status === 'completed' && (
<div>
<label htmlFor="user-verdict-select" className="block text-sm text-bambu-gray mb-1">
{t('editArchive.userVerdict')}
</label>
<select
id="user-verdict-select"
value={userVerdict}
onChange={(e) => setUserVerdict(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="">{t('editArchive.verdicts.none')}</option>
<option value="good">{t('editArchive.verdicts.good')}</option>
<option value="reject">{t('editArchive.verdicts.reject')}</option>
</select>
{/* 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 ?? '') && (
<p className="text-xs text-bambu-gray/70 mt-1" data-testid="verdict-source-hint">
{t(verdictSourceHintKey)}
</p>
)}
</div>
)}
{/* 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')) && (
<div>
<label htmlFor="failure-reason-select" className="block text-sm text-bambu-gray mb-1">{t('editArchive.failureReason')}</label>
<select
+56 -1
View File
@@ -14,6 +14,7 @@ import { useColorCatalogVersion } from '../hooks/useColorCatalogVersion';
import { useSponsorPrompt } from '../hooks/useSponsorPrompt';
import { useUnknownTagPrompt } from '../hooks/useUnknownTagPrompt';
import { UnknownSpoolModal } from './UnknownSpoolModal';
import { ConfirmOutcomeDialog } from './ConfirmOutcomeDialog';
import { useAuth } from '../contexts/AuthContext';
import { useToast } from '../contexts/ToastContext';
import { Card, CardHeader, CardContent } from './Card';
@@ -100,7 +101,7 @@ export function Layout() {
// catalog fetched — and cached HSL-fallback color names during their first
// render — refresh with the real catalog names. See #857.
useColorCatalogVersion();
const { user, authEnabled, logout, hasPermission } = useAuth();
const { user, authEnabled, logout, hasPermission, hasAnyPermission, loading: authLoading } = useAuth();
const { showToast } = useToast();
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
const [changePasswordData, setChangePasswordData] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
@@ -124,6 +125,9 @@ export function Layout() {
printer_name: string;
message: string;
} | null>(null);
// Post-print outcome confirmation (#1898): archive waiting for a verdict,
// set by the print_confirm_request WebSocket event.
const [confirmOutcomeArchiveId, setConfirmOutcomeArchiveId] = useState<number | null>(null);
// Check for updates
const { data: versionInfo } = useQuery({
@@ -460,6 +464,43 @@ export function Layout() {
return () => window.removeEventListener('plate-not-empty', handlePlateNotEmpty);
}, [hasPermission]);
// A completed print asked for its outcome verdict (#1898). Same CustomEvent
// relay as plate-not-empty, and gated the same way: the PATCH route decides
// who may record a verdict, so a user who cannot should not be handed a
// dialog whose only outcome is a 403.
// Held until the auth state has landed: while it is loading, `authEnabled`
// is still false and every permission check answers yes, which would open
// the dialog for exactly the user this gate exists to spare.
const canConfirmOutcome = !authLoading && hasAnyPermission('archives:update_all', 'archives:update_own');
useEffect(() => {
const handleConfirmRequest = (event: Event) => {
const detail = (event as CustomEvent).detail;
if (typeof detail?.archive_id === 'number') {
setConfirmOutcomeArchiveId(detail.archive_id);
}
};
window.addEventListener('print-confirm-request', handleConfirmRequest);
return () => window.removeEventListener('print-confirm-request', handleConfirmRequest);
}, []);
// The ?confirm=<id> deep link a push notification carries (#1898). It is read
// here and not on ArchivesPage because that page renders inside this Layout's
// <Outlet />: React flushes a child's effects before its parent's, so a page
// dispatching `print-confirm-request` on mount fired before the listener above
// existed — which is exactly the cold load a notification tap produces. The
// parameter is stripped afterwards so a reload does not re-open the dialog.
useEffect(() => {
const params = new URLSearchParams(location.search);
const confirmId = params.get('confirm');
if (!confirmId || !/^\d+$/.test(confirmId)) {
return;
}
setConfirmOutcomeArchiveId(Number(confirmId));
params.delete('confirm');
const rest = params.toString();
navigate({ pathname: location.pathname, search: rest ? `?${rest}` : '' }, { replace: true });
}, [location.pathname, location.search, navigate]);
// Global keyboard shortcuts for navigation
const handleKeyDown = useCallback((e: KeyboardEvent) => {
const target = e.target as HTMLElement;
@@ -1021,6 +1062,20 @@ export function Layout() {
</div>
)}
{/* Post-print outcome confirmation (#1898) */}
{/* The dialog's only outcome for a user who may not record a verdict is
a 403 from the PATCH, so it is gated on the permissions that route
enforces — the names the June migration left in the default groups.
Gated on the render rather than on the two ways a request arrives:
one of them can land before /auth/me has answered, and a check made
then would drop it for everybody. */}
{canConfirmOutcome && confirmOutcomeArchiveId !== null && (
<ConfirmOutcomeDialog
archiveId={confirmOutcomeArchiveId}
onClose={() => setConfirmOutcomeArchiveId(null)}
/>
)}
{/* Change Password Modal */}
{showChangePasswordModal && (
<div
@@ -126,6 +126,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{provider.on_plate_clear_required && (
<span className="px-2 py-0.5 bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 text-xs rounded">{t('notifications.plateClear')}</span>
)}
{provider.on_print_confirm_request && (
<span className="px-2 py-0.5 bg-teal-100 dark:bg-teal-500/20 text-teal-700 dark:text-teal-400 text-xs rounded">{t('notifications.printConfirmRequest')}</span>
)}
{provider.on_print_failed && (
<span className="px-2 py-0.5 bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400 text-xs rounded">{t('notifications.failed')}</span>
)}
@@ -314,6 +317,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">{t('notifications.printConfirmRequest')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.printConfirmRequestDescription')}</p>
</div>
<Toggle
checked={provider.on_print_confirm_request ?? true}
onChange={(checked) => updateMutation.mutate({ on_print_confirm_request: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">{t('notifications.bedCooledLabel')}</p>
@@ -49,6 +49,8 @@ export function PrintOptionsPanel({
{ key: 'layer_inspect', label: t('settings.defaultLayerInspect'), desc: t('settings.defaultLayerInspectDesc') },
{ key: 'timelapse', label: t('settings.defaultTimelapse'), desc: t('settings.defaultTimelapseDesc') },
{ key: 'nozzle_offset_cali', label: t('settings.defaultNozzleOffsetCali'), desc: t('settings.defaultNozzleOffsetCaliDesc'), dualNozzleOnly: true, tristate: true },
// Post-print outcome confirmation (#1898) — same settings.default* reuse.
{ key: 'confirm_outcome', label: t('settings.defaultConfirmOutcome'), desc: t('settings.defaultConfirmOutcomeDesc') },
];
const visibleOptions = printOptionsConfig.filter(o => !o.dualNozzleOnly || showDualNozzleOptions);
+21 -1
View File
@@ -1,5 +1,5 @@
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertCircle, AlertTriangle, Loader2, Pencil, Printer, X } from 'lucide-react';
import { AlertCircle, AlertTriangle, Loader2, Pencil, Printer, ThumbsUp, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { CostCenterSummary, PrintQueueItemCreate, PrintQueueItemUpdate, SlotMaterial } from '../../api/client';
@@ -175,6 +175,7 @@ export function PrintModal({
layer_inspect: queueItem.layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect,
timelapse: queueItem.timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse,
nozzle_offset_cali: queueItem.nozzle_offset_cali ?? DEFAULT_PRINT_OPTIONS.nozzle_offset_cali,
confirm_outcome: queueItem.confirm_outcome ?? DEFAULT_PRINT_OPTIONS.confirm_outcome,
preheat_override: queueItem.preheat_override ?? DEFAULT_PRINT_OPTIONS.preheat_override,
preheat_chamber_target_override: queueItem.preheat_chamber_target_override ?? DEFAULT_PRINT_OPTIONS.preheat_chamber_target_override,
};
@@ -325,6 +326,7 @@ export function PrintModal({
layer_inspect: settings.default_layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect,
timelapse: settings.default_timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse,
nozzle_offset_cali: settings.default_nozzle_offset_cali ?? DEFAULT_PRINT_OPTIONS.nozzle_offset_cali,
confirm_outcome: settings.default_confirm_outcome ?? DEFAULT_PRINT_OPTIONS.confirm_outcome,
preheat_override: DEFAULT_PRINT_OPTIONS.preheat_override,
preheat_chamber_target_override: DEFAULT_PRINT_OPTIONS.preheat_chamber_target_override,
});
@@ -1978,6 +1980,24 @@ export function PrintModal({
hasGcodeSnippets={!!settings?.gcode_snippets}
/>
{/* Outcome prompt (#1898) sits outside the collapsed Print Options
panel so it is discoverable; it edits the same printOptions
field as the row inside the panel. */}
<button
type="button"
aria-pressed={printOptions.confirm_outcome}
title={t('printModal.askForOutcomeTitle')}
onClick={() => setPrintOptions((prev) => ({ ...prev, confirm_outcome: !prev.confirm_outcome }))}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm transition-colors ${
printOptions.confirm_outcome
? 'bg-bambu-green/20 border-bambu-green text-bambu-green'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
>
<ThumbsUp className="w-4 h-4" />
{t('printModal.askForOutcome')}
</button>
{/* Error message */}
{updateQueueMutation.isError && (
<div className="mb-4 p-3 bg-red-100 dark:bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-700 dark:text-red-400">
@@ -66,6 +66,8 @@ export interface PrintOptions {
layer_inspect: boolean;
timelapse: boolean;
nozzle_offset_cali: CalibrationMode;
// Ask for a post-print outcome verdict when this job completes (#1898)
confirm_outcome: boolean;
// Per-item preheat / heat-soak override (#1468). 'inherit' uses the global
// Settings → Workflow toggle; 'on' / 'off' force the per-print decision.
// chamber_target_override is non-null to bypass the per-filament-type
@@ -84,6 +86,7 @@ export const DEFAULT_PRINT_OPTIONS: PrintOptions = {
layer_inspect: false,
timelapse: false,
nozzle_offset_cali: 'auto',
confirm_outcome: false,
preheat_override: 'inherit',
preheat_chamber_target_override: null,
};
+11
View File
@@ -353,6 +353,17 @@ export function useWebSocket() {
debouncedInvalidate('archiveStats');
break;
case 'print_confirm_request': {
// #1898: a completed print asked for its outcome verdict. Handed to
// the globally mounted ConfirmOutcomeDialog in Layout via the same
// CustomEvent relay plate_not_empty uses.
const detail = (message as unknown as { data?: { archive_id?: number; print_name?: string } }).data;
if (detail?.archive_id != null) {
window.dispatchEvent(new CustomEvent('print-confirm-request', { detail }));
}
break;
}
case 'kill_switch_triggered': {
const printer = message.printer_name || `Printer ${message.printer_id ?? '?'}`;
const filename = message.filename || t('common.unknown');
+56
View File
@@ -950,6 +950,8 @@ export default {
showFailedPrints: 'Fehlgeschlagene Drucke anzeigen',
hideFailedPrints: 'Fehlgeschlagene Drucke ausblenden',
hideDuplicates: 'Duplikate ausblenden',
filterUnconfirmed: 'Unbestätigt',
filterUnconfirmedTitle: 'Nur Drucke anzeigen, die noch auf ihre Ergebnis-Bewertung warten',
viewOriginalPrint: 'Klicken, um den ursprünglichen Druck anzuzeigen (#{{id}})',
printTime: 'Druckzeit',
filamentUsed: 'Verbrauchtes Filament',
@@ -1004,6 +1006,7 @@ export default {
slice: 'Slicen',
externalLink: 'Externer Link',
viewOnMakerWorld: 'Auf MakerWorld ansehen',
confirmOutcome: 'Ergebnis bestätigen',
preview3d: '3D-Vorschau',
viewTimelapse: 'Zeitraffer ansehen',
scanForTimelapse: 'Nach Zeitraffer suchen',
@@ -1065,6 +1068,12 @@ export default {
removeFromFavorites: 'Aus Favoriten entfernen',
cancelled: 'abgebrochen',
failed: 'fehlgeschlagen',
confirmPending: 'Ergebnis?',
confirmPendingTitle: 'Dieser Druck wartet auf eine Ergebnis-Bewertung — zum Antworten klicken',
rejected: 'Ausschuss',
rejectedTitle: 'Auf dem Drucker abgeschlossen, vom Benutzer als Ausschuss markiert',
confirmedGood: 'Gutteil',
confirmedGoodTitle: 'Vom Benutzer als Gutteil bestätigt',
duplicate: 'Duplikat',
duplicateTitle: 'Dieses Modell wurde bereits zuvor gedruckt',
openSource3mf: 'Quell-3MF in Bambu Studio öffnen (Rechtsklick für mehr Optionen)',
@@ -1484,6 +1493,8 @@ export default {
requiresPrevious: 'Erfordert vorherigen Erfolg',
autoPowerOff: 'Automatisch ausschalten',
gcodeInjection: 'G-Code',
confirmOutcome: 'Ergebnisabfrage',
confirmOutcomeTitle: 'Fragt nach Abschluss, wie der Druck geworden ist',
},
// Empty state
empty: {
@@ -1525,6 +1536,7 @@ export default {
layerInspection: 'Erste-Schicht-Prüfung',
timelapse: 'Zeitraffer',
useAms: 'AMS verwenden',
confirmOutcome: 'Nach Ergebnis fragen',
nozzleOffsetCali: 'Düsenversatz-Kalibrierung',
applyChanges: 'Änderungen übernehmen',
selectAll: 'Alle auswählen',
@@ -1669,6 +1681,8 @@ export default {
failureReasons: 'Fehlerursachen',
topFailureReasons: 'Häufigste Fehlerursachen',
failedPrintsCount: '{{failed}} / {{total}} Drucke fehlgeschlagen',
rejectedPrintsCount: '{{rejected}} nach Abschluss als Ausschuss markiert',
yieldRate: 'Ausbeute: {{rate}}%',
lastWeekRate: 'Letzte Woche: {{rate}}%',
// Actions
resetLayout: 'Layout zurücksetzen',
@@ -2317,6 +2331,12 @@ export default {
defaultLayerInspectDesc: 'KI-Inspektion der ersten Schicht',
defaultTimelapse: 'Zeitraffer',
defaultTimelapseDesc: 'Zeitraffervideo aufnehmen',
defaultConfirmOutcome: 'Nach Ergebnis fragen',
defaultConfirmOutcomeDesc: 'Nach Abschluss fragen, ob der Druck gut geworden ist',
confirmOutcomeExternalPrints: 'Auch bei Drucken fragen, die nicht über Bambuddy gestartet wurden',
confirmOutcomeExternalPrintsDesc: 'Drucke, die am Drucker, in Bambu Studio oder in der Handy-App gestartet wurden, landen ebenfalls im Archiv. Mit dieser Option bekommen sie dieselbe Ergebnisabfrage wie Aufträge aus der Warteschlange.',
confirmDefaultGoodOnPlateClear: 'Unbeantwortete Ergebnisse bei Druckplatten-Freigabe als gut zählen',
confirmDefaultGoodOnPlateClearDescription: 'Wenn die Druckplatte freigegeben wird (manuell oder durch den nächsten geplanten Druck) und die Ergebnisabfrage des Drucks noch unbeantwortet ist, wird er automatisch als Gutteil erfasst. Der Druck gilt dann als gut, sobald die Druckplatte freigegeben wird; eine Telegram- oder Link-Antwort danach zeigt nur noch das gespeicherte Ergebnis.',
defaultNozzleOffsetCali: 'Düsenversatz-Kalibrierung',
defaultNozzleOffsetCaliDesc: 'Düsenversatz zwischen Extrudern kalibrieren',
tempFanPresetsTitle: 'Temperatur- und Lüfter-Vorgaben',
@@ -5127,6 +5147,8 @@ export default {
insufficientBudget: 'Budget nicht ausreichend',
unlimitedNoBudget: 'Unbegrenzt – es ist kein Budgetlimit festgelegt.',
noPrintableCostCenters: 'Es ist keine aktive Kostenstelle zum Drucken verfügbar. Bitte einen Administrator, dir Druckzugriff zu gewähren.',
askForOutcome: 'Nach Ergebnis fragen',
askForOutcomeTitle: 'Nach Abschluss fragen, wie der Druck geworden ist',
},
// Backup
@@ -5445,6 +5467,32 @@ export default {
uploadFailed: 'Hochladen fehlgeschlagen',
},
confirmOutcome: {
title: 'Wie ist Ihr Druck geworden?',
good: 'Gut',
reject: 'Ausschuss',
later: 'Später fragen',
noPhoto: 'Kein Abschlussfoto verfügbar',
savedGood: 'Als Gutteil markiert',
savedReject: 'Als Ausschuss markiert',
saveFailed: 'Bewertung konnte nicht gespeichert werden',
rejectReason: 'Ausschussgrund (optional)',
noReason: 'Kein Grund',
saveReject: 'Speichern',
rejectAndReprint: 'Erneut drucken',
alreadyGood: 'Bereits als Gutteil bestätigt.',
alreadyRejected: 'Bereits als Ausschuss markiert.',
cardPrompt: 'Wie ist der Druck geworden?',
sources: {
dialog: 'In der App erfasst.',
link: 'Über einen Ein-Tipp-Link erfasst.',
plateClear: 'Bei der Freigabe der Druckplatte erfasst.',
printerCard: 'Über die Druckerkarte erfasst.',
api: 'Über die API erfasst.',
reaction: 'Über eine Reaktion im Chat erfasst.',
},
},
// Edit archive modal
editArchive: {
title: 'Archiv bearbeiten',
@@ -5471,6 +5519,12 @@ export default {
existingTags: 'Vorhandene Tags',
clickToAdd: '(zum Hinzufügen klicken)',
status: 'Status',
userVerdict: 'Ergebnis-Bewertung',
verdicts: {
none: 'Nicht festgelegt',
good: 'Gutteil',
reject: 'Ausschuss',
},
failureReason: 'Fehlergrund',
selectReason: 'Grund auswählen...',
photos: 'Fotos des Druckergebnisses',
@@ -6186,6 +6240,8 @@ export default {
plateNotEmptyDescription: 'Objekte vor dem Druck erkannt',
plateClearRequired: 'Platte freigeben',
plateClearRequiredDescription: 'Druck beendet, Warteschlange wartet auf Bestätigung',
printConfirmRequest: 'Ergebnis-Bestätigung',
printConfirmRequestDescription: 'Gut/Ausschuss-Bewertung mit Ein-Klick-Links anfordern, wenn ein dafür aktivierter Druck abgeschlossen wird',
plateClear: 'Platte freigeben',
printCompleted: 'Druck abgeschlossen',
bedCooledLabel: 'Bett abgekühlt',
+56
View File
@@ -955,6 +955,8 @@ export default {
showFailedPrints: 'Show failed prints',
hideFailedPrints: 'Hide failed prints',
hideDuplicates: 'Hide Duplicates',
filterUnconfirmed: 'Unconfirmed',
filterUnconfirmedTitle: 'Show only prints still waiting for their outcome verdict',
viewOriginalPrint: 'Click to view original print (#{{id}})',
printTime: 'Print Time',
filamentUsed: 'Filament Used',
@@ -1009,6 +1011,7 @@ export default {
slice: 'Slice',
externalLink: 'External Link',
viewOnMakerWorld: 'View on MakerWorld',
confirmOutcome: 'Confirm Outcome',
preview3d: '3D Preview',
viewTimelapse: 'View Timelapse',
scanForTimelapse: 'Scan for Timelapse',
@@ -1070,6 +1073,12 @@ export default {
removeFromFavorites: 'Remove from favorites',
cancelled: 'cancelled',
failed: 'failed',
confirmPending: 'outcome?',
confirmPendingTitle: 'This print asked for an outcome verdict — click to answer',
rejected: 'rejected',
rejectedTitle: 'Completed on the printer, marked as reject by the user',
confirmedGood: 'good',
confirmedGoodTitle: 'Confirmed as a good part by the user',
duplicate: 'duplicate',
duplicateTitle: 'This model has been printed before',
openSource3mf: 'Open source 3MF in Bambu Studio (right-click for more options)',
@@ -1500,6 +1509,8 @@ export default {
requiresPrevious: 'Requires previous success',
autoPowerOff: 'Auto power off',
gcodeInjection: 'G-code',
confirmOutcome: 'Outcome prompt',
confirmOutcomeTitle: 'Asks how the print came out after it completes',
},
// Empty state
empty: {
@@ -1541,6 +1552,7 @@ export default {
layerInspection: 'First layer inspection',
timelapse: 'Timelapse',
useAms: 'Use AMS',
confirmOutcome: 'Ask for outcome',
nozzleOffsetCali: 'Nozzle offset calibration',
applyChanges: 'Apply Changes',
selectAll: 'Select All',
@@ -1686,6 +1698,8 @@ export default {
failureReasons: 'Failure Reasons',
topFailureReasons: 'Top Failure Reasons',
failedPrintsCount: '{{failed}} / {{total}} prints failed',
rejectedPrintsCount: '{{rejected}} rejected after completion',
yieldRate: 'Yield: {{rate}}%',
lastWeekRate: 'Last week: {{rate}}%',
// Actions
resetLayout: 'Reset Layout',
@@ -2337,6 +2351,12 @@ export default {
defaultLayerInspectDesc: 'AI inspection of first layer',
defaultTimelapse: 'Timelapse',
defaultTimelapseDesc: 'Record timelapse video',
defaultConfirmOutcome: 'Ask for Outcome',
defaultConfirmOutcomeDesc: 'Ask whether the print came out well after it completes',
confirmOutcomeExternalPrints: 'Also ask for prints started outside Bambuddy',
confirmOutcomeExternalPrintsDesc: 'Prints started at the printer, in Bambu Studio or in the Handy app are archived by Bambuddy too. With this on, they get the same outcome prompt as queued prints.',
confirmDefaultGoodOnPlateClear: 'Count unanswered outcomes as good on plate release',
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.',
defaultNozzleOffsetCali: 'Nozzle Offset Calibration',
defaultNozzleOffsetCaliDesc: 'Calibrate nozzle offsets between extruders',
tempFanPresetsTitle: 'Temperature & Fan Presets',
@@ -5172,6 +5192,8 @@ export default {
insufficientBudget: 'Insufficient Budget',
unlimitedNoBudget: 'Unlimited – no budget limit is set.',
noPrintableCostCenters: 'No active cost center is available for printing. Ask an administrator to grant you print access.',
askForOutcome: 'Ask for Outcome',
askForOutcomeTitle: 'Ask how the print came out after it completes',
},
// Backup
@@ -5495,6 +5517,32 @@ export default {
uploadFailed: 'Upload failed',
},
// Post-print outcome confirmation dialog (#1898)
confirmOutcome: {
title: 'How did your print come out?',
good: 'Good',
reject: 'Reject',
later: 'Ask me later',
noPhoto: 'No finish photo available',
savedGood: 'Marked as good part',
savedReject: 'Marked as rejected',
saveFailed: 'Could not save the verdict',
rejectReason: 'Reject reason (optional)',
noReason: 'No reason',
saveReject: 'Save',
rejectAndReprint: 'Print again',
alreadyGood: 'Already confirmed as a good part.',
alreadyRejected: 'Already marked as rejected.',
cardPrompt: 'How did it come out?',
sources: {
dialog: 'Recorded in the app.',
link: 'Recorded with a one-tap link.',
plateClear: 'Recorded when the plate was cleared.',
printerCard: 'Recorded from the printer card.',
api: 'Recorded through the API.',
reaction: 'Recorded with a reaction in chat.',
},
},
// Edit archive modal
// Edit Archive Modal
editArchive: {
@@ -5522,6 +5570,12 @@ export default {
existingTags: 'Existing tags',
clickToAdd: '(click to add)',
status: 'Status',
userVerdict: 'Outcome Verdict',
verdicts: {
none: 'Not set',
good: 'Good part',
reject: 'Rejected',
},
failureReason: 'Failure Reason',
selectReason: 'Select reason...',
photos: 'Photos of Printed Result',
@@ -6237,6 +6291,8 @@ export default {
plateNotEmptyDescription: 'Objects detected before print',
plateClearRequired: 'Plate Clear Required',
plateClearRequiredDescription: 'Print finished, queue waits for plate confirmation',
printConfirmRequest: 'Outcome Confirmation',
printConfirmRequestDescription: 'Ask for a good/reject verdict with one-tap links when an opted-in print completes',
plateClear: 'Plate Clear',
printCompleted: 'Print Completed',
bedCooledLabel: 'Bed Cooled',
+56
View File
@@ -950,6 +950,8 @@ export default {
showFailedPrints: 'Mostrar impresiones fallidas',
hideFailedPrints: 'Ocultar impresiones fallidas',
hideDuplicates: 'Ocultar duplicados',
filterUnconfirmed: 'Sin confirmar',
filterUnconfirmedTitle: 'Mostrar solo las impresiones que aún esperan su veredicto de resultado',
viewOriginalPrint: 'Haga clic para ver la impresión original (n.º {{id}})',
printTime: 'Tiempo de impresión',
filamentUsed: 'Filamento usado',
@@ -1004,6 +1006,7 @@ export default {
slice: 'Laminar',
externalLink: 'Enlace externo',
viewOnMakerWorld: 'Ver en MakerWorld',
confirmOutcome: 'Confirmar resultado',
preview3d: 'Vista previa 3D',
viewTimelapse: 'Ver time-lapse',
scanForTimelapse: 'Buscar time-lapse',
@@ -1065,6 +1068,12 @@ export default {
removeFromFavorites: 'Quitar de favoritos',
cancelled: 'cancelada',
failed: 'fallida',
confirmPending: '¿resultado?',
confirmPendingTitle: 'Esta impresión solicitó un veredicto de resultado — haga clic para responder',
rejected: 'rechazada',
rejectedTitle: 'Completada en la impresora, marcada como rechazo por el usuario',
confirmedGood: 'buena',
confirmedGoodTitle: 'Confirmada como pieza buena por el usuario',
duplicate: 'duplicado',
duplicateTitle: 'Este modelo ya se ha impreso anteriormente',
openSource3mf: 'Abrir el 3MF de origen en Bambu Studio (clic derecho para más opciones)',
@@ -1484,6 +1493,8 @@ export default {
requiresPrevious: 'Requiere éxito previo',
autoPowerOff: 'Apagado automático',
gcodeInjection: 'G-code',
confirmOutcome: 'Consulta de resultado',
confirmOutcomeTitle: 'Pregunta cómo salió la impresión al terminar',
},
// Empty state
empty: {
@@ -1525,6 +1536,7 @@ export default {
layerInspection: 'Inspección de la primera capa',
timelapse: 'Time-lapse',
useAms: 'Usar AMS',
confirmOutcome: 'Preguntar el resultado',
nozzleOffsetCali: 'Calibración del offset de boquillas',
applyChanges: 'Aplicar cambios',
selectAll: 'Seleccionar todo',
@@ -1669,6 +1681,8 @@ export default {
failureReasons: 'Motivos de fallo',
topFailureReasons: 'Principales motivos de fallo',
failedPrintsCount: '{{failed}} / {{total}} impresiones fallidas',
rejectedPrintsCount: '{{rejected}} rechazadas tras completarse',
yieldRate: 'Rendimiento: {{rate}}%',
lastWeekRate: 'Semana pasada: {{rate}}%',
// Actions
resetLayout: 'Restablecer disposición',
@@ -2320,6 +2334,12 @@ export default {
defaultLayerInspectDesc: 'Inspección de la primera capa por IA',
defaultTimelapse: 'Time-lapse',
defaultTimelapseDesc: 'Grabar vídeo time-lapse',
defaultConfirmOutcome: 'Preguntar el resultado',
defaultConfirmOutcomeDesc: 'Preguntar si la impresión ha salido bien después de completarse',
confirmOutcomeExternalPrints: 'Preguntar también por impresiones iniciadas fuera de Bambuddy',
confirmOutcomeExternalPrintsDesc: 'Las impresiones iniciadas en la impresora, en Bambu Studio o en la app Handy también se archivan en Bambuddy. Con esta opción reciben la misma pregunta de resultado que los trabajos de la cola.',
confirmDefaultGoodOnPlateClear: 'Contar resultados sin responder como buenos al despejar la cama',
confirmDefaultGoodOnPlateClearDescription: 'Cuando se despeja la cama (manualmente o por la siguiente impresión en cola) y la pregunta sobre el resultado de la impresión sigue sin responder, registrarla automáticamente como pieza buena. La impresión cuenta entonces como buena en cuanto se despeja la cama; una respuesta posterior por Telegram o por enlace solo muestra el resultado ya registrado.',
defaultNozzleOffsetCali: 'Calibración del offset de boquillas',
defaultNozzleOffsetCaliDesc: 'Calibrar los desplazamientos entre boquillas',
tempFanPresetsTitle: 'Predefinidos de temperatura y ventilador',
@@ -5134,6 +5154,8 @@ export default {
insufficientBudget: 'Presupuesto insuficiente',
unlimitedNoBudget: 'Ilimitado: no se ha establecido ningún límite de presupuesto.',
noPrintableCostCenters: 'No hay ningún centro de costes activo disponible para imprimir. Pide a un administrador que te conceda acceso de impresión.',
askForOutcome: 'Preguntar el resultado',
askForOutcomeTitle: 'Preguntar cómo salió la impresión al terminar',
},
// Backup
@@ -5452,6 +5474,32 @@ export default {
uploadFailed: 'Error al subir',
},
confirmOutcome: {
title: '¿Cómo ha salido su impresión?',
good: 'Buena',
reject: 'Rechazar',
later: 'Preguntar más tarde',
noPhoto: 'No hay foto del acabado disponible',
savedGood: 'Marcada como pieza buena',
savedReject: 'Marcada como rechazada',
saveFailed: 'No se pudo guardar el veredicto',
rejectReason: 'Motivo del rechazo (opcional)',
noReason: 'Sin motivo',
saveReject: 'Guardar',
rejectAndReprint: 'Imprimir de nuevo',
alreadyGood: 'Ya está confirmada como pieza buena.',
alreadyRejected: 'Ya está marcada como rechazada.',
cardPrompt: '¿Cómo ha salido?',
sources: {
dialog: 'Registrado en la aplicación.',
link: 'Registrado con un enlace de un toque.',
plateClear: 'Registrado al despejar la cama.',
printerCard: 'Registrado desde la tarjeta de la impresora.',
api: 'Registrado a través de la API.',
reaction: 'Registrado con una reacción en el chat.',
},
},
// Edit archive modal
// Edit Archive Modal
editArchive: {
@@ -5479,6 +5527,12 @@ export default {
existingTags: 'Etiquetas existentes',
clickToAdd: '(haga clic para añadir)',
status: 'Estado',
userVerdict: 'Veredicto del resultado',
verdicts: {
none: 'Sin establecer',
good: 'Pieza buena',
reject: 'Rechazada',
},
failureReason: 'Motivo del fallo',
selectReason: 'Seleccionar motivo...',
photos: 'Fotos del resultado impreso',
@@ -6194,6 +6248,8 @@ export default {
plateNotEmptyDescription: 'Objetos detectados antes de la impresión',
plateClearRequired: 'Confirmar cama libre',
plateClearRequiredDescription: 'Impresión terminada, la cola espera confirmación',
printConfirmRequest: 'Confirmación de resultado',
printConfirmRequestDescription: 'Solicitar un veredicto de buena/rechazo con enlaces de un solo toque cuando se complete una impresión que lo tenga activado',
plateClear: 'Cama libre',
printCompleted: 'Impresión completada',
bedCooledLabel: 'Cama enfriada',
+56
View File
@@ -950,6 +950,8 @@ export default {
showFailedPrints: 'Afficher les échecs',
hideFailedPrints: 'Masquer les échecs',
hideDuplicates: 'Masquer les doublons',
filterUnconfirmed: 'Non confirmées',
filterUnconfirmedTitle: 'Afficher uniquement les impressions en attente de leur verdict de résultat',
viewOriginalPrint: 'Cliquez pour afficher l\'impression originale (#{{id}})',
printTime: 'Temps d\'impression',
filamentUsed: 'Filament utilisé',
@@ -1004,6 +1006,7 @@ export default {
slice: 'Découper',
externalLink: 'Lien externe',
viewOnMakerWorld: 'Voir sur MakerWorld',
confirmOutcome: 'Confirmer le résultat',
preview3d: 'Aperçu 3D',
viewTimelapse: 'Voir le Timelapse',
scanForTimelapse: 'Scanner pour Timelapse',
@@ -1065,6 +1068,12 @@ export default {
removeFromFavorites: 'Retirer des favoris',
cancelled: 'annulé',
failed: 'échoué',
confirmPending: 'résultat ?',
confirmPendingTitle: 'Cette impression attend un verdict de résultat — cliquez pour répondre',
rejected: 'rebuté',
rejectedTitle: 'Terminée sur l\'imprimante, marquée comme rebut par l\'utilisateur',
confirmedGood: 'réussi',
confirmedGoodTitle: 'Confirmée comme pièce réussie par l\'utilisateur',
duplicate: 'doublon',
duplicateTitle: 'Ce modèle a déjà été imprimé',
openSource3mf: 'Ouvrir 3MF dans Bambu Studio (clic droit pour plus)',
@@ -1484,6 +1493,8 @@ export default {
requiresPrevious: 'Nécessite succès précédent',
autoPowerOff: 'Extinction auto',
gcodeInjection: 'G-code',
confirmOutcome: 'Demande de résultat',
confirmOutcomeTitle: 'Demande comment l’impression s’est déroulée une fois terminée',
},
// Empty state
empty: {
@@ -1525,6 +1536,7 @@ export default {
layerInspection: 'Inspection 1ère couche',
timelapse: 'Time-lapse',
useAms: 'Utiliser AMS',
confirmOutcome: 'Demander le résultat',
nozzleOffsetCali: 'Calibration du décalage des buses',
applyChanges: 'Appliquer',
selectAll: 'Tout sélectionner',
@@ -1669,6 +1681,8 @@ export default {
failureReasons: 'Raisons des échecs',
topFailureReasons: 'Top raisons d\'échec',
failedPrintsCount: '{{failed}} / {{total}} impressions ont échoué',
rejectedPrintsCount: '{{rejected}} rebutées après achèvement',
yieldRate: 'Rendement : {{rate}}%',
lastWeekRate: 'Semaine dernière : {{rate}}%',
// Actions
resetLayout: 'Réinitialiser la mise en page',
@@ -2273,6 +2287,12 @@ export default {
defaultLayerInspectDesc: 'Inspection IA de la première couche',
defaultTimelapse: 'Time-lapse',
defaultTimelapseDesc: 'Enregistrer une vidéo timelapse',
defaultConfirmOutcome: 'Demander le résultat',
defaultConfirmOutcomeDesc: 'Demander si l\'impression est bien sortie une fois terminée',
confirmOutcomeExternalPrints: 'Demander aussi pour les impressions lancées hors de Bambuddy',
confirmOutcomeExternalPrintsDesc: 'Les impressions lancées sur l\'imprimante, dans Bambu Studio ou dans l\'application Handy sont elles aussi archivées par Bambuddy. Avec cette option, elles reçoivent la même demande de résultat que les travaux de la file d\'attente.',
confirmDefaultGoodOnPlateClear: 'Compter les résultats sans réponse comme bons à la libération du plateau',
confirmDefaultGoodOnPlateClearDescription: 'Lorsque le plateau est libéré (manuellement ou par l\'impression suivante en file d\'attente) et que la demande de résultat de l\'impression reste sans réponse, l\'enregistrer automatiquement comme bonne pièce. Une fois le plateau libéré, la pièce compte comme bonne ; une réponse ultérieure par Telegram ou par lien affiche seulement le résultat enregistré.',
defaultNozzleOffsetCali: 'Calibration du décalage des buses',
defaultNozzleOffsetCaliDesc: 'Calibrer les décalages entre les buses',
tempFanPresetsTitle: 'Préréglages de température et de ventilateur',
@@ -5116,6 +5136,8 @@ export default {
insufficientBudget: 'Budget insuffisant',
unlimitedNoBudget: 'Illimité – aucune limite de budget n’est définie.',
noPrintableCostCenters: 'Aucun centre de coûts actif n’est disponible pour l’impression. Demandez à un administrateur de vous accorder l’accès à l’impression.',
askForOutcome: 'Demander le résultat',
askForOutcomeTitle: 'Demander comment l’impression s’est déroulée une fois terminée',
},
// Backup
@@ -5434,6 +5456,32 @@ export default {
uploadFailed: 'Échec du téléversement',
},
confirmOutcome: {
title: 'Comment votre impression est-elle sortie ?',
good: 'Bonne',
reject: 'Rebut',
later: 'Me redemander plus tard',
noPhoto: 'Aucune photo de fin disponible',
savedGood: 'Marquée comme bonne pièce',
savedReject: 'Marquée comme rebut',
saveFailed: 'Impossible d\'enregistrer le verdict',
rejectReason: 'Raison du rebut (facultatif)',
noReason: 'Aucune raison',
saveReject: 'Enregistrer',
rejectAndReprint: 'Réimprimer',
alreadyGood: 'Déjà confirmée comme bonne pièce.',
alreadyRejected: 'Déjà marquée comme rebut.',
cardPrompt: 'Comment est-elle sortie ?',
sources: {
dialog: 'Enregistré depuis Bambuddy.',
link: 'Enregistré via un lien rapide.',
plateClear: 'Enregistré lors de la libération du plateau.',
printerCard: 'Enregistré depuis la carte imprimante.',
api: 'Enregistré par API.',
reaction: 'Enregistré par une réaction dans le chat.',
},
},
// Edit archive modal
// Edit Archive Modal
editArchive: {
@@ -5461,6 +5509,12 @@ export default {
existingTags: 'Tags existants',
clickToAdd: '(cliquer pour ajouter)',
status: 'Statut',
userVerdict: 'Verdict de résultat',
verdicts: {
none: 'Non défini',
good: 'Bonne pièce',
reject: 'Rebutée',
},
failureReason: 'Raison de l\'échec',
selectReason: 'Choisir raison...',
photos: 'Photos du résultat',
@@ -6176,6 +6230,8 @@ export default {
plateNotEmptyDescription: 'Objets détectés avant l\'impression',
plateClearRequired: 'Plateau à libérer',
plateClearRequiredDescription: 'Impression terminée, la file attend la confirmation',
printConfirmRequest: 'Confirmation du résultat',
printConfirmRequestDescription: 'Demander un verdict bon/rebut avec des liens en un clic à la fin d\'une impression ayant activé l\'option',
plateClear: 'Plateau libre',
printCompleted: 'Impression terminée',
bedCooledLabel: 'Plateau refroidi',
+56
View File
@@ -950,6 +950,8 @@ export default {
showFailedPrints: 'Mostra stampe fallite',
hideFailedPrints: 'Nascondi stampe fallite',
hideDuplicates: 'Nascondi duplicati',
filterUnconfirmed: 'Non confermate',
filterUnconfirmedTitle: 'Mostra solo le stampe ancora in attesa del verdetto sull\'esito',
viewOriginalPrint: 'Fai clic per visualizzare la stampa originale (#{{id}})',
printTime: 'Tempo di stampa',
filamentUsed: 'Filamento usato',
@@ -1004,6 +1006,7 @@ export default {
slice: 'Slice',
externalLink: 'Link esterno',
viewOnMakerWorld: 'Vedi su MakerWorld',
confirmOutcome: 'Conferma esito',
preview3d: 'Anteprima 3D',
viewTimelapse: 'Vedi Timelapse',
scanForTimelapse: 'Cerca Timelapse',
@@ -1065,6 +1068,12 @@ export default {
removeFromFavorites: 'Rimuovi dai preferiti',
cancelled: 'annullato',
failed: 'fallito',
confirmPending: 'esito?',
confirmPendingTitle: 'Questa stampa ha richiesto un verdetto sull\'esito — clicca per rispondere',
rejected: 'scartato',
rejectedTitle: 'Completata sulla stampante, contrassegnata come scarto dall\'utente',
confirmedGood: 'riuscito',
confirmedGoodTitle: 'Confermato come pezzo riuscito dall\'utente',
duplicate: 'duplicato',
duplicateTitle: 'Questo modello è stato stampato prima',
openSource3mf: 'Apri sorgente 3MF in Bambu Studio (clic destro per altre opzioni)',
@@ -1484,6 +1493,8 @@ export default {
requiresPrevious: 'Richiede successo precedente',
autoPowerOff: 'Spegnimento automatico',
gcodeInjection: 'G-code',
confirmOutcome: 'Richiesta esito',
confirmOutcomeTitle: 'Chiede com’è venuta la stampa una volta completata',
},
// Empty state
empty: {
@@ -1525,6 +1536,7 @@ export default {
layerInspection: 'Controllo primo layer',
timelapse: 'Timelapse',
useAms: 'Usa AMS',
confirmOutcome: 'Chiedi l’esito',
nozzleOffsetCali: 'Calibrazione offset ugelli',
applyChanges: 'Applica modifiche',
selectAll: 'Seleziona tutto',
@@ -1669,6 +1681,8 @@ export default {
failureReasons: 'Cause guasto',
topFailureReasons: 'Cause principali',
failedPrintsCount: '{{failed}} / {{total}} stampe fallite',
rejectedPrintsCount: '{{rejected}} scartate dopo il completamento',
yieldRate: 'Resa: {{rate}}%',
lastWeekRate: 'Settimana scorsa: {{rate}}%',
// Actions
resetLayout: 'Reimposta layout',
@@ -2273,6 +2287,12 @@ export default {
defaultLayerInspectDesc: 'Ispezione IA del primo strato',
defaultTimelapse: 'Timelapse',
defaultTimelapseDesc: 'Registra un video timelapse',
defaultConfirmOutcome: 'Chiedi esito',
defaultConfirmOutcomeDesc: 'Chiedi se la stampa è venuta bene una volta completata',
confirmOutcomeExternalPrints: 'Chiedi anche per le stampe avviate fuori da Bambuddy',
confirmOutcomeExternalPrintsDesc: 'Le stampe avviate sulla stampante, in Bambu Studio o nell\'app Handy vengono archiviate anche da Bambuddy. Con questa opzione ricevono la stessa richiesta di esito dei lavori in coda.',
confirmDefaultGoodOnPlateClear: 'Conta gli esiti senza risposta come buoni alla liberazione del piatto',
confirmDefaultGoodOnPlateClearDescription: 'Quando il piatto viene liberato (manualmente o dalla stampa successiva in coda) e la richiesta di esito della stampa è ancora senza risposta, registra automaticamente il pezzo come buono. La stampa vale come buona non appena il piatto viene liberato; una risposta successiva via Telegram o tramite link mostra solo il risultato registrato.',
defaultNozzleOffsetCali: 'Calibrazione offset ugelli',
defaultNozzleOffsetCaliDesc: 'Calibra gli offset tra gli ugelli',
tempFanPresetsTitle: 'Preset di temperatura e ventola',
@@ -5115,6 +5135,8 @@ export default {
insufficientBudget: 'Budget insufficiente',
unlimitedNoBudget: 'Illimitato – non è impostato alcun limite di budget.',
noPrintableCostCenters: 'Non è disponibile alcun centro di costo attivo per la stampa. Chiedi a un amministratore di concederti l’accesso alla stampa.',
askForOutcome: 'Chiedi l’esito',
askForOutcomeTitle: 'Chiedi com’è venuta la stampa una volta completata',
},
// Backup
@@ -5433,6 +5455,32 @@ export default {
uploadFailed: 'Caricamento fallito',
},
confirmOutcome: {
title: 'Com\'è venuta la tua stampa?',
good: 'Buona',
reject: 'Scarto',
later: 'Chiedimelo più tardi',
noPhoto: 'Nessuna foto finale disponibile',
savedGood: 'Contrassegnata come pezzo buono',
savedReject: 'Contrassegnata come scartata',
saveFailed: 'Impossibile salvare il verdetto',
rejectReason: 'Motivo dello scarto (opzionale)',
noReason: 'Nessun motivo',
saveReject: 'Salva',
rejectAndReprint: 'Stampa di nuovo',
alreadyGood: 'Già confermata come pezzo buono.',
alreadyRejected: 'Già contrassegnata come scartata.',
cardPrompt: 'Com\'è venuta?',
sources: {
dialog: 'Registrato in Bambuddy.',
link: 'Registrato con un link rapido.',
plateClear: 'Registrato alla liberazione del piatto.',
printerCard: 'Registrato dalla scheda stampante.',
api: 'Registrato tramite API.',
reaction: 'Registrato con una reazione in chat.',
},
},
// Edit archive modal
// Edit Archive Modal
editArchive: {
@@ -5460,6 +5508,12 @@ export default {
existingTags: 'Tag esistenti',
clickToAdd: '(clicca per aggiungere)',
status: 'Stato',
userVerdict: 'Verdetto esito',
verdicts: {
none: 'Non impostato',
good: 'Pezzo buono',
reject: 'Scartato',
},
failureReason: 'Motivo fallimento',
selectReason: 'Seleziona motivo...',
photos: 'Foto del risultato stampato',
@@ -6175,6 +6229,8 @@ export default {
plateNotEmptyDescription: 'Oggetti rilevati prima della stampa',
plateClearRequired: 'Conferma piatto libero',
plateClearRequiredDescription: 'Stampa finita, la coda attende conferma',
printConfirmRequest: 'Conferma esito',
printConfirmRequestDescription: 'Chiedi un verdetto buona/scarto con link a un tocco quando si completa una stampa con l\'opzione attiva',
plateClear: 'Piatto libero',
printCompleted: 'Stampa completata',
bedCooledLabel: 'Piatto raffreddato',
+56
View File
@@ -949,6 +949,8 @@ export default {
showFailedPrints: '失敗した印刷を表示',
hideFailedPrints: '失敗した印刷を非表示',
hideDuplicates: '重複を非表示',
filterUnconfirmed: '未確認',
filterUnconfirmedTitle: '結果の判定待ちの印刷のみを表示',
viewOriginalPrint: 'クリックして元の印刷を表示 (#{{id}})',
printTime: '印刷時間',
filamentUsed: 'フィラメント使用量',
@@ -1003,6 +1005,7 @@ export default {
slice: 'スライス',
externalLink: '外部リンク',
viewOnMakerWorld: 'MakerWorldで表示',
confirmOutcome: '結果を確認',
preview3d: '3Dプレビュー',
viewTimelapse: 'タイムラプスを表示',
scanForTimelapse: 'タイムラプスをスキャン',
@@ -1064,6 +1067,12 @@ export default {
removeFromFavorites: 'お気に入りから削除',
cancelled: 'キャンセル',
failed: '失敗',
confirmPending: '結果は?',
confirmPendingTitle: 'この印刷は結果の判定を求めています — クリックして回答',
rejected: '不良',
rejectedTitle: 'プリンターでは完了しましたが、ユーザーにより不良と判定されました',
confirmedGood: '良品',
confirmedGoodTitle: 'ユーザーにより良品と確認されました',
duplicate: '重複',
duplicateTitle: 'このモデルは以前印刷されています',
openSource3mf: 'ソース3MFをBambu Studioで開く(右クリックでオプション表示)',
@@ -1483,6 +1492,8 @@ export default {
requiresPrevious: '前の成功が必要',
autoPowerOff: '自動電源オフ',
gcodeInjection: 'G-code',
confirmOutcome: '結果確認',
confirmOutcomeTitle: '完了後に印刷の仕上がりを確認します',
},
// Empty state
empty: {
@@ -1524,6 +1535,7 @@ export default {
layerInspection: '第一層検査',
timelapse: 'タイムラプス',
useAms: 'AMS使用',
confirmOutcome: '結果を確認する',
nozzleOffsetCali: 'ノズルオフセットキャリブレーション',
applyChanges: '変更を適用',
selectAll: 'すべて選択',
@@ -1668,6 +1680,8 @@ export default {
failureReasons: '失敗理由',
topFailureReasons: '主な失敗理由',
failedPrintsCount: '{{failed}} / {{total}} 件の印刷が失敗',
rejectedPrintsCount: '完了後に {{rejected}} 件が不良と判定',
yieldRate: '歩留まり: {{rate}}%',
lastWeekRate: '先週: {{rate}}%',
// Actions
resetLayout: 'レイアウトをリセット',
@@ -2316,6 +2330,12 @@ export default {
defaultLayerInspectDesc: 'AIによる第1層の検査',
defaultTimelapse: 'タイムラプス',
defaultTimelapseDesc: 'タイムラプス動画を記録',
defaultConfirmOutcome: '結果の確認',
defaultConfirmOutcomeDesc: '印刷完了後に仕上がりの良否を確認',
confirmOutcomeExternalPrints: 'Bambuddy 以外で開始した印刷にも確認する',
confirmOutcomeExternalPrintsDesc: 'プリンター本体、Bambu Studio、Handy アプリから開始した印刷も Bambuddy がアーカイブします。この設定をオンにすると、キューの印刷と同じ結果確認が届きます。',
confirmDefaultGoodOnPlateClear: 'プレートクリア時に未回答の結果を良品として記録',
confirmDefaultGoodOnPlateClearDescription: 'プレートがクリアされた際(手動またはキューの次の印刷による)、その印刷の結果確認が未回答の場合、自動的に良品として記録します。プレートがクリアされた時点でその印刷は良品として扱われ、その後に Telegram やリンクから回答しても、記録済みの結果が表示されるだけです。',
defaultNozzleOffsetCali: 'ノズルオフセットキャリブレーション',
defaultNozzleOffsetCaliDesc: 'エクストルーダー間のノズルオフセットを校正',
tempFanPresetsTitle: '温度・ファンプリセット',
@@ -5127,6 +5147,8 @@ export default {
insufficientBudget: '予算が不足しています',
unlimitedNoBudget: '無制限 – 予算上限は設定されていません。',
noPrintableCostCenters: '印刷に利用できる有効なコストセンターがありません。管理者に印刷アクセスの付与を依頼してください。',
askForOutcome: '結果を確認する',
askForOutcomeTitle: '完了後に印刷の仕上がりを尋ねます',
},
// Backup
@@ -5445,6 +5467,32 @@ export default {
uploadFailed: 'アップロード失敗',
},
confirmOutcome: {
title: '印刷の仕上がりはいかがでしたか?',
good: '良品',
reject: '不良',
later: '後で確認',
noPhoto: '完成写真がありません',
savedGood: '良品として記録しました',
savedReject: '不良として記録しました',
saveFailed: '判定を保存できませんでした',
rejectReason: '不良理由(任意)',
noReason: '理由なし',
saveReject: '保存',
rejectAndReprint: 'もう一度印刷',
alreadyGood: 'すでに良品として確認済みです。',
alreadyRejected: 'すでに不良として記録済みです。',
cardPrompt: '仕上がりはどうでしたか?',
sources: {
dialog: 'アプリで記録されました。',
link: 'ワンタップリンクで記録されました。',
plateClear: 'プレートのクリア時に記録されました。',
printerCard: 'プリンターカードから記録されました。',
api: 'API 経由で記録されました。',
reaction: 'チャットのリアクションで記録されました。',
},
},
// Edit archive modal
// Edit Archive Modal
editArchive: {
@@ -5472,6 +5520,12 @@ export default {
existingTags: '既存のタグ',
clickToAdd: '(クリックして追加)',
status: 'ステータス',
userVerdict: '結果判定',
verdicts: {
none: '未設定',
good: '良品',
reject: '不良',
},
failureReason: '失敗理由',
selectReason: '理由を選択...',
photos: '印刷結果の写真',
@@ -6187,6 +6241,8 @@ export default {
plateNotEmptyDescription: '印刷前にオブジェクトが検出されました',
plateClearRequired: 'プレート確認が必要',
plateClearRequiredDescription: '印刷完了、キューはプレート確認を待機中',
printConfirmRequest: '結果確認',
printConfirmRequestDescription: '確認を有効にした印刷の完了時に、ワンタップリンクで良品/不良の判定を求める',
plateClear: 'プレート確認',
printCompleted: '印刷完了',
bedCooledLabel: 'ベッド冷却済み',
+56 -1
View File
@@ -906,6 +906,8 @@ export default {
showFailedPrints: '실패한 인쇄 표시',
hideFailedPrints: '실패한 인쇄 숨기기',
hideDuplicates: '중복 숨기기',
filterUnconfirmed: '미확인',
filterUnconfirmedTitle: '결과 판정을 기다리는 인쇄만 표시',
viewOriginalPrint: '원본 인쇄 보기 클릭 (#{{id}})',
printTime: '인쇄 시간',
filamentUsed: '사용된 필라멘트',
@@ -960,6 +962,7 @@ export default {
slice: '슬라이스',
externalLink: '외부 링크',
viewOnMakerWorld: 'MakerWorld에서 보기',
confirmOutcome: '결과 확인',
preview3d: '3D 미리보기',
viewTimelapse: '타임랩스 보기',
scanForTimelapse: '타임랩스 검색',
@@ -1021,6 +1024,12 @@ export default {
removeFromFavorites: '즐겨찾기에서 제거',
cancelled: '취소됨',
failed: '실패',
confirmPending: '결과는?',
confirmPendingTitle: '이 인쇄는 결과 판정을 요청했습니다 — 클릭하여 답변하세요',
rejected: '불량',
rejectedTitle: '프린터에서는 완료되었지만 사용자가 불량으로 표시함',
confirmedGood: '양품',
confirmedGoodTitle: '사용자가 양품으로 확인함',
duplicate: '중복',
duplicateTitle: '이 모델은 이전에 인쇄된 적이 있습니다',
openSource3mf: 'Bambu Studio에서 소스 3MF 열기 (더 많은 옵션은 우클릭)',
@@ -1414,7 +1423,9 @@ export default {
staged: '준비됨',
requiresPrevious: '이전 성공 필요',
autoPowerOff: '자동 전원 끄기',
gcodeInjection: 'G코드'
gcodeInjection: 'G코드',
confirmOutcome: '결과 확인',
confirmOutcomeTitle: '완료 후 출력 결과가 어땠는지 물어봅니다',
},
empty: {
title: '예약된 인쇄 없음',
@@ -1452,6 +1463,7 @@ export default {
layerInspection: '첫 번째 층 검사',
timelapse: '타임랩스',
useAms: 'AMS 사용',
confirmOutcome: '결과 묻기',
nozzleOffsetCali: '노즐 오프셋 보정',
applyChanges: '변경사항 적용',
selectAll: '전체 선택',
@@ -1602,6 +1614,8 @@ export default {
failureReasons: '실패 원인',
topFailureReasons: '주요 실패 원인',
failedPrintsCount: '{{total}}개 인쇄 중 {{failed}}개 실패',
rejectedPrintsCount: '완료 후 불량 처리 {{rejected}}개',
yieldRate: '수율: {{rate}}%',
lastWeekRate: '지난 주: {{rate}}%',
resetLayout: '레이아웃 초기화',
recalculateCosts: '비용 재계산',
@@ -2197,6 +2211,12 @@ export default {
defaultLayerInspectDesc: '첫 번째 층 AI 검사',
defaultTimelapse: '타임랩스',
defaultTimelapseDesc: '타임랩스 영상 녹화',
defaultConfirmOutcome: '결과 확인 요청',
defaultConfirmOutcomeDesc: '인쇄 완료 후 결과가 잘 나왔는지 물어봅니다',
confirmOutcomeExternalPrints: 'Bambuddy 외부에서 시작한 인쇄도 확인',
confirmOutcomeExternalPrintsDesc: '프린터, Bambu Studio 또는 Handy 앱에서 시작한 인쇄도 Bambuddy에 보관됩니다. 이 옵션을 켜면 대기열 인쇄와 동일한 결과 확인을 받습니다.',
confirmDefaultGoodOnPlateClear: '플레이트 비움 시 미응답 결과를 양품으로 기록',
confirmDefaultGoodOnPlateClearDescription: '플레이트 비움이 확인될 때(수동 또는 다음 대기열 인쇄에 의해) 해당 인쇄의 결과 확인이 아직 응답되지 않았다면 자동으로 양품으로 기록합니다. 플레이트를 비우는 순간 해당 인쇄는 양품으로 처리되며, 그 뒤에 텔레그램이나 링크로 답해도 이미 기록된 결과만 표시됩니다.',
defaultNozzleOffsetCali: '노즐 오프셋 보정',
defaultNozzleOffsetCaliDesc: '익스트루더 간 노즐 오프셋 보정',
tempFanPresetsTitle: '온도 및 팬 프리셋',
@@ -4897,6 +4917,8 @@ export default {
insufficientBudget: '예산 부족',
unlimitedNoBudget: '무제한 – 예산 한도가 설정되지 않았습니다.',
noPrintableCostCenters: '인쇄에 사용할 수 있는 활성 비용 센터가 없습니다. 관리자에게 인쇄 권한을 요청하세요.',
askForOutcome: '결과 묻기',
askForOutcomeTitle: '완료 후 출력 결과가 어땠는지 물어보기',
},
backup: {
includesEncryptionKey: '로컬 백업에는 MFA 암호화 키 파일(DATA_DIR/.mfa_encryption_key)이 포함되어 백업 ZIP이 자체 완결됩니다. ZIP 파일을 민감하게 취급하세요 — 파일을 가진 누구나 내부에 저장된 OIDC 클라이언트 비밀과 TOTP 비밀을 복호화할 수 있습니다.',
@@ -5196,6 +5218,31 @@ export default {
upload: '업로드',
uploadFailed: '업로드 실패'
},
confirmOutcome: {
title: '인쇄 결과가 어땠나요?',
good: '양품',
reject: '불량',
later: '나중에 묻기',
noPhoto: '완료 사진 없음',
savedGood: '양품으로 표시됨',
savedReject: '불량으로 표시됨',
saveFailed: '판정을 저장할 수 없습니다',
rejectReason: '불량 사유 (선택 사항)',
noReason: '사유 없음',
saveReject: '저장',
rejectAndReprint: '다시 인쇄',
alreadyGood: '이미 양품으로 확인되었습니다.',
alreadyRejected: '이미 불량으로 표시되었습니다.',
cardPrompt: '결과가 어땠나요?',
sources: {
dialog: '앱에서 기록되었습니다.',
link: '원탭 링크로 기록되었습니다.',
plateClear: '플레이트를 비울 때 기록되었습니다.',
printerCard: '프린터 카드에서 기록되었습니다.',
api: 'API를 통해 기록되었습니다.',
reaction: '채팅 반응으로 기록되었습니다.',
},
},
editArchive: {
title: '아카이브 편집',
name: '이름',
@@ -5221,6 +5268,12 @@ export default {
existingTags: '기존 태그',
clickToAdd: '(클릭하여 추가)',
status: '상태',
userVerdict: '결과 판정',
verdicts: {
none: '설정 안 됨',
good: '양품',
reject: '불량',
},
failureReason: '실패 원인',
selectReason: '원인 선택...',
photos: '인쇄 결과 사진',
@@ -5905,6 +5958,8 @@ export default {
plateNotEmptyDescription: '인쇄 전 개체 감지됨',
plateClearRequired: '플레이트 비움 확인 필요',
plateClearRequiredDescription: '인쇄 완료, 대기열이 플레이트 확인을 기다림',
printConfirmRequest: '결과 확인',
printConfirmRequestDescription: '참여 설정된 인쇄가 완료되면 원탭 링크로 양품/불량 판정을 요청합니다',
plateClear: '플레이트 확인',
printCompleted: '인쇄 완료됨',
bedCooledLabel: '베드 냉각됨',
+56
View File
@@ -955,6 +955,8 @@ export default {
showFailedPrints: 'Mislukte afdrukken tonen',
hideFailedPrints: 'Mislukte afdrukken verbergen',
hideDuplicates: 'Duplicaten verbergen',
filterUnconfirmed: 'Onbevestigd',
filterUnconfirmedTitle: 'Alleen afdrukken tonen die nog op hun resultaatoordeel wachten',
viewOriginalPrint: 'Klik om originele afdruk te bekijken (#{{id}})',
printTime: 'Afdruktijd',
filamentUsed: 'Filament gebruikt',
@@ -1009,6 +1011,7 @@ export default {
slice: 'Slicen',
externalLink: 'Externe link',
viewOnMakerWorld: 'Bekijken op MakerWorld',
confirmOutcome: 'Resultaat bevestigen',
preview3d: '3D-voorbeeld',
viewTimelapse: 'Timelapse bekijken',
scanForTimelapse: 'Zoeken naar timelapse',
@@ -1070,6 +1073,12 @@ export default {
removeFromFavorites: 'Uit favorieten verwijderen',
cancelled: 'geannuleerd',
failed: 'mislukt',
confirmPending: 'resultaat?',
confirmPendingTitle: 'Deze afdruk vroeg om een resultaatoordeel — klik om te antwoorden',
rejected: 'afgekeurd',
rejectedTitle: 'Voltooid op de printer, door de gebruiker als afkeur gemarkeerd',
confirmedGood: 'goedgekeurd',
confirmedGoodTitle: 'Door de gebruiker bevestigd als een goed onderdeel',
duplicate: 'duplicaat',
duplicateTitle: 'Dit model is eerder afgedrukt',
openSource3mf: 'Bron-3MF openen in Bambu Studio (klik met rechts voor meer opties)',
@@ -1500,6 +1509,8 @@ export default {
requiresPrevious: 'Vereist dat vorige taak slaagt',
autoPowerOff: 'Automatisch uitschakelen',
gcodeInjection: 'G-code',
confirmOutcome: 'Resultaatvraag',
confirmOutcomeTitle: 'Vraagt na afloop hoe de print is geworden',
},
// Empty state
empty: {
@@ -1541,6 +1552,7 @@ export default {
layerInspection: 'Inspectie eerste laag',
timelapse: 'Timelapse',
useAms: 'AMS gebruiken',
confirmOutcome: 'Vragen naar resultaat',
nozzleOffsetCali: 'Nozzle-offsetkalibratie',
applyChanges: 'Wijzigingen toepassen',
selectAll: 'Alles selecteren',
@@ -1686,6 +1698,8 @@ export default {
failureReasons: 'Oorzaken van fouten',
topFailureReasons: 'Meest voorkomende foutoorzaken',
failedPrintsCount: '{{failed}} / {{total}} afdrukken mislukt',
rejectedPrintsCount: '{{rejected}} afgekeurd na voltooiing',
yieldRate: 'Opbrengst: {{rate}}%',
lastWeekRate: 'Vorige week: {{rate}}%',
// Actions
resetLayout: 'Indeling resetten',
@@ -2337,6 +2351,12 @@ export default {
defaultLayerInspectDesc: 'AI-inspectie van de eerste laag',
defaultTimelapse: 'Timelapse',
defaultTimelapseDesc: 'Timelapsevideo opnemen',
defaultConfirmOutcome: 'Vragen om resultaat',
defaultConfirmOutcomeDesc: 'Vragen of de afdruk goed is gelukt nadat deze is voltooid',
confirmOutcomeExternalPrints: 'Ook vragen bij prints die buiten Bambuddy zijn gestart',
confirmOutcomeExternalPrintsDesc: 'Prints die op de printer, in Bambu Studio of in de Handy-app zijn gestart, worden ook door Bambuddy gearchiveerd. Met deze optie krijgen ze dezelfde resultaatvraag als prints uit de wachtrij.',
confirmDefaultGoodOnPlateClear: 'Onbeantwoorde resultaten als goed tellen bij plaatvrijgave',
confirmDefaultGoodOnPlateClearDescription: 'Wanneer de plaat wordt vrijgegeven (handmatig of door de volgende afdruk in de wachtrij) en de resultaatvraag van de afdruk nog onbeantwoord is, wordt deze automatisch als goed onderdeel geregistreerd. De afdruk geldt dan als goed zodra de plaat wordt vrijgegeven; een antwoord via Telegram of link daarna toont alleen nog het opgeslagen resultaat.',
defaultNozzleOffsetCali: 'Nozzle-offsetkalibratie',
defaultNozzleOffsetCaliDesc: 'Nozzle-offsets tussen extruders kalibreren',
tempFanPresetsTitle: 'Voorinstellingen temperatuur en ventilator',
@@ -5172,6 +5192,8 @@ export default {
insufficientBudget: 'Onvoldoende budget',
unlimitedNoBudget: 'Onbeperkt – er is geen budgetlimiet ingesteld.',
noPrintableCostCenters: 'Er is geen actieve kostenplaats beschikbaar voor afdrukken. Vraag een beheerder om je afdruktoegang te geven.',
askForOutcome: 'Vragen naar resultaat',
askForOutcomeTitle: 'Na afloop vragen hoe de print is geworden',
},
// Backup
@@ -5495,6 +5517,32 @@ export default {
uploadFailed: 'Upload mislukt',
},
confirmOutcome: {
title: 'Hoe is je afdruk gelukt?',
good: 'Goed',
reject: 'Afkeuren',
later: 'Vraag het me later',
noPhoto: 'Geen eindfoto beschikbaar',
savedGood: 'Gemarkeerd als goed onderdeel',
savedReject: 'Gemarkeerd als afgekeurd',
saveFailed: 'Kon het oordeel niet opslaan',
rejectReason: 'Reden van afkeuring (optioneel)',
noReason: 'Geen reden',
saveReject: 'Opslaan',
rejectAndReprint: 'Opnieuw afdrukken',
alreadyGood: 'Al bevestigd als goed onderdeel.',
alreadyRejected: 'Al gemarkeerd als afgekeurd.',
cardPrompt: 'Hoe is het gelukt?',
sources: {
dialog: 'Vastgelegd in de app.',
link: 'Vastgelegd via een link met één tik.',
plateClear: 'Vastgelegd bij het vrijgeven van de plaat.',
printerCard: 'Vastgelegd via de printerkaart.',
api: 'Vastgelegd via de API.',
reaction: 'Vastgelegd via een reactie in de chat.',
},
},
// Edit archive modal
// Edit Archive Modal
editArchive: {
@@ -5522,6 +5570,12 @@ export default {
existingTags: 'Bestaande tags',
clickToAdd: '(klik om toe te voegen)',
status: 'Status',
userVerdict: 'Resultaatoordeel',
verdicts: {
none: 'Niet ingesteld',
good: 'Goed onderdeel',
reject: 'Afgekeurd',
},
failureReason: 'Reden van mislukking',
selectReason: 'Selecteer reden...',
photos: 'Foto\'s van het afdrukresultaat',
@@ -6237,6 +6291,8 @@ export default {
plateNotEmptyDescription: 'Objecten gedetecteerd vóór afdrukken',
plateClearRequired: 'Bevestiging lege plaat vereist',
plateClearRequiredDescription: 'Afdruk voltooid, wachtrij wacht op bevestiging van de plaat',
printConfirmRequest: 'Resultaatbevestiging',
printConfirmRequestDescription: 'Vragen om een goed/afkeur-oordeel via links met één tik wanneer een aangemelde afdruk is voltooid',
plateClear: 'Plaat leeg',
printCompleted: 'Afdruk voltooid',
bedCooledLabel: 'Bed afgekoeld',
+56
View File
@@ -950,6 +950,8 @@ export default {
showFailedPrints: 'Mostrar impressões falhas',
hideFailedPrints: 'Ocultar impressões falhas',
hideDuplicates: 'Ocultar duplicados',
filterUnconfirmed: 'Não confirmadas',
filterUnconfirmedTitle: 'Mostrar apenas impressões que ainda aguardam o veredito de resultado',
viewOriginalPrint: 'Clique para visualizar a impressão original (#{{id}})',
printTime: 'Tempo de impressão',
filamentUsed: 'Filamento usado',
@@ -1004,6 +1006,7 @@ export default {
slice: 'Fatiar',
externalLink: 'Link externo',
viewOnMakerWorld: 'Ver no MakerWorld',
confirmOutcome: 'Confirmar Resultado',
preview3d: 'Pré-visualização 3D',
viewTimelapse: 'Ver Timelapse',
scanForTimelapse: 'Escanear Timelapse',
@@ -1065,6 +1068,12 @@ export default {
removeFromFavorites: 'Remover dos favoritos',
cancelled: 'cancelado',
failed: 'falha',
confirmPending: 'resultado?',
confirmPendingTitle: 'Esta impressão pediu um veredito de resultado — clique para responder',
rejected: 'rejeitado',
rejectedTitle: 'Concluída na impressora, marcada como rejeitada pelo usuário',
confirmedGood: 'boa',
confirmedGoodTitle: 'Confirmada como uma peça boa pelo usuário',
duplicate: 'duplicado',
duplicateTitle: 'Este modelo já foi impresso antes',
openSource3mf: 'Abrir source 3MF no Bambu Studio (clique com o botão direito para mais opções)',
@@ -1484,6 +1493,8 @@ export default {
requiresPrevious: 'Requer sucesso anterior',
autoPowerOff: 'Desligamento automático',
gcodeInjection: 'G-code',
confirmOutcome: 'Consulta de resultado',
confirmOutcomeTitle: 'Pergunta como a impressão ficou depois de concluída',
},
// Empty state
empty: {
@@ -1525,6 +1536,7 @@ export default {
layerInspection: 'Inspeção da Primeira Camada',
timelapse: 'Timelapse',
useAms: 'Usar AMS',
confirmOutcome: 'Perguntar o resultado',
nozzleOffsetCali: 'Calibração de offset dos bicos',
applyChanges: 'Aplicar Alterações',
selectAll: 'Selecionar Todos',
@@ -1669,6 +1681,8 @@ export default {
failureReasons: 'Razões de Falha',
topFailureReasons: 'Principais Razões de Falha',
failedPrintsCount: '{{failed}} / {{total}} impressões falharam',
rejectedPrintsCount: '{{rejected}} rejeitadas após a conclusão',
yieldRate: 'Rendimento: {{rate}}%',
lastWeekRate: 'Última semana: {{rate}}%',
// Actions
resetLayout: 'Redefinir Layout',
@@ -2273,6 +2287,12 @@ export default {
defaultLayerInspectDesc: 'Inspeção IA da primeira camada',
defaultTimelapse: 'Timelapse',
defaultTimelapseDesc: 'Gravar vídeo timelapse',
defaultConfirmOutcome: 'Perguntar o Resultado',
defaultConfirmOutcomeDesc: 'Perguntar se a impressão saiu bem após a conclusão',
confirmOutcomeExternalPrints: 'Perguntar também em impressões iniciadas fora do Bambuddy',
confirmOutcomeExternalPrintsDesc: 'Impressões iniciadas na impressora, no Bambu Studio ou no app Handy também são arquivadas pelo Bambuddy. Com esta opção, elas recebem a mesma pergunta de resultado das impressões da fila.',
confirmDefaultGoodOnPlateClear: 'Contar resultados sem resposta como bons ao liberar a placa',
confirmDefaultGoodOnPlateClearDescription: 'Quando a placa é liberada (manualmente ou pela próxima impressão na fila) e a pergunta sobre o resultado da impressão ainda não foi respondida, registrá-la automaticamente como peça boa. A impressão passa a contar como boa assim que a placa é liberada; uma resposta posterior por Telegram ou por link apenas mostra o resultado registrado.',
defaultNozzleOffsetCali: 'Calibração de offset dos bicos',
defaultNozzleOffsetCaliDesc: 'Calibrar offsets entre extrusores',
tempFanPresetsTitle: 'Predefinições de temperatura e ventilador',
@@ -5115,6 +5135,8 @@ export default {
insufficientBudget: 'Orçamento insuficiente',
unlimitedNoBudget: 'Ilimitado – nenhum limite de orçamento foi definido.',
noPrintableCostCenters: 'Não há nenhum centro de custo ativo disponível para impressão. Peça a um administrador para conceder acesso de impressão.',
askForOutcome: 'Perguntar o resultado',
askForOutcomeTitle: 'Perguntar como a impressão ficou depois de concluída',
},
// Backup
@@ -5433,6 +5455,32 @@ export default {
uploadFailed: 'Falha no envio',
},
confirmOutcome: {
title: 'Como ficou sua impressão?',
good: 'Boa',
reject: 'Rejeitar',
later: 'Perguntar depois',
noPhoto: 'Nenhuma foto do resultado disponível',
savedGood: 'Marcada como peça boa',
savedReject: 'Marcada como rejeitada',
saveFailed: 'Não foi possível salvar o veredito',
rejectReason: 'Motivo da rejeição (opcional)',
noReason: 'Sem motivo',
saveReject: 'Salvar',
rejectAndReprint: 'Imprimir novamente',
alreadyGood: 'Já confirmada como peça boa.',
alreadyRejected: 'Já marcada como rejeitada.',
cardPrompt: 'Como ficou a impressão?',
sources: {
dialog: 'Registrado no aplicativo.',
link: 'Registrado com um link de um toque.',
plateClear: 'Registrado ao liberar a placa.',
printerCard: 'Registrado no cartão da impressora.',
api: 'Registrado pela API.',
reaction: 'Registrado com uma reação no chat.',
},
},
// Edit archive modal
// Edit Archive Modal
editArchive: {
@@ -5460,6 +5508,12 @@ export default {
existingTags: 'Tags existentes',
clickToAdd: '(clique para adicionar)',
status: 'Status',
userVerdict: 'Veredito de Resultado',
verdicts: {
none: 'Não definido',
good: 'Peça boa',
reject: 'Rejeitada',
},
failureReason: 'Motivo da Falha',
selectReason: 'Selecione o motivo...',
photos: 'Fotos do Resultado da Impressão',
@@ -6175,6 +6229,8 @@ export default {
plateNotEmptyDescription: 'Objetos detectados antes da impressão',
plateClearRequired: 'Confirmar mesa livre',
plateClearRequiredDescription: 'Impressão concluída, a fila aguarda confirmação',
printConfirmRequest: 'Confirmação de Resultado',
printConfirmRequestDescription: 'Pedir um veredito de boa/rejeitada com links de um toque quando uma impressão com a opção ativada for concluída',
plateClear: 'Mesa livre',
printCompleted: 'Impressão Concluída',
bedCooledLabel: 'Mesa Resfriada',
+55
View File
@@ -905,6 +905,8 @@ export default {
showFailedPrints: "Показывать неудачные печати",
hideFailedPrints: "Скрывать неудачные печати",
hideDuplicates: "Скрывать дубликаты",
filterUnconfirmed: "Неподтверждённые",
filterUnconfirmedTitle: "Показывать только печати, ожидающие вердикта о результате",
viewOriginalPrint: "Открыть исходную печать №{{id}}",
printTime: "Время печати",
filamentUsed: "Израсходовано филамента",
@@ -959,6 +961,7 @@ export default {
slice: "Нарезать",
externalLink: "Внешняя ссылка",
viewOnMakerWorld: "Открыть на MakerWorld",
confirmOutcome: "Подтвердить результат",
preview3d: "3D-просмотр",
viewTimelapse: "Посмотреть таймлапс",
scanForTimelapse: "Найти таймлапс",
@@ -1020,6 +1023,12 @@ export default {
removeFromFavorites: "Удалить из избранного",
cancelled: "отменено",
failed: "ошибка",
confirmPending: "результат?",
confirmPendingTitle: "Для этой печати запрошен вердикт о результате — нажмите, чтобы ответить",
rejected: "брак",
rejectedTitle: "Печать завершена на принтере, но помечена пользователем как брак",
confirmedGood: "годная",
confirmedGoodTitle: "Подтверждено пользователем как годная деталь",
duplicate: "дубликат",
duplicateTitle: "Эта модель уже печаталась",
openSource3mf: "Открыть исходный 3MF в Bambu Studio (щёлкните правой кнопкой для дополнительных действий)",
@@ -1425,6 +1434,8 @@ export default {
requiresPrevious: "Требуется успешное предыдущее задание",
autoPowerOff: "Автоотключение питания",
gcodeInjection: "G-code",
confirmOutcome: 'Запрос результата',
confirmOutcomeTitle: 'После завершения спросит, как получилась печать',
},
empty: {
title: "Нет запланированных заданий",
@@ -1462,6 +1473,7 @@ export default {
layerInspection: "Проверка первого слоя",
timelapse: "Таймлапс",
useAms: "Использовать AMS",
confirmOutcome: 'Спросить о результате',
nozzleOffsetCali: "Калибровка смещения сопел",
applyChanges: "Применить изменения",
selectAll: "Выбрать все",
@@ -1599,6 +1611,8 @@ export default {
failureReasons: "Причины неудач",
topFailureReasons: "Основные причины неудач",
failedPrintsCount: "Неудачных печатей: {{failed}} из {{total}}",
rejectedPrintsCount: "Забраковано после завершения: {{rejected}}",
yieldRate: "Выход годных: {{rate}}%",
lastWeekRate: "За прошлую неделю: {{rate}}%",
resetLayout: "Сбросить расположение",
recalculateCosts: "Пересчитать стоимость",
@@ -2197,6 +2211,12 @@ export default {
defaultLayerInspectDesc: "Проверка первого слоя системой компьютерного зрения",
defaultTimelapse: "Таймлапс",
defaultTimelapseDesc: "Записывать таймлапс",
defaultConfirmOutcome: "Запрашивать результат",
defaultConfirmOutcomeDesc: "Спрашивать после завершения печати, удалась ли она",
confirmOutcomeExternalPrints: "Спрашивать и о печати, запущенной вне Bambuddy",
confirmOutcomeExternalPrintsDesc: "Печать, запущенная на принтере, в Bambu Studio или в приложении Handy, тоже попадает в архив Bambuddy. С этой опцией по ней задаётся тот же вопрос о результате, что и по заданиям из очереди.",
confirmDefaultGoodOnPlateClear: "Считать неотвеченные результаты годными при очистке пластины",
confirmDefaultGoodOnPlateClearDescription: "Если пластина очищена (вручную или следующей печатью из очереди), а запрос результата печати остался без ответа, автоматически записывать её как годную деталь. Печать считается годной, как только пластина очищена; ответ через Telegram или по ссылке после этого лишь показывает сохранённый результат.",
defaultNozzleOffsetCali: "Калибровка смещения сопел",
defaultNozzleOffsetCaliDesc: "Калибровать взаимное смещение сопел экструдеров",
tempFanPresetsTitle: "Пресеты температуры и вентиляторов",
@@ -4886,6 +4906,8 @@ export default {
insufficientBudget: "Недостаточно бюджета",
unlimitedNoBudget: "Без ограничений — лимит бюджета не задан.",
noPrintableCostCenters: "Нет активного центра затрат, доступного для печати. Попросите администратора предоставить вам доступ к печати.",
askForOutcome: 'Спросить о результате',
askForOutcomeTitle: 'После завершения спросить, как получилась печать',
},
backup: {
includesEncryptionKey: "Локальные резервные копии включают файл ключа шифрования MFA (DATA_DIR/.mfa_encryption_key), поэтому ZIP-архив является самодостаточным. Считайте этот ZIP конфиденциальным: любой, у кого есть файл, сможет расшифровать сохранённые в нём секреты клиента OIDC и TOTP.",
@@ -5184,6 +5206,31 @@ export default {
upload: "Загрузить",
uploadFailed: "Не удалось загрузить",
},
confirmOutcome: {
title: "Как получилась печать?",
good: "Хорошо",
reject: "Брак",
later: "Спросить позже",
noPhoto: "Фото готовой печати недоступно",
savedGood: "Отмечено как годная деталь",
savedReject: "Отмечено как брак",
saveFailed: "Не удалось сохранить вердикт",
rejectReason: "Причина брака (необязательно)",
noReason: "Без причины",
saveReject: "Сохранить",
rejectAndReprint: "Напечатать снова",
alreadyGood: "Уже подтверждено как годная деталь.",
alreadyRejected: "Уже отмечено как брак.",
cardPrompt: "Как получилась печать?",
sources: {
dialog: "Записано в приложении.",
link: "Записано по ссылке в одно нажатие.",
plateClear: "Записано при очистке пластины.",
printerCard: "Записано с карточки принтера.",
api: "Записано через API.",
reaction: "Записано реакцией в чате.",
},
},
editArchive: {
title: "Редактирование архива",
name: "Название",
@@ -5209,6 +5256,12 @@ export default {
existingTags: "Существующие метки",
clickToAdd: "(нажмите, чтобы добавить)",
status: "Статус",
userVerdict: "Вердикт о результате",
verdicts: {
none: "Не задан",
good: "Годная деталь",
reject: "Брак",
},
failureReason: "Причина сбоя",
selectReason: "Выберите причину...",
photos: "Фотографии результата печати",
@@ -5892,6 +5945,8 @@ export default {
plateNotEmptyDescription: "Перед печатью на пластине обнаружены объекты",
plateClearRequired: "Требуется подтверждение стола",
plateClearRequiredDescription: "Печать завершена, очередь ждет подтверждения",
printConfirmRequest: "Подтверждение результата",
printConfirmRequestDescription: "Запрашивать вердикт «годная/брак» со ссылками в одно нажатие, когда завершается печать с включённым запросом результата",
plateClear: "Стол свободен",
printCompleted: "Печать завершена",
bedCooledLabel: "Стол остыл",
+56
View File
@@ -955,6 +955,8 @@ export default {
showFailedPrints: 'Visa misslyckade utskrifter',
hideFailedPrints: 'Dölj misslyckade utskrifter',
hideDuplicates: 'Dölj dubbletter',
filterUnconfirmed: 'Obekräftade',
filterUnconfirmedTitle: 'Visa endast utskrifter som fortfarande väntar på sin resultatbedömning',
viewOriginalPrint: 'Klicka för att visa originalutskrift (#{{id}})',
printTime: 'Utskriftstid',
filamentUsed: 'Filament använt',
@@ -1009,6 +1011,7 @@ export default {
slice: 'Slica',
externalLink: 'Extern länk',
viewOnMakerWorld: 'Visa på MakerWorld',
confirmOutcome: 'Bekräfta resultat',
preview3d: '3D-förhandsgranskning',
viewTimelapse: 'Visa timelapse',
scanForTimelapse: 'Sök efter timelapse',
@@ -1070,6 +1073,12 @@ export default {
removeFromFavorites: 'Ta bort från favoriter',
cancelled: 'avbruten',
failed: 'misslyckad',
confirmPending: 'resultat?',
confirmPendingTitle: 'Denna utskrift bad om en resultatbedömning — klicka för att svara',
rejected: 'kasserad',
rejectedTitle: 'Slutförd på skrivaren, markerad som kasserad av användaren',
confirmedGood: 'godkänd',
confirmedGoodTitle: 'Bekräftad som godkänd del av användaren',
duplicate: 'kopia',
duplicateTitle: 'Denna modell har skrivits ut tidigare',
openSource3mf: 'Öppna 3MF-källfil i Bambu Studio (högerklicka för fler alternativ)',
@@ -1500,6 +1509,8 @@ export default {
requiresPrevious: 'Kräver tidigare framgång',
autoPowerOff: 'Auto-avstängning',
gcodeInjection: 'G-kod',
confirmOutcome: 'Resultatfråga',
confirmOutcomeTitle: 'Frågar hur utskriften blev när den är klar',
},
// Empty state
empty: {
@@ -1541,6 +1552,7 @@ export default {
layerInspection: 'Förstaskiktsinspektion',
timelapse: 'Timelapse',
useAms: 'Använd AMS',
confirmOutcome: 'Fråga efter resultat',
nozzleOffsetCali: 'Munstyckesoffsetskalibrering',
applyChanges: 'Tillämpa ändringar',
selectAll: 'Markera alla',
@@ -1686,6 +1698,8 @@ export default {
failureReasons: 'Orsaker till fel',
topFailureReasons: 'Vanligaste orsaker till fel',
failedPrintsCount: '{{failed}} / {{total}} utskrifter misslyckades',
rejectedPrintsCount: '{{rejected}} kasserade efter slutförande',
yieldRate: 'Utbyte: {{rate}}%',
lastWeekRate: 'Förra veckan: {{rate}}%',
// Actions
resetLayout: 'Återställ layout',
@@ -2337,6 +2351,12 @@ export default {
defaultLayerInspectDesc: 'AI-inspektion av första skiktet',
defaultTimelapse: 'Timelapse',
defaultTimelapseDesc: 'Spela in timelapse-video',
defaultConfirmOutcome: 'Fråga efter resultat',
defaultConfirmOutcomeDesc: 'Fråga om utskriften blev bra när den är klar',
confirmOutcomeExternalPrints: 'Fråga även för utskrifter som startats utanför Bambuddy',
confirmOutcomeExternalPrintsDesc: 'Utskrifter som startats på skrivaren, i Bambu Studio eller i Handy-appen arkiveras också av Bambuddy. Med detta påslaget får de samma resultatfråga som utskrifter från kön.',
confirmDefaultGoodOnPlateClear: 'Räkna obesvarade resultat som godkända när plattan rensas',
confirmDefaultGoodOnPlateClearDescription: 'När plattan rensas (manuellt eller av nästa utskrift i kön) och utskriftens resultatfråga fortfarande är obesvarad, registreras utskriften automatiskt som en godkänd del. Utskriften räknas då som godkänd så snart plattan rensas; ett svar via Telegram eller länk därefter visar bara det registrerade resultatet.',
defaultNozzleOffsetCali: 'Munstyckesoffsetkalibrering',
defaultNozzleOffsetCaliDesc: 'Kalibrera munstyckesoffsetar mellan extruderare',
tempFanPresetsTitle: 'Temperatur- och fläktförinställningar',
@@ -5172,6 +5192,8 @@ errors: {
insufficientBudget: 'Otillräcklig budget',
unlimitedNoBudget: 'Obgränsad – ingen budgetgräns är inställd.',
noPrintableCostCenters: 'Inget aktivt kostnadsställe är tillgängligt för utskrift. Be en administratör att ge dig utskriftsåtkomst.',
askForOutcome: 'Fråga efter resultat',
askForOutcomeTitle: 'Fråga hur utskriften blev när den är klar',
},
// Backup
@@ -5495,6 +5517,32 @@ errors: {
uploadFailed: 'Kunde inte ladda upp',
},
// Post-print outcome confirmation dialog (#1898)
confirmOutcome: {
title: 'Hur blev din utskrift?',
good: 'Godkänd',
reject: 'Kassera',
later: 'Fråga mig senare',
noPhoto: 'Inget slutfoto tillgängligt',
savedGood: 'Markerad som godkänd del',
savedReject: 'Markerad som kasserad',
saveFailed: 'Kunde inte spara bedömningen',
rejectReason: 'Orsak till kassering (valfritt)',
noReason: 'Ingen orsak',
saveReject: 'Spara',
rejectAndReprint: 'Skriv ut igen',
alreadyGood: 'Redan bekräftad som godkänd del.',
alreadyRejected: 'Redan markerad som kasserad.',
cardPrompt: 'Hur blev den?',
sources: {
dialog: 'Registrerad i appen.',
link: 'Registrerad via en snabblänk.',
plateClear: 'Registrerad när plattan rensades.',
printerCard: 'Registrerad från skrivarkortet.',
api: 'Registrerad via API:et.',
reaction: 'Registrerad med en reaktion i chatten.',
},
},
// Edit archive modal
// Edit Archive Modal
editArchive: {
@@ -5522,6 +5570,12 @@ errors: {
existingTags: 'Befintliga taggar',
clickToAdd: '(klicka för att lägga till)',
status: 'Status',
userVerdict: 'Resultatbedömning',
verdicts: {
none: 'Inte angiven',
good: 'Godkänd del',
reject: 'Kasserad',
},
failureReason: 'Orsak till fel',
selectReason: 'Välj orsak...',
photos: 'Bilder av utskriftsresultat',
@@ -6237,6 +6291,8 @@ errors: {
plateNotEmptyDescription: 'Objekt upptäckta före utskrift',
plateClearRequired: 'Platt-rensning krävs',
plateClearRequiredDescription: 'Utskrift slutförd, kön väntar på plattbekräftelse',
printConfirmRequest: 'Resultatbekräftelse',
printConfirmRequestDescription: 'Begär en godkänd/kasserad-bedömning med snabblänkar när en utskrift med resultatfråga blir klar',
plateClear: 'Platta rensad',
printCompleted: 'Utskrift slutförd',
bedCooledLabel: 'Byggplatta kyld',
+56
View File
@@ -950,6 +950,8 @@ export default {
showFailedPrints: 'Başarısız baskıları göster',
hideFailedPrints: 'Başarısız baskıları gizle',
hideDuplicates: 'Tekrarları Gizle',
filterUnconfirmed: 'Onaylanmamış',
filterUnconfirmedTitle: 'Yalnızca sonuç kararı bekleyen baskıları göster',
viewOriginalPrint: 'Orijinal baskıyı görüntülemek için tıklayın (#{{id}})',
printTime: 'Baskı Süresi',
filamentUsed: 'Kullanılan Filament',
@@ -1004,6 +1006,7 @@ export default {
slice: 'Dilimle',
externalLink: 'Harici Bağlantı',
viewOnMakerWorld: "MakerWorld'de Görüntüle",
confirmOutcome: 'Sonucu Onayla',
preview3d: '3B Önizleme',
viewTimelapse: 'Zaman Atlamalı Videoyu Görüntüle',
scanForTimelapse: 'Zaman Atlamalı Video Tara',
@@ -1065,6 +1068,12 @@ export default {
removeFromFavorites: 'Favorilerden kaldır',
cancelled: 'iptal edildi',
failed: 'başarısız',
confirmPending: 'sonuç?',
confirmPendingTitle: 'Bu baskı için sonuç kararı istendi — yanıtlamak için tıklayın',
rejected: 'reddedildi',
rejectedTitle: 'Yazıcıda tamamlandı, kullanıcı tarafından ret olarak işaretlendi',
confirmedGood: 'sağlam',
confirmedGoodTitle: 'Kullanıcı tarafından sağlam parça olarak onaylandı',
duplicate: 'tekrar',
duplicateTitle: 'Bu model daha önce yazdırıldı',
openSource3mf: "Kaynak 3MF'yi Bambu Studio'da aç (daha fazla seçenek için sağ tıklayın)",
@@ -1484,6 +1493,8 @@ export default {
requiresPrevious: 'Önceki başarı gerekli',
autoPowerOff: 'Otomatik kapanma',
gcodeInjection: 'G-kod',
confirmOutcome: 'Sonuç sorgusu',
confirmOutcomeTitle: 'Tamamlandığında baskının nasıl çıktığını sorar',
},
// Boş durum
empty: {
@@ -1525,6 +1536,7 @@ export default {
layerInspection: 'İlk katman denetimi',
timelapse: 'Zaman atlamalı video',
useAms: "AMS Kullan",
confirmOutcome: 'Sonucu sor',
nozzleOffsetCali: 'Nozül ofset kalibrasyonu',
applyChanges: 'Değişiklikleri Uygula',
selectAll: 'Tümünü Seç',
@@ -1670,6 +1682,8 @@ export default {
failureReasons: 'Başarısızlık Nedenleri',
topFailureReasons: 'En Sık Başarısızlık Nedenleri',
failedPrintsCount: '{{total}} baskının {{failed}} tanesi başarısız',
rejectedPrintsCount: '{{rejected}} tamamlandıktan sonra reddedildi',
yieldRate: 'Verim: %{{rate}}',
lastWeekRate: 'Geçen hafta: %{{rate}}',
// İşlemler
resetLayout: 'Düzeni Sıfırla',
@@ -2321,6 +2335,12 @@ export default {
defaultLayerInspectDesc: 'İlk katmanın AI denetimi',
defaultTimelapse: 'Zaman Atlamalı Video',
defaultTimelapseDesc: 'Zaman atlamalı video kaydet',
defaultConfirmOutcome: 'Sonucu Sor',
defaultConfirmOutcomeDesc: 'Baskı tamamlandıktan sonra iyi çıkıp çıkmadığını sor',
confirmOutcomeExternalPrints: 'Bambuddy dışında başlatılan baskılar için de sor',
confirmOutcomeExternalPrintsDesc: 'Yazıcıda, Bambu Studio\'da veya Handy uygulamasında başlatılan baskılar da Bambuddy tarafından arşivlenir. Bu seçenek açıkken, kuyruktaki baskılarla aynı sonuç sorusunu alırlar.',
confirmDefaultGoodOnPlateClear: 'Plaka temizlendiğinde yanıtsız sonuçları iyi olarak say',
confirmDefaultGoodOnPlateClearDescription: 'Plaka temizlendi olarak onaylandığında (elle veya kuyruktaki bir sonraki baskıyla) ve baskının sonuç sorusu hâlâ yanıtsızsa, otomatik olarak iyi parça olarak kaydet. Plaka temizlendiği anda baskı iyi sayılır; sonrasında Telegram veya bağlantı üzerinden verilen yanıt yalnızca kayıtlı sonucu gösterir.',
defaultNozzleOffsetCali: 'Nozül Ofset Kalibrasyonu',
defaultNozzleOffsetCaliDesc: 'Ekstrüderler arasındaki nozül ofsetlerini kalibre et',
tempFanPresetsTitle: 'Sıcaklık ve Fan Ön Ayarları',
@@ -5104,6 +5124,8 @@ export default {
insufficientBudget: 'Yetersiz bütçe',
unlimitedNoBudget: 'Sınırsız – bütçe limiti belirlenmemiş.',
noPrintableCostCenters: 'Yazdırma için kullanılabilir etkin bir masraf merkezi yok. Bir yöneticiden yazdırma erişimi vermesini isteyin.',
askForOutcome: 'Sonucu sor',
askForOutcomeTitle: 'Tamamlandığında baskının nasıl çıktığını sor',
},
// Yedekleme
@@ -5419,6 +5441,32 @@ export default {
uploadFailed: 'Yükleme başarısız',
},
confirmOutcome: {
title: 'Baskınız nasıl çıktı?',
good: 'İyi',
reject: 'Ret',
later: 'Daha sonra sor',
noPhoto: 'Bitiş fotoğrafı mevcut değil',
savedGood: 'İyi parça olarak işaretlendi',
savedReject: 'Reddedildi olarak işaretlendi',
saveFailed: 'Karar kaydedilemedi',
rejectReason: 'Ret nedeni (isteğe bağlı)',
noReason: 'Neden yok',
saveReject: 'Kaydet',
rejectAndReprint: 'Tekrar yazdır',
alreadyGood: 'Zaten iyi parça olarak onaylandı.',
alreadyRejected: 'Zaten reddedildi olarak işaretlendi.',
cardPrompt: 'Nasıl çıktı?',
sources: {
dialog: 'Uygulamada kaydedildi.',
link: 'Tek dokunuşluk bağlantıyla kaydedildi.',
plateClear: 'Plaka temizlendiğinde kaydedildi.',
printerCard: 'Yazıcı kartından kaydedildi.',
api: 'API üzerinden kaydedildi.',
reaction: 'Sohbetteki bir tepkiyle kaydedildi.',
},
},
// Arşiv düzenleme modali
editArchive: {
title: 'Arşivi Düzenle',
@@ -5445,6 +5493,12 @@ export default {
existingTags: 'Mevcut etiketler',
clickToAdd: '(eklemek için tıklayın)',
status: 'Durum',
userVerdict: 'Sonuç Kararı',
verdicts: {
none: 'Ayarlanmadı',
good: 'İyi parça',
reject: 'Reddedildi',
},
failureReason: 'Başarısızlık Nedeni',
selectReason: 'Neden seç...',
photos: 'Yazdırılan Sonuç Fotoğrafları',
@@ -6142,6 +6196,8 @@ export default {
plateNotEmptyDescription: 'Baskıdan önce nesneler algılandı',
plateClearRequired: 'Tabla onayı gerekli',
plateClearRequiredDescription: 'Baskı bitti, kuyruk tabla onayını bekliyor',
printConfirmRequest: 'Sonuç Onayı',
printConfirmRequestDescription: 'Onay istenen bir baskı tamamlandığında tek dokunuş bağlantılarıyla iyi/ret kararı iste',
plateClear: 'Tabla onayı',
printCompleted: 'Baskı Tamamlandı',
bedCooledLabel: 'Tabla Soğudu',
+56
View File
@@ -954,6 +954,8 @@ export default {
showFailedPrints: "Показати невдалі друки",
hideFailedPrints: "Приховати невдалі друки",
hideDuplicates: "Приховати дублікати",
filterUnconfirmed: "Непідтверджені",
filterUnconfirmedTitle: "Показати лише друки, що досі очікують вердикту щодо результату",
viewOriginalPrint: "Клацніть, щоб переглянути оригінальний друк (#{{id}})",
printTime: "Час друку",
filamentUsed: "Використано філаменту",
@@ -1008,6 +1010,7 @@ export default {
slice: "Нарізати",
externalLink: "Зовнішнє посилання",
viewOnMakerWorld: "Переглянути на MakerWorld",
confirmOutcome: "Підтвердити результат",
preview3d: "3D попередній перегляд",
viewTimelapse: "Переглянути таймлапс",
scanForTimelapse: "Знайти таймлапс",
@@ -1069,6 +1072,12 @@ export default {
removeFromFavorites: "Видалити з вибраного",
cancelled: "скасовано",
failed: "не вдалося",
confirmPending: "результат?",
confirmPendingTitle: "Цей друк очікує вердикту щодо результату — натисніть, щоб відповісти",
rejected: "відбраковано",
rejectedTitle: "Завершено на принтері, позначено користувачем як брак",
confirmedGood: "вдалий",
confirmedGoodTitle: "Підтверджено користувачем як вдала деталь",
duplicate: "дублікат",
duplicateTitle: "Ця модель була надрукована раніше",
openSource3mf: "Відкрити вихідний файл 3MF у Bambu Studio (клацніть правою кнопкою, щоб переглянути додаткові дії)",
@@ -1499,6 +1508,8 @@ export default {
requiresPrevious: "Потрібне успішне завершення попереднього завдання",
autoPowerOff: "Автоматичне вимкнення",
gcodeInjection: "G-код",
confirmOutcome: 'Запит результату',
confirmOutcomeTitle: 'Після завершення запитає, як вийшов друк',
},
// Empty state
empty: {
@@ -1540,6 +1551,7 @@ export default {
layerInspection: "Перевірка першого шару",
timelapse: "Таймлапс",
useAms: "Використовувати AMS",
confirmOutcome: 'Запитати про результат',
nozzleOffsetCali: "Калібрування зсуву сопла",
applyChanges: "Застосувати зміни",
selectAll: "Вибрати усі",
@@ -1685,6 +1697,8 @@ export default {
failureReasons: "Причини невдач",
topFailureReasons: "Основні причини невдач",
failedPrintsCount: "Невдалих друків: {{failed}} із {{total}}",
rejectedPrintsCount: "Відбраковано після завершення: {{rejected}}",
yieldRate: "Вихід придатних: {{rate}}%",
lastWeekRate: "Минулого тижня: {{rate}}%",
// Actions
resetLayout: "Скинути макет",
@@ -2336,6 +2350,12 @@ export default {
defaultLayerInspectDesc: "Перевірка першого шару за допомогою ШІ",
defaultTimelapse: "Таймлапс",
defaultTimelapseDesc: "Записувати таймлапс",
defaultConfirmOutcome: "Запитувати про результат",
defaultConfirmOutcomeDesc: "Запитувати після завершення друку, чи вдалим він вийшов",
confirmOutcomeExternalPrints: "Запитувати й про друк, запущений поза Bambuddy",
confirmOutcomeExternalPrintsDesc: "Друк, запущений на принтері, у Bambu Studio чи в застосунку Handy, також потрапляє до архіву Bambuddy. З цією опцією щодо нього ставиться те саме запитання про результат, що й для завдань із черги.",
confirmDefaultGoodOnPlateClear: "Вважати непідтверджені результати вдалими після очищення пластини",
confirmDefaultGoodOnPlateClearDescription: "Якщо пластину очищено (вручну або наступним друком із черги), а запит щодо результату друку залишився без відповіді, автоматично записувати його як придатну деталь. Друк вважається придатним, щойно пластину очищено; відповідь через Telegram або за посиланням після цього лише показує збережений результат.",
defaultNozzleOffsetCali: "Калібрування зсуву сопла",
defaultNozzleOffsetCaliDesc: "Калібрувати зміщення сопел між екструдерами",
tempFanPresetsTitle: "Профілі температури та вентилятора",
@@ -5169,6 +5189,8 @@ export default {
insufficientBudget: "Недостатньо бюджету",
unlimitedNoBudget: "Без обмежень — ліміт бюджету не встановлено.",
noPrintableCostCenters: "Немає активного центру витрат, доступного для друку. Попросіть адміністратора надати вам доступ до друку.",
askForOutcome: 'Запитати про результат',
askForOutcomeTitle: 'Після завершення запитати, як вийшов друк',
},
// Backup
@@ -5487,6 +5509,32 @@ export default {
uploadFailed: "Не вдалося вивантажити",
},
confirmOutcome: {
title: "Як вийшов ваш друк?",
good: "Добре",
reject: "Брак",
later: "Запитати пізніше",
noPhoto: "Фото завершеного друку недоступне",
savedGood: "Позначено як придатну деталь",
savedReject: "Позначено як брак",
saveFailed: "Не вдалося зберегти вердикт",
rejectReason: "Причина браку (необов’язково)",
noReason: "Без причини",
saveReject: "Зберегти",
rejectAndReprint: "Друкувати знову",
alreadyGood: "Уже підтверджено як придатну деталь.",
alreadyRejected: "Уже позначено як брак.",
cardPrompt: "Як вийшов друк?",
sources: {
dialog: "Записано в застосунку.",
link: "Записано за посиланням в один дотик.",
plateClear: "Записано під час очищення пластини.",
printerCard: "Записано з картки принтера.",
api: "Записано через API.",
reaction: "Записано реакцією в чаті.",
},
},
// Edit archive modal
// Edit Archive Modal
editArchive: {
@@ -5514,6 +5562,12 @@ export default {
existingTags: "Існуючі теги",
clickToAdd: "(натисніть, щоб додати)",
status: "Статус",
userVerdict: "Вердикт щодо результату",
verdicts: {
none: "Не задано",
good: "Придатна деталь",
reject: "Відбраковано",
},
failureReason: "Причина невдачі",
selectReason: "Виберіть причину...",
photos: "Фотографії надрукованих результатів",
@@ -6229,6 +6283,8 @@ export default {
plateNotEmptyDescription: "Перед друком виявлено об’єкти",
plateClearRequired: "Потрібно очистити пластину",
plateClearRequiredDescription: "Друк завершено, черга очікує підтвердження очищення пластини",
printConfirmRequest: "Підтвердження результату",
printConfirmRequestDescription: "Запитувати вердикт «придатний/брак» із посиланнями в один дотик, коли завершується друк з увімкненим запитом",
plateClear: "Очищення пластини",
printCompleted: "Друк завершено",
bedCooledLabel: "Стіл охолов",
+56
View File
@@ -950,6 +950,8 @@ export default {
showFailedPrints: '显示失败的打印',
hideFailedPrints: '隐藏失败的打印',
hideDuplicates: '隐藏重复项',
filterUnconfirmed: '待确认',
filterUnconfirmedTitle: '仅显示仍在等待成品判定的打印',
viewOriginalPrint: '点击查看原始打印 (#{{id}})',
printTime: '打印时间',
filamentUsed: '耗材用量',
@@ -1004,6 +1006,7 @@ export default {
slice: '切片',
externalLink: '外部链接',
viewOnMakerWorld: '在 MakerWorld 上查看',
confirmOutcome: '确认成品',
preview3d: '3D 预览',
viewTimelapse: '查看延时摄影',
scanForTimelapse: '扫描延时摄影',
@@ -1065,6 +1068,12 @@ export default {
removeFromFavorites: '从收藏中移除',
cancelled: '已取消',
failed: '失败',
confirmPending: '结果?',
confirmPendingTitle: '此打印请求了成品判定 — 点击回答',
rejected: '已报废',
rejectedTitle: '在打印机上已完成,但被用户标记为报废',
confirmedGood: '良品',
confirmedGoodTitle: '被用户确认为良品',
duplicate: '重复',
duplicateTitle: '此模型之前已打印过',
openSource3mf: '在 Bambu Studio 中打开源 3MF(右键查看更多选项)',
@@ -1484,6 +1493,8 @@ export default {
requiresPrevious: '需要前一个成功',
autoPowerOff: '自动关机',
gcodeInjection: 'G-code',
confirmOutcome: '结果确认',
confirmOutcomeTitle: '打印完成后询问打印效果如何',
},
// Empty state
empty: {
@@ -1525,6 +1536,7 @@ export default {
layerInspection: '首层检查',
timelapse: '延时摄影',
useAms: '使用 AMS',
confirmOutcome: '询问打印结果',
nozzleOffsetCali: '喷嘴偏移校准',
applyChanges: '应用更改',
selectAll: '全选',
@@ -1669,6 +1681,8 @@ export default {
failureReasons: '失败原因',
topFailureReasons: '主要失败原因',
failedPrintsCount: '{{failed}} / {{total}} 次打印失败',
rejectedPrintsCount: '{{rejected}} 个完成后被报废',
yieldRate: '良品率:{{rate}}%',
lastWeekRate: '上周:{{rate}}%',
// Actions
resetLayout: '重置布局',
@@ -2318,6 +2332,12 @@ export default {
defaultLayerInspectDesc: 'AI首层检测',
defaultTimelapse: '延时摄影',
defaultTimelapseDesc: '录制延时摄影视频',
defaultConfirmOutcome: '询问成品结果',
defaultConfirmOutcomeDesc: '打印完成后询问成品是否良好',
confirmOutcomeExternalPrints: '也询问非 Bambuddy 启动的打印',
confirmOutcomeExternalPrintsDesc: '在打印机、Bambu Studio 或 Handy 应用中启动的打印同样会被 Bambuddy 归档。开启后,它们会收到与队列打印相同的成品确认。',
confirmDefaultGoodOnPlateClear: '打印板清空时将未回答的结果计为良品',
confirmDefaultGoodOnPlateClearDescription: '当打印板被清空(手动或由下一个排队打印触发)而该打印的结果询问仍未回答时,自动将其记录为良品。打印板被清空后,该打印即算作良品;此后通过 Telegram 或链接作答只会显示已记录的结果。',
defaultNozzleOffsetCali: '喷嘴偏移校准',
defaultNozzleOffsetCaliDesc: '校准两个挤出机之间的喷嘴偏移',
tempFanPresetsTitle: '温度与风扇预设',
@@ -5115,6 +5135,8 @@ export default {
insufficientBudget: '预算不足',
unlimitedNoBudget: '无限制 – 未设置预算上限。',
noPrintableCostCenters: '没有可用于打印的有效成本中心。请联系管理员授予你打印权限。',
askForOutcome: '询问打印结果',
askForOutcomeTitle: '打印完成后询问打印效果如何',
},
// Backup
@@ -5433,6 +5455,32 @@ export default {
uploadFailed: '上传失败',
},
confirmOutcome: {
title: '这次打印的成品如何?',
good: '良品',
reject: '报废',
later: '稍后询问',
noPhoto: '暂无完成照片',
savedGood: '已标记为良品',
savedReject: '已标记为报废',
saveFailed: '无法保存判定结果',
rejectReason: '报废原因(可选)',
noReason: '无原因',
saveReject: '保存',
rejectAndReprint: '重新打印',
alreadyGood: '已确认为良品。',
alreadyRejected: '已标记为报废。',
cardPrompt: '打印效果如何?',
sources: {
dialog: '在应用中记录。',
link: '通过一键链接记录。',
plateClear: '在清空打印板时记录。',
printerCard: '从打印机卡片记录。',
api: '通过 API 记录。',
reaction: '通过聊天中的表情回应记录。',
},
},
// Edit archive modal
// Edit Archive Modal
editArchive: {
@@ -5460,6 +5508,12 @@ export default {
existingTags: '现有标签',
clickToAdd: '(点击添加)',
status: '状态',
userVerdict: '成品判定',
verdicts: {
none: '未设置',
good: '良品',
reject: '已报废',
},
failureReason: '失败原因',
selectReason: '选择原因...',
photos: '打印成品照片',
@@ -6174,6 +6228,8 @@ export default {
plateNotEmptyDescription: '打印前检测到物体',
plateClearRequired: '需要确认热床已清空',
plateClearRequiredDescription: '打印完成,队列等待热床确认',
printConfirmRequest: '成品确认',
printConfirmRequestDescription: '当已启用询问的打印完成时,通过一键链接请求良品/报废判定',
plateClear: '热床确认',
printCompleted: '打印已完成',
bedCooledLabel: '热床已冷却',
+56
View File
@@ -950,6 +950,8 @@ export default {
showFailedPrints: '顯示失敗的列印',
hideFailedPrints: '隱藏失敗的列印',
hideDuplicates: '隱藏重複項',
filterUnconfirmed: '未確認',
filterUnconfirmedTitle: '僅顯示仍在等待成品判定的列印',
viewOriginalPrint: '點選檢視原始列印 (#{{id}})',
printTime: '列印時間',
filamentUsed: '耗材用量',
@@ -1004,6 +1006,7 @@ export default {
slice: '切片',
externalLink: '外部連結',
viewOnMakerWorld: '在 MakerWorld 上檢視',
confirmOutcome: '確認成品結果',
preview3d: '3D 預覽',
viewTimelapse: '檢視縮時攝影',
scanForTimelapse: '掃描縮時攝影',
@@ -1065,6 +1068,12 @@ export default {
removeFromFavorites: '從收藏中移除',
cancelled: '已取消',
failed: '失敗',
confirmPending: '成品結果?',
confirmPendingTitle: '此列印需要成品判定——點選以回答',
rejected: '不良',
rejectedTitle: '已在印表機上完成,但被使用者標記為不良',
confirmedGood: '良品',
confirmedGoodTitle: '已由使用者確認為良品',
duplicate: '重複',
duplicateTitle: '此模型之前已列印過',
openSource3mf: '在 Bambu Studio 中開啟源 3MF(右鍵檢視更多選項)',
@@ -1484,6 +1493,8 @@ export default {
requiresPrevious: '需要前一個成功',
autoPowerOff: '自動關機',
gcodeInjection: 'G-code',
confirmOutcome: '結果確認',
confirmOutcomeTitle: '列印完成後詢問列印效果如何',
},
// Empty state
empty: {
@@ -1525,6 +1536,7 @@ export default {
layerInspection: '首層檢查',
timelapse: '縮時攝影',
useAms: '使用 AMS',
confirmOutcome: '詢問列印結果',
nozzleOffsetCali: '噴嘴偏移校準',
applyChanges: '套用更改',
selectAll: '全選',
@@ -1669,6 +1681,8 @@ export default {
failureReasons: '失敗原因',
topFailureReasons: '主要失敗原因',
failedPrintsCount: '{{failed}} / {{total}} 次列印失敗',
rejectedPrintsCount: '{{rejected}} 次完成後被判為不良',
yieldRate: '良率:{{rate}}%',
lastWeekRate: '上週:{{rate}}%',
// Actions
resetLayout: '重設佈局',
@@ -2318,6 +2332,12 @@ export default {
defaultLayerInspectDesc: 'AI首層檢測',
defaultTimelapse: '縮時攝影',
defaultTimelapseDesc: '錄製縮時攝影影片',
defaultConfirmOutcome: '詢問成品結果',
defaultConfirmOutcomeDesc: '列印完成後詢問成品是否良好',
confirmOutcomeExternalPrints: '也詢問非 Bambuddy 啟動的列印',
confirmOutcomeExternalPrintsDesc: '在印表機、Bambu Studio 或 Handy 應用程式中啟動的列印同樣會被 Bambuddy 封存。開啟後,它們會收到與佇列列印相同的成品確認。',
confirmDefaultGoodOnPlateClear: '熱床清空時將未回答的結果計為良品',
confirmDefaultGoodOnPlateClearDescription: '當熱床被清空(手動或由下一個佇列列印觸發)而該列印的成品結果詢問仍未回答時,自動將其記錄為良品。熱床被清空後,該列印即算作良品;之後透過 Telegram 或連結作答只會顯示已記錄的結果。',
defaultNozzleOffsetCali: '噴嘴偏移校準',
defaultNozzleOffsetCaliDesc: '校準兩個擠出機之間的噴嘴偏移',
tempFanPresetsTitle: '溫度與風扇預設',
@@ -5115,6 +5135,8 @@ export default {
insufficientBudget: '預算不足',
unlimitedNoBudget: '無限制 – 未設定預算上限。',
noPrintableCostCenters: '沒有可用於列印的有效成本中心。請聯絡管理員授予你列印權限。',
askForOutcome: '詢問列印結果',
askForOutcomeTitle: '列印完成後詢問列印效果如何',
},
// Backup
@@ -5433,6 +5455,32 @@ export default {
uploadFailed: '上傳失敗',
},
confirmOutcome: {
title: '這次列印的成品如何?',
good: '良品',
reject: '不良',
later: '稍後再問',
noPhoto: '沒有可用的完成照片',
savedGood: '已標記為良品',
savedReject: '已標記為不良',
saveFailed: '無法儲存判定結果',
rejectReason: '不良原因(選填)',
noReason: '無原因',
saveReject: '儲存',
rejectAndReprint: '再次列印',
alreadyGood: '已確認為良品。',
alreadyRejected: '已標記為不良。',
cardPrompt: '成品如何?',
sources: {
dialog: '在應用程式中記錄。',
link: '透過一鍵連結記錄。',
plateClear: '在清空熱床時記錄。',
printerCard: '從印表機卡片記錄。',
api: '透過 API 記錄。',
reaction: '透過聊天中的表情回應記錄。',
},
},
// Edit archive modal
// Edit Archive Modal
editArchive: {
@@ -5460,6 +5508,12 @@ export default {
existingTags: '現有標籤',
clickToAdd: '(點選新增)',
status: '狀態',
userVerdict: '成品判定',
verdicts: {
none: '未設定',
good: '良品',
reject: '不良',
},
failureReason: '失敗原因',
selectReason: '選擇原因...',
photos: '列印成品照片',
@@ -6174,6 +6228,8 @@ export default {
plateNotEmptyDescription: '列印前偵測到物體',
plateClearRequired: '需要確認熱床已清空',
plateClearRequiredDescription: '列印完成,佇列等待熱床確認',
printConfirmRequest: '成品結果確認',
printConfirmRequestDescription: '已選擇加入的列印完成後,透過一鍵連結詢問良品/不良判定',
plateClear: '熱床確認',
printCompleted: '列印已完成',
bedCooledLabel: '熱床已冷卻',
+154 -3
View File
@@ -61,6 +61,8 @@ import {
Columns,
ChevronUp,
ChevronDown,
ThumbsUp,
ThumbsDown,
} from 'lucide-react';
import { api } from '../api/client';
import { SliceModal } from '../components/SliceModal';
@@ -71,11 +73,13 @@ import { getCurrencySymbol } from '../utils/currency';
import { getBedTypeInfo } from '../utils/bedType';
import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
import { assignableProjects } from '../utils/projectTree';
import { verdictSourceKey } from '../utils/verdictSource';
import { usePageFileDrop } from '../hooks/usePageFileDrop';
import type { Archive, PrintLogEntry, ProjectListItem } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { PrintModal } from '../components/PrintModal';
import { ConfirmOutcomeDialog } from '../components/ConfirmOutcomeDialog';
import { UploadModal } from '../components/UploadModal';
import { PurgeArchivesModal } from '../components/PurgeArchivesModal';
import { ConfirmModal } from '../components/ConfirmModal';
@@ -326,6 +330,8 @@ function ArchiveCard({
return printerMap.get(saved.printer_id);
}, [archive.extra_data, printerMap]);
const [showReprint, setShowReprint] = useState(false);
// Post-print outcome confirmation dialog (#1898)
const [showConfirmOutcome, setShowConfirmOutcome] = useState(false);
const [showSliceModal, setShowSliceModal] = useState(false);
const [showRunPipeline, setShowRunPipeline] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -371,6 +377,9 @@ function ArchiveCard({
// Use pre-computed duplicate sequence and original archive ID from list response
const duplicateSequence = archive.duplicate_sequence ?? 0;
// Appended to the verdict badge's tooltip (#1898) so a verdict the
// plate-clear default recorded can be explained where it is shown.
const verdictSourceHintKey = verdictSourceKey(archive.user_verdict_source);
const originalArchiveId = archive.original_archive_id ?? null;
const plates = platesData?.plates ?? [];
@@ -608,6 +617,21 @@ function ArchiveCard({
},
disabled: !archive.external_url && !archive.makerworld_url,
},
// Post-print outcome confirmation (#1898): completed prints only — the
// machine statuses already cover everything else.
...(archive.status === 'completed'
? [{
label: t('archives.menu.confirmOutcome'),
icon: archive.user_verdict === 'reject'
? <ThumbsDown className="w-4 h-4" />
: <ThumbsUp className="w-4 h-4" />,
onClick: () => setShowConfirmOutcome(true),
disabled: !canModify('archives', 'update', archive.created_by_id),
title: !canModify('archives', 'update', archive.created_by_id)
? t('archives.permission.noUpdateArchives')
: undefined,
}]
: []),
{ label: '', divider: true, onClick: () => {} },
{
label: t('archives.menu.preview3d'),
@@ -983,6 +1007,42 @@ function ArchiveCard({
{archive.status === 'aborted' ? t('archives.card.cancelled') : t('archives.card.failed')}
</div>
)}
{/* Outcome-confirmation badges (#1898): a completed print either
waiting for its verdict (click to answer, for whoever may update
the archive; a status only for anyone else) or marked as reject.
Same spot as the failed badge — the three are mutually exclusive. */}
{archive.status === 'completed' && archive.confirm_requested && archive.user_verdict == null && (
<button
onClick={(e) => {
e.stopPropagation();
setShowConfirmOutcome(true);
}}
disabled={!canModify('archives', 'update', archive.created_by_id)}
className="absolute top-2 left-12 px-2 py-1 rounded text-xs bg-amber-500/90 enabled:hover:bg-amber-500 text-black flex items-center gap-1 transition-colors enabled:cursor-pointer"
title={!canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : t('archives.card.confirmPendingTitle')}
>
<ThumbsUp className="w-3 h-3" />
{t('archives.card.confirmPending')}
</button>
)}
{archive.status === 'completed' && archive.user_verdict === 'reject' && (
<div
className="absolute top-2 left-12 px-2 py-1 rounded text-xs bg-status-error/80 text-white flex items-center gap-1"
title={`${t('archives.card.rejectedTitle')}${verdictSourceHintKey ? ` — ${t(verdictSourceHintKey)}` : ''}`}
>
<ThumbsDown className="w-3 h-3" />
{t('archives.card.rejected')}
</div>
)}
{archive.status === 'completed' && archive.user_verdict === 'good' && (
<div
className="absolute top-2 left-12 px-2 py-1 rounded text-xs bg-bambu-green/80 text-white flex items-center gap-1"
title={`${t('archives.card.confirmedGoodTitle')}${verdictSourceHintKey ? ` — ${t(verdictSourceHintKey)}` : ''}`}
>
<ThumbsUp className="w-3 h-3" />
{t('archives.card.confirmedGood')}
</div>
)}
{/* Duplicate badge */}
{archive.duplicate_count > 0 && duplicateSequence > 0 && originalArchiveId && (
<button
@@ -1460,6 +1520,14 @@ function ArchiveCard({
/>
)}
{/* Post-print outcome confirmation (#1898) */}
{showConfirmOutcome && (
<ConfirmOutcomeDialog
archiveId={archive.id}
onClose={() => setShowConfirmOutcome(false)}
/>
)}
{/* Slice Modal */}
{showSliceModal && (
<SliceModal
@@ -1791,6 +1859,8 @@ function ArchiveListRow({
});
const navigate = useNavigate();
const [showReprint, setShowReprint] = useState(false);
// Post-print outcome confirmation dialog (#1898)
const [showConfirmOutcome, setShowConfirmOutcome] = useState(false);
const [showSliceModal, setShowSliceModal] = useState(false);
const [showRunPipeline, setShowRunPipeline] = useState(false);
const [showTimelapse, setShowTimelapse] = useState(false);
@@ -1811,6 +1881,9 @@ function ArchiveListRow({
// Use pre-computed duplicate sequence and original archive ID from list response
const duplicateSequence = archive.duplicate_sequence ?? 0;
// Appended to the verdict badge's tooltip (#1898) so a verdict the
// plate-clear default recorded can be explained where it is shown.
const verdictSourceHintKey = verdictSourceKey(archive.user_verdict_source);
const originalArchiveId = archive.original_archive_id ?? null;
// 3D Preview click handler. Multi-plate archives show the plate picker
@@ -2033,6 +2106,21 @@ function ArchiveListRow({
},
disabled: !archive.external_url && !archive.makerworld_url,
},
// Post-print outcome confirmation (#1898): completed prints only — the
// machine statuses already cover everything else.
...(archive.status === 'completed'
? [{
label: t('archives.menu.confirmOutcome'),
icon: archive.user_verdict === 'reject'
? <ThumbsDown className="w-4 h-4" />
: <ThumbsUp className="w-4 h-4" />,
onClick: () => setShowConfirmOutcome(true),
disabled: !canModify('archives', 'update', archive.created_by_id),
title: !canModify('archives', 'update', archive.created_by_id)
? t('archives.permission.noUpdateArchives')
: undefined,
}]
: []),
{ label: '', divider: true, onClick: () => {} },
{
label: t('archives.menu.preview3d'),
@@ -2298,6 +2386,36 @@ function ArchiveListRow({
{archive.status === 'aborted' ? t('archives.card.cancelled') : t('archives.card.failed')}
</span>
)}
{/* Outcome-confirmation badges (#1898), compact list variants */}
{archive.status === 'completed' && archive.confirm_requested && archive.user_verdict == null && (
<button
onClick={(e) => {
e.stopPropagation();
setShowConfirmOutcome(true);
}}
disabled={!canModify('archives', 'update', archive.created_by_id)}
className="px-1.5 py-0.5 rounded text-[10px] leading-tight bg-amber-500/90 enabled:hover:bg-amber-500 text-black flex-shrink-0 transition-colors"
title={!canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : t('archives.card.confirmPendingTitle')}
>
{t('archives.card.confirmPending')}
</button>
)}
{archive.status === 'completed' && archive.user_verdict === 'reject' && (
<span
className="px-1.5 py-0.5 rounded text-[10px] leading-tight bg-status-error/80 text-white flex-shrink-0"
title={`${t('archives.card.rejectedTitle')}${verdictSourceHintKey ? ` — ${t(verdictSourceHintKey)}` : ''}`}
>
{t('archives.card.rejected')}
</span>
)}
{archive.status === 'completed' && archive.user_verdict === 'good' && (
<span
className="px-1.5 py-0.5 rounded text-[10px] leading-tight bg-bambu-green/80 text-white flex-shrink-0"
title={`${t('archives.card.confirmedGoodTitle')}${verdictSourceHintKey ? ` — ${t(verdictSourceHintKey)}` : ''}`}
>
{t('archives.card.confirmedGood')}
</span>
)}
{archive.duplicate_count > 0 && duplicateSequence > 0 && originalArchiveId && (
<button
onClick={(e) => {
@@ -2516,6 +2634,14 @@ function ArchiveListRow({
/>
)}
{/* Post-print outcome confirmation (#1898) */}
{showConfirmOutcome && (
<ConfirmOutcomeDialog
archiveId={archive.id}
onClose={() => setShowConfirmOutcome(false)}
/>
)}
{/* Slice Modal */}
{showSliceModal && (
<SliceModal
@@ -2837,6 +2963,10 @@ export function ArchivesPage() {
const [hideFailed, setHideFailed] = useState(() =>
localStorage.getItem('archiveHideFailed') === 'true'
);
// Only archives still waiting for their outcome verdict (#1898)
const [filterUnconfirmed, setFilterUnconfirmed] = useState(() =>
localStorage.getItem('archiveFilterUnconfirmed') === 'true'
);
const [hideDuplicates, setHideDuplicates] = useState(() =>
localStorage.getItem('archiveHideDuplicates') === 'true'
);
@@ -3314,6 +3444,10 @@ export function ArchivesPage() {
localStorage.setItem('archiveHideFailed', hideFailed.toString());
}, [hideFailed]);
useEffect(() => {
localStorage.setItem('archiveFilterUnconfirmed', filterUnconfirmed.toString());
}, [filterUnconfirmed]);
useEffect(() => {
localStorage.setItem('archiveHideDuplicates', hideDuplicates.toString());
}, [hideDuplicates]);
@@ -3333,7 +3467,7 @@ export function ArchivesPage() {
// Reset page when filters/search/sort/collection change
useEffect(() => {
setPageIndex(0);
}, [search, filterPrinter, filterMaterial, filterColors, colorFilterMode, filterFavorites, hideFailed, hideDuplicates, filterTag, filterFileType, sortBy, collection]);
}, [search, filterPrinter, filterMaterial, filterColors, colorFilterMode, filterFavorites, hideFailed, filterUnconfirmed, hideDuplicates, filterTag, filterFileType, sortBy, collection]);
useEffect(() => {
try { localStorage.setItem('archivePageSize', String(pageSize)); } catch { /* ignore */ }
@@ -3466,6 +3600,10 @@ export function ArchivesPage() {
// Hide failed filter (don't apply when viewing failed collection)
const matchesHideFailed = collection === 'failed' || !hideFailed || (a.status !== 'failed' && a.status !== 'aborted');
// Unconfirmed-outcome filter (#1898): archives that asked for a verdict
// and are still waiting for one.
const matchesUnconfirmed = !filterUnconfirmed || (a.confirm_requested && a.user_verdict == null);
// Hide duplicates filter (don't apply when viewing duplicates collection)
const matchesHideDuplicates =
collection === 'duplicates' || !hideDuplicates || a.duplicate_count === 0 || a.duplicate_sequence === 0;
@@ -3480,7 +3618,7 @@ export function ArchivesPage() {
(filterFileType === 'gcode' && isGcodeFile) ||
(filterFileType === 'source' && !isGcodeFile);
return matchesCollection && matchesSearch && matchesMaterial && matchesColor && matchesFavorites && matchesHideFailed && matchesHideDuplicates && matchesTag && matchesFileType;
return matchesCollection && matchesSearch && matchesMaterial && matchesColor && matchesFavorites && matchesHideFailed && matchesUnconfirmed && matchesHideDuplicates && matchesTag && matchesFileType;
})
.sort((a, b) => {
switch (sortBy) {
@@ -3569,12 +3707,13 @@ export function ArchivesPage() {
setFilterMaterial(null);
setFilterFavorites(false);
setHideFailed(false);
setFilterUnconfirmed(false);
setHideDuplicates(false);
setFilterTag(null);
setFilterFileType('all');
};
const hasTopFilters = search || filterPrinter || filterMaterial || filterFavorites || hideFailed || hideDuplicates || filterTag || filterFileType !== 'all';
const hasTopFilters = search || filterPrinter || filterMaterial || filterFavorites || hideFailed || filterUnconfirmed || hideDuplicates || filterTag || filterFileType !== 'all';
// Page-wide drag-and-drop upload (#1510). The hook covers the three cancel
// paths the previous inline implementation missed (drag-out-of-window, Escape,
@@ -4015,6 +4154,18 @@ export function ArchivesPage() {
<AlertCircle className={`w-4 h-4 ${hideFailed ? '' : ''}`} />
<span className="text-sm hidden md:inline">Hide Failed</span>
</button>
<button
onClick={() => setFilterUnconfirmed(!filterUnconfirmed)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors flex-shrink-0 ${
filterUnconfirmed
? 'bg-amber-100 dark:bg-amber-500/20 border-amber-500 text-amber-700 dark:text-amber-400'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
title={t('archives.filterUnconfirmedTitle')}
>
<ThumbsUp className="w-4 h-4" />
<span className="text-sm hidden md:inline">{t('archives.filterUnconfirmed')}</span>
</button>
<button
onClick={() => setHideDuplicates(!hideDuplicates)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors flex-shrink-0 ${
+88 -14
View File
@@ -140,7 +140,10 @@ import {
MonitorPlay,
ExternalLink,
PictureInPicture2,
ThumbsUp,
ThumbsDown,
} from 'lucide-react';
import { ConfirmOutcomeDialog } from '../components/ConfirmOutcomeDialog';
// Aliased: lucide-react already exports a `Link` icon into this module.
import { Link as RouterLink, useNavigate } from 'react-router-dom';
@@ -2164,7 +2167,7 @@ function PrinterCard({
const queryClient = useQueryClient();
const navigate = useNavigate();
const { showToast } = useToast();
const { hasPermission } = useAuth();
const { hasPermission, canModify } = useAuth();
const [showMenu, setShowMenu] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteArchives, setDeleteArchives] = useState(true);
@@ -2655,6 +2658,21 @@ function PrinterCard({
const lastPrint = lastPrints?.[0];
const isPrintingOrPaused = status?.state === 'RUNNING' || status?.state === 'PAUSE';
const needsPlateClear = requirePlateClear && status?.awaiting_plate_clear === true;
// Post-print outcome confirmation on the card (#1898): while the plate-clear
// gate is up and the just-finished print asked for a verdict, offer
// Good/Reject right where the operator releases the plate. Releasing the
// plate without answering stays possible — the two are orthogonal (though
// the confirm_default_good_on_plate_clear setting can couple them).
// Only offered to whoever may record it: the PATCH checks archives:update
// against the archive's owner, so for anyone else the pair could only fail.
const pendingConfirmArchive =
lastPrint &&
lastPrint.status === 'completed' &&
lastPrint.confirm_requested &&
lastPrint.user_verdict == null &&
canModify('archives', 'update', lastPrint.created_by_id)
? lastPrint
: null;
// Not gated on `connected`: the plate-clear gate is Bambuddy-side state, and with
// Auto Power Off the printer is powered down exactly when the operator clears the
// plate. Hiding the control there left no way to release the gate (#2864).
@@ -2871,24 +2889,80 @@ function PrinterCard({
onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
});
// Post-print outcome confirmation (#1898): one-tap "good" from the card,
// reject goes through the dialog for the optional reason + reprint.
const [showConfirmOutcome, setShowConfirmOutcome] = useState(false);
// Only "good" is answered on the card. A reject wants a reason and possibly
// a reprint, so that button opens the dialog instead — and the parameter
// says as much, rather than accepting a verdict this path cannot send and
// then reporting it as good.
const cardVerdictMutation = useMutation({
mutationFn: (verdict: 'good') =>
api.updateArchive(pendingConfirmArchive!.id, {
user_verdict: verdict,
user_verdict_source: 'printer_card',
}),
onSuccess: () => {
showToast(t('confirmOutcome.savedGood'), 'success');
queryClient.invalidateQueries({ queryKey: ['archives'] });
},
onError: (error: Error) => showToast(error.message || t('confirmOutcome.saveFailed'), 'error'),
});
// Rendered from two places: inside the live-status block for a connected printer,
// and standalone below it for a powered-down one, whose status block isn't rendered
// at all (#2864). Shared so the two can't drift apart.
const expandedClearPlateButton = (
<button
type="button"
onClick={() => clearPlateMutation.mutate()}
disabled={clearPlateMutation.isPending || !hasPermission('printers:clear_plate')}
className="mt-2 w-full inline-flex items-center justify-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-300 dark:border-yellow-400/40 text-yellow-700 dark:text-yellow-400 hover:bg-yellow-500/30 transition-colors text-xs font-medium disabled:opacity-50"
title={!hasPermission('printers:clear_plate') ? t('printers.permission.noControl') : t('printers.plateStatus.markCleared')}
>
{clearPlateMutation.isPending ? (
<Loader2 className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] animate-spin" />
) : (
<PlateClearedIcon className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
<>
{pendingConfirmArchive && (
<div className="mt-2 flex items-center gap-1.5">
<span
className="flex-1 min-w-0 truncate text-xs text-bambu-gray"
title={pendingConfirmArchive.print_name || pendingConfirmArchive.filename}
>
{t('confirmOutcome.cardPrompt')}
</span>
<button
type="button"
onClick={() => setShowConfirmOutcome(true)}
disabled={cardVerdictMutation.isPending}
className="p-1.5 rounded-lg border border-red-500/40 text-red-500 hover:bg-red-500/10 transition-colors"
title={t('confirmOutcome.reject')}
>
<ThumbsDown className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
</button>
<button
type="button"
onClick={() => cardVerdictMutation.mutate('good')}
disabled={cardVerdictMutation.isPending}
className="p-1.5 rounded-lg border border-bambu-green/50 text-bambu-green hover:bg-bambu-green/10 transition-colors"
title={t('confirmOutcome.good')}
>
<ThumbsUp className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
</button>
</div>
)}
{t('printers.plateStatus.markCleared')}
</button>
<button
type="button"
onClick={() => clearPlateMutation.mutate()}
disabled={clearPlateMutation.isPending || !hasPermission('printers:clear_plate')}
className="mt-2 w-full inline-flex items-center justify-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-300 dark:border-yellow-400/40 text-yellow-700 dark:text-yellow-400 hover:bg-yellow-500/30 transition-colors text-xs font-medium disabled:opacity-50"
title={!hasPermission('printers:clear_plate') ? t('printers.permission.noControl') : t('printers.plateStatus.markCleared')}
>
{clearPlateMutation.isPending ? (
<Loader2 className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] animate-spin" />
) : (
<PlateClearedIcon className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
)}
{t('printers.plateStatus.markCleared')}
</button>
{showConfirmOutcome && pendingConfirmArchive && (
<ConfirmOutcomeDialog
archiveId={pendingConfirmArchive.id}
onClose={() => setShowConfirmOutcome(false)}
/>
)}
</>
);
const nozzleTemperatureMutation = useMutation({
+14 -1
View File
@@ -62,6 +62,7 @@ import {
Ban,
PlayCircle,
Workflow,
ThumbsUp,
} from 'lucide-react';
import { api, ApiError } from '../api/client';
import { PipelineRunsView } from './PipelineRunsPage';
@@ -156,6 +157,7 @@ function BulkEditModal({
const [layerInspect, setLayerInspect] = useState<boolean | 'unchanged'>('unchanged');
const [timelapse, setTimelapse] = useState<boolean | 'unchanged'>('unchanged');
const [useAms, setUseAms] = useState<boolean | 'unchanged'>('unchanged');
const [confirmOutcome, setConfirmOutcome] = useState<boolean | 'unchanged'>('unchanged');
const [nozzleOffsetCali, setNozzleOffsetCali] = useState<CalibrationMode | 'unchanged'>('unchanged');
// Show the dual-nozzle-only toggle when the user has at least one
@@ -176,6 +178,7 @@ function BulkEditModal({
if (layerInspect !== 'unchanged') data.layer_inspect = layerInspect;
if (timelapse !== 'unchanged') data.timelapse = timelapse;
if (useAms !== 'unchanged') data.use_ams = useAms;
if (confirmOutcome !== 'unchanged') data.confirm_outcome = confirmOutcome;
if (nozzleOffsetCali !== 'unchanged') data.nozzle_offset_cali = nozzleOffsetCali;
onSave(data);
};
@@ -183,7 +186,7 @@ function BulkEditModal({
const hasChanges = printerId !== 'unchanged' || manualStart !== 'unchanged' || autoOffAfter !== 'unchanged' ||
requirePreviousSuccess !== 'unchanged' || bedLevelling !== 'unchanged' || flowCali !== 'unchanged' ||
vibrationCali !== 'unchanged' || layerInspect !== 'unchanged' || timelapse !== 'unchanged' || useAms !== 'unchanged' ||
nozzleOffsetCali !== 'unchanged' || gcodeInjection !== 'unchanged';
confirmOutcome !== 'unchanged' || nozzleOffsetCali !== 'unchanged' || gcodeInjection !== 'unchanged';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
@@ -249,6 +252,7 @@ function BulkEditModal({
<TriStateToggle label={t('queue.bulkEdit.layerInspection')} value={layerInspect} onChange={setLayerInspect} t={t} />
<TriStateToggle label={t('queue.bulkEdit.timelapse')} value={timelapse} onChange={setTimelapse} t={t} />
<TriStateToggle label={t('queue.bulkEdit.useAms')} value={useAms} onChange={setUseAms} t={t} />
<TriStateToggle label={t('queue.bulkEdit.confirmOutcome')} value={confirmOutcome} onChange={setConfirmOutcome} t={t} />
{hasDualNozzlePrinter && (
<CalibrationModeToggle label={t('queue.bulkEdit.nozzleOffsetCali')} value={nozzleOffsetCali} onChange={setNozzleOffsetCali} t={t} />
)}
@@ -716,6 +720,15 @@ function SortableQueueItem({
{t('queue.badges.gcodeInjection')}
</span>
)}
{item.confirm_outcome && (
<span
title={t('queue.badges.confirmOutcomeTitle')}
className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-green-50 dark:bg-green-500/10 text-green-700 dark:text-green-400 rounded-full border border-green-200 dark:border-green-500/20 flex items-center gap-1"
>
<ThumbsUp className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
{t('queue.badges.confirmOutcome')}
</span>
)}
</div>
{/* Progress bar for printing items - TODO: integrate with WebSocket */}
+49
View File
@@ -1200,6 +1200,9 @@ export function SettingsPage() {
(baseline.default_layer_inspect ?? false) !== (localSettings.default_layer_inspect ?? false) ||
(baseline.default_timelapse ?? false) !== (localSettings.default_timelapse ?? false) ||
(baseline.default_nozzle_offset_cali ?? 'auto') !== (localSettings.default_nozzle_offset_cali ?? 'auto') ||
(baseline.default_confirm_outcome ?? false) !== (localSettings.default_confirm_outcome ?? false) ||
(baseline.confirm_outcome_external_prints ?? false) !== (localSettings.confirm_outcome_external_prints ?? false) ||
(baseline.confirm_default_good_on_plate_clear ?? false) !== (localSettings.confirm_default_good_on_plate_clear ?? false) ||
(baseline.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) ||
(baseline.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) ||
(baseline.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false) ||
@@ -1312,6 +1315,9 @@ export function SettingsPage() {
default_layer_inspect: localSettings.default_layer_inspect,
default_timelapse: localSettings.default_timelapse,
default_nozzle_offset_cali: localSettings.default_nozzle_offset_cali,
default_confirm_outcome: localSettings.default_confirm_outcome,
confirm_outcome_external_prints: localSettings.confirm_outcome_external_prints,
confirm_default_good_on_plate_clear: localSettings.confirm_default_good_on_plate_clear,
stagger_group_size: localSettings.stagger_group_size,
stagger_interval_minutes: localSettings.stagger_interval_minutes,
require_plate_clear: localSettings.require_plate_clear,
@@ -4939,6 +4945,7 @@ export function SettingsPage() {
{ key: 'default_layer_inspect' as const, label: t('settings.defaultLayerInspect', 'First Layer Inspection'), desc: t('settings.defaultLayerInspectDesc', 'AI inspection of first layer'), fallback: false, dualNozzleOnly: false, tristate: false },
{ key: 'default_timelapse' as const, label: t('settings.defaultTimelapse', 'Timelapse'), desc: t('settings.defaultTimelapseDesc', 'Record timelapse video'), fallback: false, dualNozzleOnly: false, tristate: false },
{ key: 'default_nozzle_offset_cali' as const, label: t('settings.defaultNozzleOffsetCali', 'Nozzle Offset Calibration'), desc: t('settings.defaultNozzleOffsetCaliDesc', 'Calibrate nozzle offsets between extruders'), fallback: true, dualNozzleOnly: true, tristate: true },
{ key: 'default_confirm_outcome' as const, label: t('settings.defaultConfirmOutcome', 'Ask for Outcome'), desc: t('settings.defaultConfirmOutcomeDesc', 'Ask whether the print came out well after it completes'), fallback: false, dualNozzleOnly: false, tristate: false },
]
.filter(({ dualNozzleOnly }) => !dualNozzleOnly || (printers || []).some(p => p.nozzle_count === 2))
.map(({ key, label, desc, fallback, tristate }) => (
@@ -4990,6 +4997,27 @@ export function SettingsPage() {
)}
</div>
))}
{/* Prints Bambuddy only archived, never dispatched, carry no
queue item to read the ask-for-outcome flag from (#1898). */}
<div className="flex items-center justify-between">
<div className="flex-1 mr-4">
<p className="text-sm text-white">
{t('settings.confirmOutcomeExternalPrints', 'Also ask for prints started outside Bambuddy')}
</p>
<p className="text-xs text-bambu-gray mt-0.5">
{t('settings.confirmOutcomeExternalPrintsDesc', 'Prints started at the printer, in Bambu Studio or in the Handy app are archived by Bambuddy too. With this on, they get the same outcome prompt as queued prints.')}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer shrink-0">
<input
type="checkbox"
checked={localSettings.confirm_outcome_external_prints ?? false}
onChange={(e) => updateSetting('confirm_outcome_external_prints', e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
</CardContent>
</Card>
@@ -5021,6 +5049,27 @@ export function SettingsPage() {
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
{/* Post-print outcome confirmation (#1898): default unanswered
prompts to "good" the moment the plate is released. */}
<div className="flex items-center justify-between">
<div className="flex-1 mr-4">
<p className="text-sm text-white">
{t('settings.confirmDefaultGoodOnPlateClear', 'Count unanswered outcomes as good on plate release')}
</p>
<p className="text-xs text-bambu-gray mt-1">
{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.')}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localSettings.confirm_default_good_on_plate_clear ?? false}
onChange={(e) => updateSetting('confirm_default_good_on_plate_clear', e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
</CardContent>
</Card>
+13
View File
@@ -787,6 +787,19 @@ function FailureAnalysisWidget({ size = 1, dateFrom, dateTo, createdById }: {
<div className="text-sm text-bambu-gray mt-1">
{t('stats.failedPrintsCount', { failed: analysis.failed_prints, total: analysis.total_prints })}
</div>
{/* 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 && (
<div className="text-sm mt-1">
<span className="text-status-warning">
{t('stats.rejectedPrintsCount', { rejected: analysis.rejected_prints })}
</span>
<span className="text-bambu-gray ml-2">
{t('stats.yieldRate', { rate: (analysis.yield_rate ?? 0).toFixed(1) })}
</span>
</div>
)}
{/* Trend indicator */}
{analysis.trend && analysis.trend.length >= 2 && (
<div className={`${size >= 2 ? 'mt-4' : 'mt-2 pt-2 border-t border-bambu-dark-tertiary'}`}>
+27
View File
@@ -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<VerdictSource, string> = {
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;
}