labels.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  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}`` and
  6. return a PDF stream.
  7. The QR code on each label deep-links to ``/inventory?spool=<id>`` so a phone
  8. scan jumps straight back into Bambuddy at that spool's row.
  9. """
  10. from __future__ import annotations
  11. import io
  12. import logging
  13. from typing import Literal
  14. from fastapi import APIRouter, Depends, HTTPException, Request
  15. from fastapi.responses import StreamingResponse
  16. from pydantic import BaseModel, Field, model_validator
  17. from sqlalchemy import select
  18. from sqlalchemy.ext.asyncio import AsyncSession
  19. from backend.app.api.routes._spoolman_helpers import parse_spoolman_multi_colors
  20. from backend.app.api.routes.settings import get_setting
  21. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  22. from backend.app.core.database import get_db
  23. from backend.app.core.permissions import Permission
  24. from backend.app.models.spool import Spool
  25. from backend.app.models.user import User
  26. from backend.app.services.label_renderer import LabelData, TemplateName, get_sheet_capacity, render_labels
  27. from backend.app.services.spoolman import get_spoolman_client
  28. from backend.app.utils.http import build_content_disposition
  29. logger = logging.getLogger(__name__)
  30. router = APIRouter(tags=["labels"])
  31. _VALID_TEMPLATES: tuple[TemplateName, ...] = (
  32. "ams_holder_74x33",
  33. "ams_holder_75x55",
  34. "box_40x30",
  35. "box_62x29",
  36. "avery_5160",
  37. "avery_l7160",
  38. )
  39. # Cap how many labels can be requested in one go. Sane upper bound for the
  40. # largest realistic batch (an Avery sheet at 30/page × ~10 pages).
  41. MAX_LABELS_PER_REQUEST = 500
  42. class LabelRequest(BaseModel):
  43. spool_ids: list[int] = Field(..., min_length=1, max_length=MAX_LABELS_PER_REQUEST)
  44. template: Literal[
  45. "ams_holder_74x33",
  46. "ams_holder_75x55",
  47. "box_40x30",
  48. "box_62x29",
  49. "avery_5160",
  50. "avery_l7160",
  51. ]
  52. # Black-and-white thermal printers: drop the colour swatch (prints as a
  53. # muddy grey block) and widen the text column instead (#1870).
  54. monochrome: bool = False
  55. starting_position: int = Field(default=1, ge=1)
  56. @model_validator(mode="after")
  57. def validate_starting_position(self) -> LabelRequest:
  58. capacity = get_sheet_capacity(self.template)
  59. if capacity is None:
  60. if self.starting_position != 1:
  61. raise ValueError("starting_position is only supported for sheet label templates")
  62. return self
  63. if self.starting_position > capacity:
  64. raise ValueError(f"starting_position must be between 1 and {capacity} for template {self.template}")
  65. return self
  66. def _split_extra_colors(raw: str | None) -> list[str] | None:
  67. """Parse ``Spool.extra_colors`` (comma-separated hex tokens) into a list."""
  68. if not raw:
  69. return None
  70. parts = [p.strip().lstrip("#") for p in raw.split(",") if p.strip()]
  71. return parts or None
  72. async def _resolve_deeplink_base(request: Request, db: AsyncSession) -> str:
  73. """Where the QR codes should point. Prefers `external_url` when set so a
  74. phone scan reaches the user's public Bambuddy URL rather than an internal
  75. address; falls back to the request's own scheme+host when no setting is
  76. configured.
  77. """
  78. external = (await get_setting(db, "external_url") or "").strip().rstrip("/")
  79. if external:
  80. return external
  81. return f"{request.url.scheme}://{request.url.netloc}"
  82. def _spool_to_label_data(spool: Spool, deeplink_base: str) -> LabelData:
  83. name = spool.color_name or spool.slicer_filament_name or f"{spool.brand or ''} {spool.material}".strip()
  84. return LabelData(
  85. spool_id=spool.id,
  86. name=name or spool.material,
  87. material=spool.material,
  88. brand=spool.brand,
  89. subtype=spool.subtype,
  90. rgba=spool.rgba,
  91. extra_colors=_split_extra_colors(spool.extra_colors),
  92. storage_location=getattr(spool, "storage_location", None),
  93. deeplink_url=f"{deeplink_base}/inventory?spool={spool.id}",
  94. )
  95. def _spoolman_dict_to_label_data(s: dict, deeplink_base: str) -> LabelData:
  96. """Build LabelData from a raw Spoolman /spool response dict.
  97. Spoolman models don't have a native 'spool name' — we derive it from the
  98. embedded filament. Material and brand come from filament/vendor.
  99. """
  100. filament = s.get("filament") or {}
  101. vendor = filament.get("vendor") or {}
  102. fname = filament.get("name") or ""
  103. material = filament.get("material") or ""
  104. brand = vendor.get("name")
  105. color_hex = filament.get("color_hex")
  106. rgba = color_hex.lstrip("#") if isinstance(color_hex, str) else None
  107. # Shared with `_map_spoolman_spool`, so the swatch printed on a label and
  108. # the swatch drawn on an AMS slot card cannot read the same field two ways.
  109. extra: list[str] | None = parse_spoolman_multi_colors(filament) or None
  110. return LabelData(
  111. spool_id=int(s.get("id", 0)),
  112. name=fname or material or "Spool",
  113. material=material or "",
  114. brand=brand,
  115. subtype=None,
  116. rgba=rgba,
  117. extra_colors=extra,
  118. storage_location=s.get("location"),
  119. deeplink_url=f"{deeplink_base}/inventory?spool={int(s.get('id', 0))}",
  120. )
  121. def _stream_pdf(pdf: bytes, filename: str) -> StreamingResponse:
  122. return StreamingResponse(
  123. io.BytesIO(pdf),
  124. media_type="application/pdf",
  125. headers={
  126. "Content-Disposition": build_content_disposition(filename, disposition="inline"),
  127. "Content-Length": str(len(pdf)),
  128. # PDFs are deterministic per request; tell the browser not to cache
  129. # so re-printing after edits picks up the new data.
  130. "Cache-Control": "no-store",
  131. },
  132. )
  133. @router.post("/inventory/labels")
  134. async def render_local_inventory_labels(
  135. body: LabelRequest,
  136. request: Request,
  137. db: AsyncSession = Depends(get_db),
  138. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  139. ) -> StreamingResponse:
  140. """Render labels for spools in the local inventory."""
  141. if body.template not in _VALID_TEMPLATES:
  142. raise HTTPException(400, f"Unknown template: {body.template}")
  143. result = await db.execute(select(Spool).where(Spool.id.in_(body.spool_ids)))
  144. spools = list(result.scalars().all())
  145. found_ids = {s.id for s in spools}
  146. missing = [sid for sid in body.spool_ids if sid not in found_ids]
  147. if missing:
  148. raise HTTPException(404, f"Spool(s) not found: {missing}")
  149. # Preserve caller's order so an Avery sheet print matches the on-screen list.
  150. ordered = sorted(spools, key=lambda s: body.spool_ids.index(s.id))
  151. deeplink_base = await _resolve_deeplink_base(request, db)
  152. data_list = [_spool_to_label_data(s, deeplink_base) for s in ordered]
  153. pdf = render_labels(
  154. body.template,
  155. data_list,
  156. monochrome=body.monochrome,
  157. starting_position=body.starting_position,
  158. )
  159. filename = f"bambuddy-labels-{body.template}.pdf"
  160. return _stream_pdf(pdf, filename)
  161. @router.post("/spoolman/labels")
  162. async def render_spoolman_labels(
  163. body: LabelRequest,
  164. request: Request,
  165. db: AsyncSession = Depends(get_db),
  166. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  167. ) -> StreamingResponse:
  168. """Render labels for spools tracked in Spoolman.
  169. The Spoolman client doesn't expose a per-id endpoint, so this fetches the
  170. full spool list and filters in-memory. For typical libraries (~50 spools)
  171. that's negligible; for very large libraries this is the trade-off until
  172. Spoolman gains a bulk filter.
  173. """
  174. if body.template not in _VALID_TEMPLATES:
  175. raise HTTPException(400, f"Unknown template: {body.template}")
  176. spoolman_on = (await get_setting(db, "spoolman_enabled") or "").lower() == "true"
  177. if not spoolman_on:
  178. raise HTTPException(400, "Spoolman integration is not enabled")
  179. client = await get_spoolman_client()
  180. if client is None or not client.is_connected:
  181. raise HTTPException(503, "Spoolman not reachable")
  182. try:
  183. all_spools = await client.get_spools()
  184. except Exception as exc:
  185. logger.warning("Spoolman fetch failed during label render: %s", exc)
  186. raise HTTPException(502, "Failed to fetch spools from Spoolman") from exc
  187. by_id = {int(s.get("id", 0)): s for s in all_spools if s.get("id") is not None}
  188. missing = [sid for sid in body.spool_ids if sid not in by_id]
  189. if missing:
  190. raise HTTPException(404, f"Spool(s) not found in Spoolman: {missing}")
  191. deeplink_base = await _resolve_deeplink_base(request, db)
  192. data_list = [_spoolman_dict_to_label_data(by_id[sid], deeplink_base) for sid in body.spool_ids]
  193. pdf = render_labels(
  194. body.template,
  195. data_list,
  196. monochrome=body.monochrome,
  197. starting_position=body.starting_position,
  198. )
  199. filename = f"bambuddy-labels-spoolman-{body.template}.pdf"
  200. return _stream_pdf(pdf, filename)