labels.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. """Spool label printing routes (#809).
  2. Two endpoints, one per inventory backend:
  3. - ``POST /inventory/labels`` — local-DB spools
  4. - ``POST /spoolman/labels`` — Spoolman-backed spools
  5. Both accept ``{spool_ids: [int], template: str, starting_position: int}`` plus
  6. the lines to print (``fields``) and the output ``format``. ``template`` is a
  7. built-in name, or ``"custom"`` with the sheet itself in ``layout`` (#3296): a PDF, or PNGs for
  8. label-printer software that takes images — one PNG as is, several in a ZIP.
  9. Each has a ``/preview`` sibling that renders one label as a PNG for the
  10. picker (#2981).
  11. The QR code on each label deep-links to ``/inventory?spool=<id>`` so a phone
  12. scan jumps straight back into Bambuddy at that spool's row.
  13. """
  14. from __future__ import annotations
  15. import asyncio
  16. import io
  17. import logging
  18. import zipfile
  19. from datetime import date, datetime
  20. from typing import Annotated, Literal
  21. from fastapi import APIRouter, Depends, HTTPException, Request
  22. from fastapi.responses import Response, StreamingResponse
  23. from pydantic import BaseModel, Field, model_validator
  24. from sqlalchemy import select
  25. from sqlalchemy.ext.asyncio import AsyncSession
  26. from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
  27. from backend.app.api.routes.settings import get_setting
  28. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  29. from backend.app.core.database import get_db
  30. from backend.app.core.permissions import Permission
  31. from backend.app.models.spool import Spool
  32. from backend.app.models.user import User
  33. from backend.app.schemas.label_template import SheetLayoutSpec
  34. from backend.app.services.label_renderer import (
  35. ALL_LABEL_FIELDS,
  36. DEFAULT_LABEL_FIELDS,
  37. LabelData,
  38. LabelField,
  39. Template,
  40. TemplateName,
  41. get_sheet_capacity,
  42. pdf_to_pngs,
  43. render_label_preview_pdf,
  44. render_labels,
  45. sheet_page_count,
  46. )
  47. from backend.app.services.spoolman import get_spoolman_client
  48. from backend.app.utils.http import build_content_disposition
  49. logger = logging.getLogger(__name__)
  50. router = APIRouter(tags=["labels"])
  51. # Cap how many labels can be requested in one go. Sane upper bound for the
  52. # largest realistic batch (an Avery sheet at 30/page × ~10 pages).
  53. MAX_LABELS_PER_REQUEST = 500
  54. # PNG export of sheets: pages × dpi. A sheet page is a whole A4 or Letter
  55. # page, rasterised under the process-wide PDFium lock that PDF thumbnails also
  56. # wait on, so a custom one-label-per-page sheet of 500 spools at 600 dpi would
  57. # hold it for minutes. 15 000 is 25 pages at 600 dpi, 50 at 300 and 73 at 203;
  58. # the built-in sheets need at most 24 pages (500 spools on an L7160), so they
  59. # always fit. Mirrored in the dialog (labelSheetLayout.ts).
  60. MAX_PNG_SHEET_PAGES_X_DPI = 15_000
  61. # Preview resolution. High enough that the browser's downscale stays sharp on
  62. # a HiDPI screen; a label is a few centimetres, so the PNG stays small.
  63. PREVIEW_DPI = 300
  64. # Spoolman's mapping rejects an id below 1, so one never reaches it.
  65. SpoolId = Annotated[int, Field(gt=0)]
  66. class LabelOptions(BaseModel):
  67. template: TemplateName | Literal["custom"]
  68. # The sheet, when ``template`` is "custom" (#3296). A saved template is
  69. # sent this way too: the dialog holds its layout, so printing never reads
  70. # the saved-template table.
  71. layout: SheetLayoutSpec | None = None
  72. # Black-and-white thermal printers: drop the colour swatch (prints as a
  73. # muddy grey block) and widen the text column instead (#1870).
  74. monochrome: bool = False
  75. # The lines to print (#2981). Omitted: what labels always carried.
  76. fields: list[LabelField] | None = Field(default=None, max_length=len(ALL_LABEL_FIELDS))
  77. @model_validator(mode="after")
  78. def layout_only_for_custom(self) -> LabelOptions:
  79. if (self.template == "custom") != (self.layout is not None):
  80. raise ValueError('layout is required for template "custom" and not allowed otherwise')
  81. return self
  82. def field_set(self) -> frozenset[LabelField]:
  83. return DEFAULT_LABEL_FIELDS if self.fields is None else frozenset(self.fields)
  84. def resolved_template(self) -> Template:
  85. if self.layout is not None:
  86. return self.layout.to_layout()
  87. if self.template == "custom":
  88. # layout_only_for_custom refuses this, so a validated request never gets here.
  89. raise ValueError('layout is required for template "custom"')
  90. return self.template
  91. class LabelRequest(LabelOptions):
  92. spool_ids: list[SpoolId] = Field(..., min_length=1, max_length=MAX_LABELS_PER_REQUEST)
  93. starting_position: int = Field(default=1, ge=1)
  94. format: Literal["pdf", "png"] = "pdf"
  95. # PNG only. 203 and 300 are the common thermal-printer resolutions; an
  96. # image at the printer's own resolution is printed dot for dot.
  97. dpi: Literal[203, 300, 600] = 300
  98. @model_validator(mode="after")
  99. def validate_starting_position(self) -> LabelRequest:
  100. if (self.template == "custom") != (self.layout is not None):
  101. return self # layout_only_for_custom reports this one
  102. capacity = get_sheet_capacity(self.resolved_template())
  103. if capacity is None:
  104. if self.starting_position != 1:
  105. raise ValueError("starting_position is only supported for sheet label templates")
  106. return self
  107. if self.starting_position > capacity:
  108. raise ValueError(f"starting_position must be between 1 and {capacity} for template {self.template}")
  109. if self.format == "png":
  110. pages = sheet_page_count(self.resolved_template(), len(self.spool_ids), self.starting_position) or 0
  111. if pages * self.dpi > MAX_PNG_SHEET_PAGES_X_DPI:
  112. max_pages = MAX_PNG_SHEET_PAGES_X_DPI // self.dpi
  113. raise ValueError(
  114. f"PNG export is limited to {max_pages} sheet pages at {self.dpi} dpi, and this is {pages}. "
  115. "Use PDF, a lower resolution or fewer spools."
  116. )
  117. return self
  118. class LabelPreviewRequest(LabelOptions):
  119. spool_id: SpoolId
  120. def _split_extra_colors(raw: str | None) -> list[str] | None:
  121. """Parse ``Spool.extra_colors`` (comma-separated hex tokens) into a list."""
  122. if not raw:
  123. return None
  124. parts = [p.strip().lstrip("#") for p in raw.split(",") if p.strip()]
  125. return parts or None
  126. async def _resolve_deeplink_base(request: Request, db: AsyncSession) -> str:
  127. """Where the QR codes should point. Prefers `external_url` when set so a
  128. phone scan reaches the user's public Bambuddy URL rather than an internal
  129. address; falls back to the request's own scheme+host when no setting is
  130. configured.
  131. """
  132. external = (await get_setting(db, "external_url") or "").strip().rstrip("/")
  133. if external:
  134. return external
  135. return f"{request.url.scheme}://{request.url.netloc}"
  136. def _spool_to_label_data(spool: Spool, deeplink_base: str) -> LabelData:
  137. name = spool.color_name or spool.slicer_filament_name or f"{spool.brand or ''} {spool.material}".strip()
  138. return LabelData(
  139. spool_id=spool.id,
  140. name=name or spool.material,
  141. material=spool.material,
  142. brand=spool.brand,
  143. subtype=spool.subtype,
  144. rgba=spool.rgba,
  145. extra_colors=_split_extra_colors(spool.extra_colors),
  146. storage_location=getattr(spool, "storage_location", None),
  147. deeplink_url=f"{deeplink_base}/inventory?spool={spool.id}",
  148. material_number=spool.material_number,
  149. nozzle_temp_min=spool.nozzle_temp_min,
  150. nozzle_temp_max=spool.nozzle_temp_max,
  151. label_weight=spool.label_weight,
  152. note=spool.note,
  153. added=spool.created_at.date() if spool.created_at else None,
  154. )
  155. def _parse_date(value: str | None) -> date | None:
  156. if not value:
  157. return None
  158. try:
  159. return datetime.fromisoformat(value).date()
  160. except ValueError:
  161. return None
  162. def _spoolman_dict_to_label_data(s: dict, deeplink_base: str) -> LabelData:
  163. """Build LabelData from a raw Spoolman /spool response dict.
  164. Goes through ``_map_spoolman_spool``, the mapping the inventory page shows,
  165. so a Spoolman label carries what a built-in-inventory label does: the same
  166. subtype, colour name, material number, temperature and weight.
  167. """
  168. m = _map_spoolman_spool(s)
  169. material = m["material"] or ""
  170. # Same precedence as a built-in spool: a colour name the user set, then the
  171. # filament (slicer) name. A synthesised colour name is only the subtype.
  172. color_name = None if m["color_name_is_synthesized"] else m["color_name"]
  173. name = color_name or m["slicer_filament_name"] or f"{m['brand'] or ''} {material}".strip()
  174. return LabelData(
  175. spool_id=m["id"],
  176. name=name or material or "Spool",
  177. material=material,
  178. brand=m["brand"],
  179. subtype=m["subtype"],
  180. rgba=m["rgba"],
  181. extra_colors=_split_extra_colors(m["extra_colors"]),
  182. storage_location=m["storage_location"],
  183. deeplink_url=f"{deeplink_base}/inventory?spool={m['id']}",
  184. material_number=m["material_number"],
  185. nozzle_temp_min=m["nozzle_temp_min"],
  186. nozzle_temp_max=m["nozzle_temp_max"],
  187. label_weight=m["label_weight"],
  188. note=m["note"],
  189. added=_parse_date(m["created_at"]),
  190. )
  191. def _stream_pdf(pdf: bytes, filename: str) -> StreamingResponse:
  192. return StreamingResponse(
  193. io.BytesIO(pdf),
  194. media_type="application/pdf",
  195. headers={
  196. "Content-Disposition": build_content_disposition(filename, disposition="inline"),
  197. "Content-Length": str(len(pdf)),
  198. # PDFs are deterministic per request; tell the browser not to cache
  199. # so re-printing after edits picks up the new data.
  200. "Cache-Control": "no-store",
  201. },
  202. )
  203. async def _render_response(body: LabelRequest, data_list: list[LabelData], filename_stem: str) -> Response:
  204. template = body.resolved_template()
  205. pdf = render_labels(
  206. template,
  207. data_list,
  208. monochrome=body.monochrome,
  209. starting_position=body.starting_position,
  210. fields=body.field_set(),
  211. )
  212. if body.format == "pdf":
  213. return _stream_pdf(pdf, f"{filename_stem}.pdf")
  214. # Rasterising a few hundred pages is CPU work; keep it off the event loop.
  215. pngs = await asyncio.to_thread(pdf_to_pngs, pdf, body.dpi)
  216. if len(pngs) == 1:
  217. return Response(
  218. content=pngs[0],
  219. media_type="image/png",
  220. headers={
  221. "Content-Disposition": build_content_disposition(f"{filename_stem}.png", disposition="attachment"),
  222. "Cache-Control": "no-store",
  223. },
  224. )
  225. # A roll template has one page per spool, so its files are named after
  226. # the spool; a sheet's pages are numbered.
  227. if get_sheet_capacity(template) is None:
  228. names = [f"label-{d.spool_id}.png" for d in data_list]
  229. else:
  230. names = [f"sheet-{n}.png" for n in range(1, len(pngs) + 1)]
  231. buf = io.BytesIO()
  232. # PNG is already compressed; deflating it again only costs time.
  233. with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf:
  234. for name, png in zip(names, pngs, strict=True):
  235. zf.writestr(name, png)
  236. return Response(
  237. content=buf.getvalue(),
  238. media_type="application/zip",
  239. headers={
  240. "Content-Disposition": build_content_disposition(f"{filename_stem}.zip", disposition="attachment"),
  241. "Cache-Control": "no-store",
  242. },
  243. )
  244. async def _render_preview(body: LabelPreviewRequest, data: LabelData) -> Response:
  245. pdf = render_label_preview_pdf(body.resolved_template(), data, monochrome=body.monochrome, fields=body.field_set())
  246. pngs = await asyncio.to_thread(pdf_to_pngs, pdf, PREVIEW_DPI)
  247. return Response(content=pngs[0], media_type="image/png", headers={"Cache-Control": "no-store"})
  248. async def _local_label_data(spool_ids: list[int], request: Request, db: AsyncSession) -> list[LabelData]:
  249. result = await db.execute(select(Spool).where(Spool.id.in_(spool_ids)))
  250. spools = list(result.scalars().all())
  251. found_ids = {s.id for s in spools}
  252. missing = [sid for sid in spool_ids if sid not in found_ids]
  253. if missing:
  254. raise HTTPException(404, f"Spool(s) not found: {missing}")
  255. # Preserve caller's order so an Avery sheet print matches the on-screen list.
  256. ordered = sorted(spools, key=lambda s: spool_ids.index(s.id))
  257. deeplink_base = await _resolve_deeplink_base(request, db)
  258. return [_spool_to_label_data(s, deeplink_base) for s in ordered]
  259. async def _spoolman_label_data(spool_ids: list[int], request: Request, db: AsyncSession) -> list[LabelData]:
  260. """Fetches the full spool list once and filters in-memory, rather than one
  261. request per id. Archived spools included: Spoolman leaves them out of its
  262. plain list, and the Inventory page can show and select them (SM-C12).
  263. """
  264. spoolman_on = (await get_setting(db, "spoolman_enabled") or "").lower() == "true"
  265. if not spoolman_on:
  266. raise HTTPException(400, "Spoolman integration is not enabled")
  267. client = await get_spoolman_client()
  268. if client is None or not client.is_connected:
  269. raise HTTPException(503, "Spoolman not reachable")
  270. try:
  271. all_spools = await client.get_all_spools(allow_archived=True)
  272. except Exception as exc:
  273. logger.warning("Spoolman fetch failed during label render: %s", exc)
  274. raise HTTPException(502, "Failed to fetch spools from Spoolman") from exc
  275. by_id = {int(s.get("id", 0)): s for s in all_spools if s.get("id") is not None}
  276. missing = [sid for sid in spool_ids if sid not in by_id]
  277. if missing:
  278. raise HTTPException(404, f"Spool(s) not found in Spoolman: {missing}")
  279. deeplink_base = await _resolve_deeplink_base(request, db)
  280. return [_spoolman_dict_to_label_data(by_id[sid], deeplink_base) for sid in spool_ids]
  281. @router.post("/inventory/labels")
  282. async def render_local_inventory_labels(
  283. body: LabelRequest,
  284. request: Request,
  285. db: AsyncSession = Depends(get_db, scope="function"),
  286. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  287. ) -> Response:
  288. """Render labels for spools in the local inventory."""
  289. data_list = await _local_label_data(body.spool_ids, request, db)
  290. return await _render_response(body, data_list, f"bambuddy-labels-{body.template}")
  291. @router.post("/inventory/labels/preview")
  292. async def preview_local_inventory_label(
  293. body: LabelPreviewRequest,
  294. request: Request,
  295. db: AsyncSession = Depends(get_db, scope="function"),
  296. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  297. ) -> Response:
  298. """One label for a local spool, as a PNG, for the picker's preview."""
  299. data_list = await _local_label_data([body.spool_id], request, db)
  300. return await _render_preview(body, data_list[0])
  301. @router.post("/spoolman/labels")
  302. async def render_spoolman_labels(
  303. body: LabelRequest,
  304. request: Request,
  305. db: AsyncSession = Depends(get_db, scope="function"),
  306. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  307. ) -> Response:
  308. """Render labels for spools tracked in Spoolman."""
  309. data_list = await _spoolman_label_data(body.spool_ids, request, db)
  310. return await _render_response(body, data_list, f"bambuddy-labels-spoolman-{body.template}")
  311. @router.post("/spoolman/labels/preview")
  312. async def preview_spoolman_label(
  313. body: LabelPreviewRequest,
  314. request: Request,
  315. db: AsyncSession = Depends(get_db, scope="function"),
  316. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  317. ) -> Response:
  318. """One label for a Spoolman spool, as a PNG, for the picker's preview."""
  319. data_list = await _spoolman_label_data([body.spool_id], request, db)
  320. return await _render_preview(body, data_list[0])