print_confirmation.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. """Post-print outcome confirmation helpers (#1898)."""
  2. import logging
  3. from collections.abc import Mapping
  4. from datetime import datetime, timezone
  5. from sqlalchemy import and_, or_, select
  6. from sqlalchemy.ext.asyncio import AsyncSession
  7. from backend.app.models.archive import PrintArchive
  8. from backend.app.models.print_log import PrintLogEntry
  9. logger = logging.getLogger(__name__)
  10. # How a verdict reached the archive. 'reaction' is written by the Telegram
  11. # reaction poller (#3046, services/telegram_reactions.py) through
  12. # apply_outcome_verdict below.
  13. VERDICT_SOURCES = ("dialog", "link", "plate_clear", "printer_card", "api", "reaction")
  14. # Link-preview unfurlers and mail-security scanners fetch every URL they find in
  15. # a message, unattended, within seconds of it being sent. Nothing they can do
  16. # with a GET records a verdict any more -- that is the POST route's job -- so
  17. # this list is the second layer: it decides whether the confirmation page
  18. # submits its own form, which is what keeps a human at one tap. A scanner that
  19. # runs JavaScript would otherwise press the button on the operator's behalf.
  20. # Matched as case-insensitive substrings of the User-Agent; the generic "bot"
  21. # token covers TelegramBot, Discordbot, Slackbot-LinkExpanding, Twitterbot and
  22. # LinkedInBot in one go.
  23. UNATTENDED_FETCH_AGENTS = (
  24. "bot",
  25. "crawler",
  26. "spider",
  27. "facebookexternalhit",
  28. "whatsapp",
  29. "skypeuripreview",
  30. "bingpreview",
  31. "safelinks",
  32. "urldefense",
  33. "proofpoint",
  34. "mimecast",
  35. "barracuda",
  36. "forcepoint",
  37. )
  38. # Prefetch / preload hints. A finger on a notification button is never one.
  39. UNATTENDED_FETCH_HEADERS = {
  40. "purpose": ("prefetch", "preview"),
  41. "x-purpose": ("prefetch", "preview"),
  42. "x-moz": ("prefetch",),
  43. "sec-purpose": ("prefetch",),
  44. }
  45. def is_unattended_fetch(method: str, headers: Mapping[str, str]) -> bool:
  46. """Whether a request for a one-tap verdict link came from a machine.
  47. Decides whether the confirmation page submits itself. False positives are
  48. deliberately cheap -- a browser mistaken for a bot gets the same page with
  49. a button to press -- so the lists above err towards catching more.
  50. """
  51. if method.upper() != "GET":
  52. # Anything that is not the page load is not a page load: only the GET
  53. # route renders, and only a GET can be widened to HEAD by a future
  54. # router change.
  55. return True
  56. for header, markers in UNATTENDED_FETCH_HEADERS.items():
  57. value = (headers.get(header) or "").lower()
  58. if value and any(marker in value for marker in markers):
  59. return True
  60. agent = (headers.get("user-agent") or "").lower()
  61. return any(marker in agent for marker in UNATTENDED_FETCH_AGENTS)
  62. # The one-tap marker. The confirmation page submits its own form only when the
  63. # URL it was opened from carries this, and the marker is put on exactly one
  64. # thing: the Telegram inline keyboard's buttons -- an affordance no unfurler,
  65. # gateway or proxy reads, for the same reason the capability URLs themselves no
  66. # longer travel in message text.
  67. #
  68. # It is what closes the gap the User-Agent list above cannot: a mail-security
  69. # sandbox that renders HTML and runs JavaScript sends an ordinary Chrome string
  70. # (so does literal HeadlessChrome), and a verdict URL that reached it did so out
  71. # of the message BODY -- where the marker never appears. That fetch now gets the
  72. # page with a button on it and records nothing. The operator's tap on the
  73. # notification button still costs exactly one tap.
  74. ONE_TAP_PARAM = "tap"
  75. def one_tap_url(url: str) -> str:
  76. """Mark a verdict URL as one a human is about to press.
  77. Only for the affordances a person taps directly. A URL that goes into text
  78. anybody's machine might follow is left unmarked on purpose.
  79. """
  80. return f"{url}{'&' if '?' in url else '?'}{ONE_TAP_PARAM}=1"
  81. def is_one_tap_request(query_params: Mapping[str, str]) -> bool:
  82. """Whether this page load came from a button rather than from message text."""
  83. return (query_params.get(ONE_TAP_PARAM) or "") == "1"
  84. def stamp_verdict(archive: PrintArchive | PrintLogEntry, source: str) -> None:
  85. """Record a verdict's provenance and the moment it landed (#1898).
  86. Both fields move together on every verdict write, which is what keeps the
  87. "already answered" page from pairing a new source with the timestamp of an
  88. older decision. `retire_confirm_token` is separate on purpose: spending the
  89. one-tap capability happens once, recording a verdict can happen again.
  90. """
  91. archive.user_verdict_source = source
  92. archive.user_verdict_at = datetime.now(timezone.utc)
  93. def retire_confirm_token(archive: PrintArchive | PrintLogEntry) -> None:
  94. """Spend the one-tap capability token without destroying it.
  95. The token is still single-use: once ``confirm_token_used_at`` is stamped,
  96. no verdict path accepts it again. Keeping the VALUE is what lets the
  97. one-tap route recognise a link belonging to an already-answered print and
  98. say so, instead of 404ing as if the link had never been real (the live-farm
  99. case: the plate-clear default answered the prompt, then the user tapped the
  100. Telegram button and got "invalid or already used").
  101. """
  102. if archive.confirm_token and archive.confirm_token_used_at is None:
  103. archive.confirm_token_used_at = datetime.now(timezone.utc)
  104. VERDICTS = ("good", "reject")
  105. async def latest_run(db: AsyncSession, archive_id: int) -> PrintLogEntry | None:
  106. """The archive's latest finished run by its start: the one its card shows."""
  107. return await db.scalar(
  108. select(PrintLogEntry)
  109. .where(PrintLogEntry.archive_id == archive_id)
  110. .order_by(PrintLogEntry.started_at.desc().nulls_last(), PrintLogEntry.id.desc())
  111. .limit(1)
  112. )
  113. async def run_of_archive(db: AsyncSession, archive_id: int, run_id: int) -> PrintLogEntry | None:
  114. """The archive's finished run ``run_id``, or None when it is not one."""
  115. return await db.scalar(
  116. select(PrintLogEntry).where(PrintLogEntry.id == run_id, PrintLogEntry.archive_id == archive_id)
  117. )
  118. async def record_verdict(
  119. db: AsyncSession,
  120. archive: PrintArchive,
  121. verdict: str | None,
  122. *,
  123. source: str | None,
  124. run: PrintLogEntry | None,
  125. ) -> None:
  126. """Write a verdict, or clear it (``verdict`` None), on the run it answers.
  127. Plates of one archive are runs of their own, each with its own question,
  128. so the verdict is the run's: its value, provenance and its one-tap token
  129. spent. The archive shows its latest run's, so it takes the verdict when
  130. ``run`` is that run, or when there is no run (a print logged before runs).
  131. Clearing drops the provenance but leaves the tokens spent: they were used.
  132. Deliberately does NOT commit.
  133. """
  134. holders: list[PrintArchive | PrintLogEntry] = []
  135. if run is not None:
  136. holders.append(run)
  137. if run is None or (latest := await latest_run(db, archive.id)) is None or latest.id == run.id:
  138. holders.append(archive)
  139. for holder in holders:
  140. holder.user_verdict = verdict
  141. if verdict is None:
  142. holder.user_verdict_source = None
  143. holder.user_verdict_at = None
  144. else:
  145. stamp_verdict(holder, source or "api")
  146. retire_confirm_token(holder)
  147. async def apply_outcome_verdict(
  148. db: AsyncSession,
  149. archive: PrintArchive,
  150. verdict: str,
  151. *,
  152. source: str,
  153. run: PrintLogEntry | None = None,
  154. ) -> bool:
  155. """Record a verdict on a print that has not been answered yet.
  156. For verdict paths that arrive with nobody looking at the archive, like the
  157. Telegram reaction poller (#3046). ``run`` is the run the prompt asked
  158. about; without one, the archive's latest (a prompt sent before runs had
  159. their own). Written by :func:`record_verdict`.
  160. First verdict wins. A print that already carries one is left exactly as
  161. it is and False is returned, so a late reaction cannot flip a decision
  162. somebody made in the meantime. Deliberately does NOT commit.
  163. """
  164. if verdict not in VERDICTS:
  165. raise ValueError(f"Verdict must be one of {VERDICTS}, got {verdict!r}")
  166. if source not in VERDICT_SOURCES:
  167. raise ValueError(f"Verdict source must be one of {VERDICT_SOURCES}, got {source!r}")
  168. if run is None:
  169. if archive.user_verdict is not None:
  170. return False
  171. run = await latest_run(db, archive.id)
  172. elif run.user_verdict is not None:
  173. return False
  174. await record_verdict(db, archive, verdict, source=source, run=run)
  175. return True
  176. async def confirm_token_holder(db: AsyncSession, token: str) -> tuple[PrintArchive, PrintLogEntry | None] | None:
  177. """The print a one-tap capability token asks about: the run it was minted
  178. for, with its archive, or an archive's own token (minted before runs had
  179. theirs, answering its latest run). None for a token nobody holds."""
  180. if not token:
  181. return None
  182. run = await db.scalar(select(PrintLogEntry).where(PrintLogEntry.confirm_token == token))
  183. if run is not None:
  184. archive = await db.get(PrintArchive, run.archive_id) if run.archive_id is not None else None
  185. return (archive, run) if archive is not None else None
  186. archive = await db.scalar(select(PrintArchive).where(PrintArchive.confirm_token == token))
  187. return (archive, None) if archive is not None else None
  188. async def resolve_pending_confirmation_as_good(db: AsyncSession, printer_id: int) -> int | None:
  189. """Mark the printer's latest print awaiting its verdict as good.
  190. Backs the opt-in ``confirm_default_good_on_plate_clear`` setting: releasing
  191. the build plate is the moment the operator moves on to the next job, so an
  192. unanswered outcome prompt can default to "good part" right there instead of
  193. lingering as unconfirmed. Only the LATEST pending one is resolved -- the
  194. plate release refers to the print that just came off the plate, not to
  195. older unanswered prompts. Found by the run's printer: a plate of the
  196. archive printed since on another printer moved the archive there. A run
  197. logged before runs carried the opt-in asks, and is answered, as its
  198. archive is.
  199. Retires the one-tap capability token (:func:`record_verdict`). Deliberately
  200. does NOT commit -- both callers (the clear-plate route and the queue
  201. dispatcher) manage their own transaction.
  202. Returns the resolved archive id, or None when nothing was pending.
  203. """
  204. run = await db.scalar(
  205. select(PrintLogEntry)
  206. .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
  207. .where(
  208. PrintLogEntry.printer_id == printer_id,
  209. PrintLogEntry.status == "completed",
  210. PrintLogEntry.user_verdict.is_(None),
  211. or_(
  212. PrintLogEntry.confirm_requested.is_(True),
  213. and_(
  214. PrintLogEntry.confirm_requested.is_(None),
  215. PrintArchive.confirm_requested.is_(True),
  216. PrintArchive.user_verdict.is_(None),
  217. ),
  218. ),
  219. )
  220. .order_by(PrintLogEntry.started_at.desc().nulls_last(), PrintLogEntry.id.desc())
  221. .limit(1)
  222. )
  223. archive = await db.get(PrintArchive, run.archive_id) if run is not None else None
  224. if archive is None:
  225. # A printer with no such run logged: its archive, as before runs.
  226. archive = await db.scalar(
  227. select(PrintArchive)
  228. .where(
  229. PrintArchive.printer_id == printer_id,
  230. PrintArchive.status == "completed",
  231. PrintArchive.confirm_requested.is_(True),
  232. PrintArchive.user_verdict.is_(None),
  233. )
  234. .order_by(PrintArchive.id.desc())
  235. .limit(1)
  236. )
  237. if archive is None:
  238. return None
  239. run = await latest_run(db, archive.id)
  240. await record_verdict(db, archive, "good", source="plate_clear", run=run)
  241. logger.info("[#1898] Plate clear defaulted archive %s to 'good' (printer %s)", archive.id, printer_id)
  242. return archive.id
  243. async def confirm_outcome_for_new_queue_item(db: AsyncSession, *, started_outside_bambuddy: bool = False) -> bool:
  244. """The ask-for-outcome flag for a queue item created without the print dialog.
  245. The dialog seeds its own per-job toggle from ``default_confirm_outcome``.
  246. Every other queue-creation path -- the virtual printer, the library bulk
  247. add, the webhook, a pipeline run -- has no toggle to seed and used to leave
  248. the column at its ``False`` default, so "Ask for Outcome" only ever reached
  249. jobs queued by hand.
  250. ``started_outside_bambuddy`` additionally honours
  251. ``confirm_outcome_external_prints``: a plate sent from Bambu Studio to a
  252. virtual printer is one of the prints that setting's description names, but
  253. it arrives with a queue item, so ``on_print_start`` never sees it as
  254. external and the setting could not otherwise reach it.
  255. """
  256. from backend.app.api.routes.settings import get_setting, setting_is_true
  257. if setting_is_true(await get_setting(db, "default_confirm_outcome")):
  258. return True
  259. return started_outside_bambuddy and setting_is_true(await get_setting(db, "confirm_outcome_external_prints"))