mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
A Ziro "Colorful Mist" -- yellow, cyan and pink, effect Tri Color -- hovered on the printer card as a single flat pink rectangle. A printer reports exactly one tray_color hex per tray and nothing else, so telemetry cannot describe a gradient or a surface effect and never will. The header now paints the bound spool's own swatch whenever that spool declares extra colour stops or an effect, through buildFilamentBackground -- the builder the Inventory swatches already use, so the two surfaces cannot drift apart. A plain single-colour spool keeps the flat backgroundColor it has always had, and a slot with nothing bound is untouched, so the common case goes nowhere near the gradient path. The gate is "any stop at all", not "more than one". buildColorLayer ignores rgba the moment stops exist, so a one-stop spool renders that stop rather than the slot hex; skipping it would leave this card showing a different colour from the Inventory row for the same spool, which is the class of disagreement the shared builder exists to prevent. isLightColor now tests the colour actually on screen. Once the spool's swatch is painted the base is no longer the slot hex -- a single stop replaces it outright, and an effect-only spool paints the spool's own rgba -- so testing the slot hex would pick the text colour for a background that is not there. Above one band no single hex can decide legibility, and the name sits dead centre where a multi-stop background is likeliest to change under it. So a genuinely multi-band header puts the name on the same scrim the vendor badge already uses. One stop, or an effect over one colour, still vendor badge already uses. One stop, or an effect over one colour, still leaves a real base colour to test and keeps the contrast rule it had. Spoolman mode gains the gradient in the process. Spoolman has held the stops in filament.multi_color_hexes all along and the label renderer has been reading them for releases, but _map_spoolman_spool never returned them -- so the identical roll registered in Spoolman rendered flat while the internally-managed one did not. Both now share one parser rather than reading the same field two ways. What stays asymmetric is Spoolman's own limitation, and it is pinned by a test rather than left to be rediscovered: Spoolman has no field for a surface effect at all. Its only neighbouring field, multi_color_direction, says how the stops are laid out, not that the roll is silk or glitter. effect_type is therefore None for a Spoolman spool instead of guessed at, and silk/sparkle/wood remain internal-only. The other two halves of the report -- the header naming the colour "White" instead of "Colorful Mist", and the print dialog offering "A3: PLA (White)" -- were already fixed on dev by #2875 and by the slot-naming change that landed the day after this was filed. Neither is in 1.2.5.3, which is what the reporter is running.
244 lines
9.0 KiB
Python
244 lines
9.0 KiB
Python
"""Spool label printing routes (#809).
|
||
|
||
Two endpoints, one per inventory backend:
|
||
|
||
- ``POST /inventory/labels`` — local-DB spools
|
||
- ``POST /spoolman/labels`` — Spoolman-backed spools
|
||
|
||
Both accept ``{spool_ids: [int], template: str, starting_position: int}`` and
|
||
return a PDF stream.
|
||
The QR code on each label deep-links to ``/inventory?spool=<id>`` so a phone
|
||
scan jumps straight back into Bambuddy at that spool's row.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import logging
|
||
from typing import Literal
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||
from fastapi.responses import StreamingResponse
|
||
from pydantic import BaseModel, Field, model_validator
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from backend.app.api.routes._spoolman_helpers import parse_spoolman_multi_colors
|
||
from backend.app.api.routes.settings import get_setting
|
||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||
from backend.app.core.database import get_db
|
||
from backend.app.core.permissions import Permission
|
||
from backend.app.models.spool import Spool
|
||
from backend.app.models.user import User
|
||
from backend.app.services.label_renderer import LabelData, TemplateName, get_sheet_capacity, render_labels
|
||
from backend.app.services.spoolman import get_spoolman_client
|
||
from backend.app.utils.http import build_content_disposition
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(tags=["labels"])
|
||
|
||
_VALID_TEMPLATES: tuple[TemplateName, ...] = (
|
||
"ams_holder_74x33",
|
||
"ams_holder_75x55",
|
||
"box_40x30",
|
||
"box_62x29",
|
||
"avery_5160",
|
||
"avery_l7160",
|
||
)
|
||
|
||
# Cap how many labels can be requested in one go. Sane upper bound for the
|
||
# largest realistic batch (an Avery sheet at 30/page × ~10 pages).
|
||
MAX_LABELS_PER_REQUEST = 500
|
||
|
||
|
||
class LabelRequest(BaseModel):
|
||
spool_ids: list[int] = Field(..., min_length=1, max_length=MAX_LABELS_PER_REQUEST)
|
||
template: Literal[
|
||
"ams_holder_74x33",
|
||
"ams_holder_75x55",
|
||
"box_40x30",
|
||
"box_62x29",
|
||
"avery_5160",
|
||
"avery_l7160",
|
||
]
|
||
# Black-and-white thermal printers: drop the colour swatch (prints as a
|
||
# muddy grey block) and widen the text column instead (#1870).
|
||
monochrome: bool = False
|
||
starting_position: int = Field(default=1, ge=1)
|
||
|
||
@model_validator(mode="after")
|
||
def validate_starting_position(self) -> LabelRequest:
|
||
capacity = get_sheet_capacity(self.template)
|
||
if capacity is None:
|
||
if self.starting_position != 1:
|
||
raise ValueError("starting_position is only supported for sheet label templates")
|
||
return self
|
||
if self.starting_position > capacity:
|
||
raise ValueError(f"starting_position must be between 1 and {capacity} for template {self.template}")
|
||
return self
|
||
|
||
|
||
def _split_extra_colors(raw: str | None) -> list[str] | None:
|
||
"""Parse ``Spool.extra_colors`` (comma-separated hex tokens) into a list."""
|
||
if not raw:
|
||
return None
|
||
parts = [p.strip().lstrip("#") for p in raw.split(",") if p.strip()]
|
||
return parts or None
|
||
|
||
|
||
async def _resolve_deeplink_base(request: Request, db: AsyncSession) -> str:
|
||
"""Where the QR codes should point. Prefers `external_url` when set so a
|
||
phone scan reaches the user's public Bambuddy URL rather than an internal
|
||
address; falls back to the request's own scheme+host when no setting is
|
||
configured.
|
||
"""
|
||
external = (await get_setting(db, "external_url") or "").strip().rstrip("/")
|
||
if external:
|
||
return external
|
||
return f"{request.url.scheme}://{request.url.netloc}"
|
||
|
||
|
||
def _spool_to_label_data(spool: Spool, deeplink_base: str) -> LabelData:
|
||
name = spool.color_name or spool.slicer_filament_name or f"{spool.brand or ''} {spool.material}".strip()
|
||
return LabelData(
|
||
spool_id=spool.id,
|
||
name=name or spool.material,
|
||
material=spool.material,
|
||
brand=spool.brand,
|
||
subtype=spool.subtype,
|
||
rgba=spool.rgba,
|
||
extra_colors=_split_extra_colors(spool.extra_colors),
|
||
storage_location=getattr(spool, "storage_location", None),
|
||
deeplink_url=f"{deeplink_base}/inventory?spool={spool.id}",
|
||
)
|
||
|
||
|
||
def _spoolman_dict_to_label_data(s: dict, deeplink_base: str) -> LabelData:
|
||
"""Build LabelData from a raw Spoolman /spool response dict.
|
||
|
||
Spoolman models don't have a native 'spool name' — we derive it from the
|
||
embedded filament. Material and brand come from filament/vendor.
|
||
"""
|
||
filament = s.get("filament") or {}
|
||
vendor = filament.get("vendor") or {}
|
||
fname = filament.get("name") or ""
|
||
material = filament.get("material") or ""
|
||
brand = vendor.get("name")
|
||
color_hex = filament.get("color_hex")
|
||
rgba = color_hex.lstrip("#") if isinstance(color_hex, str) else None
|
||
|
||
# Shared with `_map_spoolman_spool`, so the swatch printed on a label and
|
||
# the swatch drawn on an AMS slot card cannot read the same field two ways.
|
||
extra: list[str] | None = parse_spoolman_multi_colors(filament) or None
|
||
|
||
return LabelData(
|
||
spool_id=int(s.get("id", 0)),
|
||
name=fname or material or "Spool",
|
||
material=material or "",
|
||
brand=brand,
|
||
subtype=None,
|
||
rgba=rgba,
|
||
extra_colors=extra,
|
||
storage_location=s.get("location"),
|
||
deeplink_url=f"{deeplink_base}/inventory?spool={int(s.get('id', 0))}",
|
||
)
|
||
|
||
|
||
def _stream_pdf(pdf: bytes, filename: str) -> StreamingResponse:
|
||
return StreamingResponse(
|
||
io.BytesIO(pdf),
|
||
media_type="application/pdf",
|
||
headers={
|
||
"Content-Disposition": build_content_disposition(filename, disposition="inline"),
|
||
"Content-Length": str(len(pdf)),
|
||
# PDFs are deterministic per request; tell the browser not to cache
|
||
# so re-printing after edits picks up the new data.
|
||
"Cache-Control": "no-store",
|
||
},
|
||
)
|
||
|
||
|
||
@router.post("/inventory/labels")
|
||
async def render_local_inventory_labels(
|
||
body: LabelRequest,
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
|
||
) -> StreamingResponse:
|
||
"""Render labels for spools in the local inventory."""
|
||
if body.template not in _VALID_TEMPLATES:
|
||
raise HTTPException(400, f"Unknown template: {body.template}")
|
||
|
||
result = await db.execute(select(Spool).where(Spool.id.in_(body.spool_ids)))
|
||
spools = list(result.scalars().all())
|
||
|
||
found_ids = {s.id for s in spools}
|
||
missing = [sid for sid in body.spool_ids if sid not in found_ids]
|
||
if missing:
|
||
raise HTTPException(404, f"Spool(s) not found: {missing}")
|
||
|
||
# Preserve caller's order so an Avery sheet print matches the on-screen list.
|
||
ordered = sorted(spools, key=lambda s: body.spool_ids.index(s.id))
|
||
|
||
deeplink_base = await _resolve_deeplink_base(request, db)
|
||
data_list = [_spool_to_label_data(s, deeplink_base) for s in ordered]
|
||
|
||
pdf = render_labels(
|
||
body.template,
|
||
data_list,
|
||
monochrome=body.monochrome,
|
||
starting_position=body.starting_position,
|
||
)
|
||
filename = f"bambuddy-labels-{body.template}.pdf"
|
||
return _stream_pdf(pdf, filename)
|
||
|
||
|
||
@router.post("/spoolman/labels")
|
||
async def render_spoolman_labels(
|
||
body: LabelRequest,
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
|
||
) -> StreamingResponse:
|
||
"""Render labels for spools tracked in Spoolman.
|
||
|
||
The Spoolman client doesn't expose a per-id endpoint, so this fetches the
|
||
full spool list and filters in-memory. For typical libraries (~50 spools)
|
||
that's negligible; for very large libraries this is the trade-off until
|
||
Spoolman gains a bulk filter.
|
||
"""
|
||
if body.template not in _VALID_TEMPLATES:
|
||
raise HTTPException(400, f"Unknown template: {body.template}")
|
||
|
||
spoolman_on = (await get_setting(db, "spoolman_enabled") or "").lower() == "true"
|
||
if not spoolman_on:
|
||
raise HTTPException(400, "Spoolman integration is not enabled")
|
||
|
||
client = await get_spoolman_client()
|
||
if client is None or not client.is_connected:
|
||
raise HTTPException(503, "Spoolman not reachable")
|
||
|
||
try:
|
||
all_spools = await client.get_spools()
|
||
except Exception as exc:
|
||
logger.warning("Spoolman fetch failed during label render: %s", exc)
|
||
raise HTTPException(502, "Failed to fetch spools from Spoolman") from exc
|
||
|
||
by_id = {int(s.get("id", 0)): s for s in all_spools if s.get("id") is not None}
|
||
missing = [sid for sid in body.spool_ids if sid not in by_id]
|
||
if missing:
|
||
raise HTTPException(404, f"Spool(s) not found in Spoolman: {missing}")
|
||
|
||
deeplink_base = await _resolve_deeplink_base(request, db)
|
||
data_list = [_spoolman_dict_to_label_data(by_id[sid], deeplink_base) for sid in body.spool_ids]
|
||
|
||
pdf = render_labels(
|
||
body.template,
|
||
data_list,
|
||
monochrome=body.monochrome,
|
||
starting_position=body.starting_position,
|
||
)
|
||
filename = f"bambuddy-labels-spoolman-{body.template}.pdf"
|
||
return _stream_pdf(pdf, filename)
|