mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
This commit is contained in:
@@ -5,7 +5,8 @@ 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}`` and return a PDF stream.
|
||||
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.
|
||||
"""
|
||||
@@ -18,7 +19,7 @@ from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -28,7 +29,7 @@ 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, render_labels
|
||||
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
|
||||
|
||||
@@ -63,6 +64,18 @@ class LabelRequest(BaseModel):
|
||||
# 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:
|
||||
@@ -173,7 +186,12 @@ async def render_local_inventory_labels(
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -217,6 +235,11 @@ async def render_spoolman_labels(
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -154,24 +154,28 @@ def _qr_png_bytes(payload: str, *, box_size: int = 4, border: int = 2) -> bytes:
|
||||
def _draw_swatch(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, data: LabelData) -> None:
|
||||
"""Draw the colour swatch. Multi-colour spools use vertical stripes
|
||||
(matching the FilamentSwatch convention in the frontend)."""
|
||||
primary = _color_from_hex(data.rgba)
|
||||
extras = [_color_from_hex(h) for h in (data.extra_colors or []) if h]
|
||||
colors = [primary, *extras]
|
||||
c.saveState()
|
||||
try:
|
||||
primary = _color_from_hex(data.rgba)
|
||||
extras = [_color_from_hex(h) for h in (data.extra_colors or []) if h]
|
||||
colors = [primary, *extras]
|
||||
|
||||
if not colors:
|
||||
c.setFillColor(HexColor(0x808080))
|
||||
c.rect(x, y, w, h, stroke=0, fill=1)
|
||||
return
|
||||
if not colors:
|
||||
c.setFillColor(HexColor(0x808080))
|
||||
c.rect(x, y, w, h, stroke=0, fill=1)
|
||||
return
|
||||
|
||||
stripe_w = w / len(colors)
|
||||
for i, col in enumerate(colors):
|
||||
c.setFillColor(col)
|
||||
c.rect(x + i * stripe_w, y, stripe_w, h, stroke=0, fill=1)
|
||||
stripe_w = w / len(colors)
|
||||
for i, col in enumerate(colors):
|
||||
c.setFillColor(col)
|
||||
c.rect(x + i * stripe_w, y, stripe_w, h, stroke=0, fill=1)
|
||||
|
||||
# Thin black border so light-colour swatches stay visible on white labels.
|
||||
c.setStrokeColor(black)
|
||||
c.setLineWidth(0.3)
|
||||
c.rect(x, y, w, h, stroke=1, fill=0)
|
||||
# Thin black border so light-colour swatches stay visible on white labels.
|
||||
c.setStrokeColor(black)
|
||||
c.setLineWidth(0.3)
|
||||
c.rect(x, y, w, h, stroke=1, fill=0)
|
||||
finally:
|
||||
c.restoreState()
|
||||
|
||||
|
||||
def _roomy_qr_size(inner_w: float, inner_h: float) -> float:
|
||||
@@ -442,7 +446,20 @@ def _render_single_label_pdf(template: TemplateName, data_list: list[LabelData],
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData], monochrome: bool = False) -> bytes:
|
||||
def get_sheet_capacity(template: TemplateName) -> int | None:
|
||||
"""Return the number of slots on a sheet template, or ``None`` for roll labels."""
|
||||
layout = _SHEET_TEMPLATES.get(template)
|
||||
if layout is None:
|
||||
return None
|
||||
return layout[3] * layout[4]
|
||||
|
||||
|
||||
def _render_sheet_pdf(
|
||||
template: TemplateName,
|
||||
data_list: list[LabelData],
|
||||
monochrome: bool,
|
||||
starting_position: int,
|
||||
) -> bytes:
|
||||
page_size, w_mm, h_mm, cols, rows, top_mm, left_mm, col_gap_mm, row_gap_mm = _SHEET_TEMPLATES[template]
|
||||
page_w, page_h = page_size
|
||||
|
||||
@@ -458,21 +475,37 @@ def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData], monoch
|
||||
c.setTitle(f"Bambuddy spool labels ({template})")
|
||||
|
||||
per_page = cols * rows
|
||||
for page_start in range(0, len(data_list), per_page):
|
||||
chunk = data_list[page_start : page_start + per_page]
|
||||
if starting_position < 1 or starting_position > per_page:
|
||||
raise ValueError(f"Starting position must be between 1 and {per_page} for {template}")
|
||||
|
||||
data_index = 0
|
||||
page_number = 0
|
||||
while data_index < len(data_list):
|
||||
slot_offset = starting_position - 1 if page_number == 0 else 0
|
||||
page_capacity = per_page - slot_offset
|
||||
chunk = data_list[data_index : data_index + page_capacity]
|
||||
for idx, data in enumerate(chunk):
|
||||
row = idx // cols
|
||||
col = idx % cols
|
||||
slot_index = slot_offset + idx
|
||||
row = slot_index // cols
|
||||
col = slot_index % cols
|
||||
x = left_margin + col * (label_w + col_gap)
|
||||
y = page_h - top_margin - (row + 1) * label_h - row * row_gap
|
||||
_draw_label(c, x, y, label_w, label_h, data, monochrome)
|
||||
c.showPage()
|
||||
data_index += len(chunk)
|
||||
page_number += 1
|
||||
|
||||
c.save()
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def render_labels(template: TemplateName, data_list: list[LabelData], *, monochrome: bool = False) -> bytes:
|
||||
def render_labels(
|
||||
template: TemplateName,
|
||||
data_list: list[LabelData],
|
||||
*,
|
||||
monochrome: bool = False,
|
||||
starting_position: int = 1,
|
||||
) -> bytes:
|
||||
"""Render ``data_list`` to a PDF using the named template. Returns bytes.
|
||||
|
||||
Empty ``data_list`` still produces a valid (empty) PDF — callers should
|
||||
@@ -481,14 +514,19 @@ def render_labels(template: TemplateName, data_list: list[LabelData], *, monochr
|
||||
``monochrome`` drops the colour swatch (which prints as a useless grey block
|
||||
on black-and-white thermal printers) and reclaims the space for text; the
|
||||
hex-code line still carries the colour. See #1870.
|
||||
|
||||
``starting_position`` is one-based and applies only to the first page of a
|
||||
sheet template. Later pages always begin at the first slot.
|
||||
"""
|
||||
if template in _SINGLE_LABEL_SIZES_MM:
|
||||
if starting_position != 1:
|
||||
raise ValueError("Starting position is only supported for sheet label templates")
|
||||
return _render_single_label_pdf(template, data_list, monochrome)
|
||||
if template in _SHEET_TEMPLATES:
|
||||
return _render_sheet_pdf(template, data_list, monochrome)
|
||||
return _render_sheet_pdf(template, data_list, monochrome, starting_position)
|
||||
raise ValueError(f"Unknown label template: {template!r}")
|
||||
|
||||
|
||||
__all__ = ["LabelData", "TemplateName", "render_labels"]
|
||||
__all__ = ["LabelData", "TemplateName", "get_sheet_capacity", "render_labels"]
|
||||
# white re-exported for completeness; future templates may need a paper-tone variant.
|
||||
_ = white
|
||||
|
||||
@@ -141,6 +141,29 @@ class TestLocalInventoryLabels:
|
||||
assert resp.status_code == 200
|
||||
assert captured["ids"] == [s3.id, s1.id, s2.id]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_forwards_sheet_starting_position(self, async_client: AsyncClient, spool_factory):
|
||||
spool = await spool_factory()
|
||||
|
||||
from backend.app.api.routes import labels as labels_module
|
||||
|
||||
captured = {}
|
||||
original = labels_module.render_labels
|
||||
|
||||
def _capture(template, data_list, **kwargs):
|
||||
captured["starting_position"] = kwargs["starting_position"]
|
||||
return original(template, data_list, **kwargs)
|
||||
|
||||
with patch.object(labels_module, "render_labels", side_effect=_capture):
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/labels",
|
||||
json={"spool_ids": [spool.id], "template": "avery_5160", "starting_position": 8},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert captured["starting_position"] == 8
|
||||
|
||||
|
||||
# ── /spoolman/labels (Spoolman-backed) ───────────────────────────────────────
|
||||
|
||||
@@ -237,6 +260,24 @@ class TestSpoolmanLabels:
|
||||
|
||||
|
||||
class TestValidation:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize(
|
||||
("template", "starting_position"),
|
||||
(("avery_5160", 0), ("avery_5160", 31), ("avery_l7160", 22), ("box_62x29", 2)),
|
||||
)
|
||||
async def test_invalid_starting_position_rejected(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
template: str,
|
||||
starting_position: int,
|
||||
):
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/labels",
|
||||
json={"spool_ids": [1], "template": template, "starting_position": starting_position},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_request_body_size_capped(self, async_client: AsyncClient):
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.lib.units import mm
|
||||
|
||||
from backend.app.services.label_renderer import LabelData, render_labels
|
||||
|
||||
@@ -96,6 +100,37 @@ def test_sheet_template_paginates_when_count_exceeds_one_sheet():
|
||||
assert len(two) > len(one)
|
||||
|
||||
|
||||
def test_sheet_starting_position_offsets_first_label():
|
||||
with patch("backend.app.services.label_renderer._draw_label") as draw_label:
|
||||
render_labels("avery_5160", [_sample(1)], starting_position=8)
|
||||
|
||||
first_call = draw_label.call_args_list[0].args
|
||||
assert first_call[1] == pytest.approx(4.76 * mm + 66.675 * mm + 3.175 * mm)
|
||||
assert first_call[2] == pytest.approx(letter[1] - 12.7 * mm - 3 * 25.4 * mm)
|
||||
|
||||
|
||||
def test_sheet_starting_position_resets_on_second_page():
|
||||
data = [_sample(i) for i in range(1, 25)]
|
||||
with patch("backend.app.services.label_renderer._draw_label") as draw_label:
|
||||
render_labels("avery_5160", data, starting_position=8)
|
||||
|
||||
last_first_page_call = draw_label.call_args_list[22].args
|
||||
first_second_page_call = draw_label.call_args_list[23].args
|
||||
assert last_first_page_call[1] == pytest.approx(4.76 * mm + 2 * (66.675 * mm + 3.175 * mm))
|
||||
assert last_first_page_call[2] == pytest.approx(letter[1] - 12.7 * mm - 10 * 25.4 * mm)
|
||||
assert first_second_page_call[1] == pytest.approx(4.76 * mm)
|
||||
assert first_second_page_call[2] == pytest.approx(letter[1] - 12.7 * mm - 25.4 * mm)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("template", "starting_position"),
|
||||
(("avery_5160", 0), ("avery_5160", 31), ("avery_l7160", 22), ("box_62x29", 2)),
|
||||
)
|
||||
def test_invalid_starting_position_raises(template, starting_position):
|
||||
with pytest.raises(ValueError, match="Starting position"):
|
||||
render_labels(template, [_sample()], starting_position=starting_position)
|
||||
|
||||
|
||||
def test_qr_payload_is_present_in_pdf_stream():
|
||||
"""The QR encodes the deeplink URL via embedded PNG; we can at least
|
||||
sanity-check that the PDF contains an image stream when a deeplink is set
|
||||
@@ -172,6 +207,17 @@ def _render_uncompressed(template, data, monochrome=False):
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_transparent_swatch_does_not_apply_its_alpha_to_qr():
|
||||
pdf = _render_uncompressed(
|
||||
"box_62x29",
|
||||
[_sample(rgba="FF000000", deeplink_url="https://example.test/inventory?spool=1")],
|
||||
)
|
||||
|
||||
alpha_start = pdf.index(b"/gRLs0 gs")
|
||||
qr_draw = pdf.index(b" Do", alpha_start)
|
||||
assert b"Q" in pdf[alpha_start:qr_draw]
|
||||
|
||||
|
||||
def test_ams_template_actually_renders_text():
|
||||
"""Regression: the first cut of the AMS-holder layout produced labels with
|
||||
only swatch + QR and no text at all because the side-by-side layout left
|
||||
|
||||
Reference in New Issue
Block a user