diff --git a/backend/app/config.py b/backend/app/config.py index 4302c09..ee451db 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -9,8 +9,12 @@ pt750w_host, pt750w_port, printer_backend, printer_model, printer_discover_via_snmp, printer_snmp_community, printer_queue_timeout_s) wurden entfernt. Drucker werden jetzt über printers.yaml konfiguriert. -``extra="forbid"`` stellt sicher dass alte Env-Vars laut fehlschlagen statt -still ignoriert zu werden. + +``extra="forbid"`` rejects unknown CONSTRUCTOR KWARGS at instantiation time — +NOT unknown environment variables. pydantic-settings silently ignores unknown +env vars; leftover variables like the removed ``PRINTER_HUB_QL820_HOST`` etc. +will be silently dropped on startup. Operators should rely on the changelog +(or ``Settings(...)``-based init in tests) to detect leftover config. Usage:: diff --git a/backend/app/db/lifespan.py b/backend/app/db/lifespan.py index 2afb229..94b1a58 100644 --- a/backend/app/db/lifespan.py +++ b/backend/app/db/lifespan.py @@ -26,9 +26,12 @@ from __future__ import annotations +import logging from uuid import UUID +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import col from app.config import Settings from app.models.printer import Printer @@ -36,6 +39,8 @@ from app.services.printer_identity import derive_printer_id from app.services.template_loader import TemplateLoader +_logger = logging.getLogger(__name__) + async def run_migrations() -> None: """Apply pending Alembic migrations programmatically. @@ -190,6 +195,13 @@ async def upsert_runtime_printers( model in printers.yaml MUSS exakt der bisherigen Env-Var entsprechen. R4-M-4/M-5-Fix: Alte upsert_runtime_printer (Settings-abhängig) gelöscht — referenzierte entfernte Felder und würde AttributeError geben. + PR#98-Gemini: session.flush() statt session.commit() innerhalb der Schleife — + commit() midloop bricht Transaktions-Atomizität. Einziger commit() am Ende. + PR#98-Copilot: Slug-Collision-Detection — wenn ein alter Row dieselbe slug/name + aber eine andere UUID trägt (z.B. nach model/host/port Änderung in YAML), + wird der alte Row auf die neue deterministische UUID migriert und ein + WARNING geloggt. Ohne diese Prüfung würde ein INSERT mit IntegrityError + abstürzen statt sauber zu migrieren. Returns: Liste der printer_id UUIDs (für lifespan-Wiring). """ @@ -198,22 +210,59 @@ async def upsert_runtime_printers( printer_id = derive_printer_id(cfg.model, cfg.host, cfg.port) existing = await session.get(Printer, printer_id) if existing is None: - session.add( - Printer( - id=printer_id, - slug=cfg.slug, - name=cfg.name, - model=cfg.model.lower(), - backend=cfg.backend, - connection={"host": cfg.host, "port": cfg.port}, - enabled=True, - ) + # Slug-Collision-Check: gibt es einen Row mit gleicher slug aber anderer UUID? + # Das passiert wenn model/host/port sich geändert haben (neue deterministische UUID) + # aber slug/name gleich geblieben sind. + collision_result = await session.execute( + select(Printer).where(col(Printer.slug) == cfg.slug) ) + colliding = collision_result.scalar_one_or_none() + if colliding is not None and colliding.id != printer_id: + _logger.warning( + "upsert_runtime_printers: slug=%r already owned by printer_id=%s " + "(different from new deterministic id=%s). " + "Treating as migration — updating existing row to new UUID.", + cfg.slug, + colliding.id, + printer_id, + ) + # Migration: bestehenden Row auf neue UUID aktualisieren. + # Wir löschen den alten Row und fügen einen neuen ein, weil + # PRIMARY KEY Updates via SQLModel/SQLAlchemy nicht zuverlässig + # mit async sessions funktionieren. + await session.delete(colliding) + await session.flush() + session.add( + Printer( + id=printer_id, + slug=cfg.slug, + name=cfg.name, + model=cfg.model.lower(), + backend=cfg.backend, + connection={"host": cfg.host, "port": cfg.port}, + enabled=True, + ) + ) + else: + session.add( + Printer( + id=printer_id, + slug=cfg.slug, + name=cfg.name, + model=cfg.model.lower(), + backend=cfg.backend, + connection={"host": cfg.host, "port": cfg.port}, + enabled=True, + ) + ) else: existing.slug = cfg.slug existing.name = cfg.name existing.backend = cfg.backend # host/port/model bleiben stabil (UUID-Basis) + # flush() statt commit() hier: hält alle Änderungen in derselben Transaktion + # bis der finale commit() am Ende der Schleife alles atomar abschließt. + await session.flush() ids.append(printer_id) await session.commit() return ids diff --git a/backend/app/printer_backends/brother_ql_backend.py b/backend/app/printer_backends/brother_ql_backend.py index 850458a..4debb42 100644 --- a/backend/app/printer_backends/brother_ql_backend.py +++ b/backend/app/printer_backends/brother_ql_backend.py @@ -19,7 +19,17 @@ from PIL import Image from app.models.tape import TapeSpec -from app.printer_backends.exceptions import PrintFailedError +from app.printer_backends.exceptions import ( + PrinterCoverOpenError, + PrinterOfflineError, + PrintFailedError, + TapeEmptyError, +) +from app.printer_backends.snmp_helper import ( + PreflightStatus, + SnmpQueryError, + query_preflight, +) from app.services.status_block import StatusBlock _logger = logging.getLogger(__name__) @@ -111,6 +121,39 @@ async def print_image( blocking=True, ) + async def preflight_check( + self, + *, + community: str = "public", + timeout_s: float = 3.0, + ) -> PreflightStatus: + """SNMP-based preflight: hrPrinterStatus + error bitmap + loaded tape. + + QL-820NWB nutzt dieselbe Printer MIB wie PT-Series — query_preflight() + funktioniert identisch auf beiden Gerätereihen. + + Raises: + PrinterOfflineError: SNMP query failed (host unreachable or timeout) + TapeEmptyError: hrPrinterDetectedErrorState has noPaper bit + PrinterCoverOpenError: hrPrinterDetectedErrorState has doorOpen bit + + Does NOT raise TapeMismatchError — caller compares loaded_tape_mm. + """ + try: + preflight = await query_preflight( + self.host, + community=community, + timeout_s=timeout_s, + ) + except SnmpQueryError as exc: + raise PrinterOfflineError(f"preflight SNMP failed: {exc}") from exc + + if "noPaper" in preflight.error_flags: + raise TapeEmptyError() + if "doorOpen" in preflight.error_flags: + raise PrinterCoverOpenError() + return preflight + async def query_status(self) -> StatusBlock: """QL-Series uses SNMP-Probe via StatusProbeProducer, no synchronous path. diff --git a/backend/app/printer_backends/snmp_helper.py b/backend/app/printer_backends/snmp_helper.py index 5d8f4b7..4232592 100644 --- a/backend/app/printer_backends/snmp_helper.py +++ b/backend/app/printer_backends/snmp_helper.py @@ -9,6 +9,18 @@ from __future__ import annotations +__all__ = [ + "LiveStatus", + "PreflightStatus", + "SnmpQueryError", + "decode_error_flags", + "parse_loaded_tape_mm", + "query_live_status", + "query_loaded_tape_mm", + "query_model_pjl", + "query_preflight", +] + import asyncio import logging import re diff --git a/backend/app/schemas/print_batch.py b/backend/app/schemas/print_batch.py index 70cc6ff..10d2f6a 100644 --- a/backend/app/schemas/print_batch.py +++ b/backend/app/schemas/print_batch.py @@ -23,8 +23,9 @@ class BatchRequest(BaseModel): half_cut_override: bool | None = Field( default=None, description=( - "None=Hub-Default, False=Voll-Cut pro Label, True=explizit Half-Cut. " - "Bei QL-Series wird True als 'no-cut-between' interpretiert (kein echter Half-Cut)." + "Override half_cut for all items in this batch. " + "If the printer backend does not support half_cut (e.g. QL-Series), " + "the value is forced to False and a warning is logged." ), ) diff --git a/backend/app/services/label_renderer.py b/backend/app/services/label_renderer.py index 068ea19..7c33cc6 100644 --- a/backend/app/services/label_renderer.py +++ b/backend/app/services/label_renderer.py @@ -39,11 +39,11 @@ 62: 696, # endless QL tape — unchanged } -# Default label width in pixels — 600 px at 300 DPI ≈ 50.8mm, suitable for -# typical asset/product label lengths. The actual width the printer receives -# is determined by the print job; this is just the canvas the renderer -# paints on. DEFAULT_LABEL_WIDTH_PX: Final[int] = 600 +"""Length-axis canvas width. Decoupled from any specific DPI: +PT-Series renders at native 180 DPI, QL-Series at 300 DPI native. +Templates use this as their coordinate-system width — the backend +maps to print head dots at print time.""" # Margin around the inked content when trimming whitespace on the length # axis. 6 px ≈ 1mm at 180 DPI / 0.5mm at 300 DPI — minimal padding so diff --git a/backend/tests/api/test_openapi_completeness.py b/backend/tests/api/test_openapi_completeness.py index bf9da90..a9a68f9 100644 --- a/backend/tests/api/test_openapi_completeness.py +++ b/backend/tests/api/test_openapi_completeness.py @@ -140,7 +140,7 @@ def test_json_responses_have_schemas(openapi_schema: dict[str, Any]) -> None: def test_endpoint_count_in_range(openapi_schema: dict[str, Any]) -> None: - """Operation count must be between 23 and 45. + """Operation count must be between 28 and 45. Expected breakdown: printers (7) + templates (1) + jobs (6) + lookup (1) + webhooks (2) @@ -153,10 +153,10 @@ def test_endpoint_count_in_range(openapi_schema: dict[str, Any]) -> None: + /api/templates/{key}/preview-svg (1) = Phase-1i D Total = 29+ - The range 23-45 is intentionally wide to tolerate minor additions + The range 28-45 is intentionally wide to tolerate minor additions (e.g. a future ``/healthz/db`` probe) without requiring this test to be updated. It will still catch the case where an entire router is - accidentally unregistered (count drops below 23) or a rogue batch of + accidentally unregistered (count drops below 28) or a rogue batch of undocumented endpoints lands (count exceeds 45). """ count = sum(1 for _ in _iter_operations(openapi_schema)) diff --git a/backend/tests/integration/db/test_lifespan_printer_upsert.py b/backend/tests/integration/db/test_lifespan_printer_upsert.py index 212b4fc..0796ee8 100644 --- a/backend/tests/integration/db/test_lifespan_printer_upsert.py +++ b/backend/tests/integration/db/test_lifespan_printer_upsert.py @@ -4,6 +4,8 @@ R4-M-4/M-5-Fix: Ersetzt test_lifespan_printer_upsert.py das noch die entfernte upsert_runtime_printer(Settings) Funktion testete. M-H2-Fix: Multi-Printer-Loop. +PR#98-Gemini: session.flush() statt commit() im Loop — atomare Transaktion. +PR#98-Copilot: Slug-Collision-Detection bei UUID-Wechsel. """ from __future__ import annotations @@ -122,3 +124,66 @@ async def test_upsert_multi_printer_is_idempotent(async_session_empty): result = await async_session_empty.execute(select(Printer)) rows = list(result.scalars()) assert len(rows) == 2 + + +# --- PR#98 Gemini + Copilot: flush() + slug-collision-detection --- + + +async def test_same_uuid_update_idempotent(async_session_empty): + """PR#98-Gemini: Gleiche UUID beim zweiten Upsert → normaler UPDATE-Pfad.""" + cfg_v1 = _pt750w_cfg(slug="pt-office", name="PT Office v1") + ids_v1 = await upsert_runtime_printers(async_session_empty, [cfg_v1]) + pid = ids_v1[0] + + cfg_v2 = _pt750w_cfg(slug="pt-office-renamed", name="PT Office v2") + ids_v2 = await upsert_runtime_printers(async_session_empty, [cfg_v2]) + + # UUID bleibt gleich (model/host/port unverändert) + assert ids_v2[0] == pid + result = await async_session_empty.execute(select(Printer)) + rows = list(result.scalars()) + assert len(rows) == 1 + assert rows[0].slug == "pt-office-renamed" + assert rows[0].name == "PT Office v2" + + +async def test_slug_collision_different_uuid_migrates(async_session_empty): + """PR#98-Copilot: Slug-Collision — alter Row mit gleicher slug aber anderer UUID + wird gelöscht und durch neuen Row mit neuer UUID ersetzt (Migration-Pfad). + """ + # Erster Eintrag: PT-P750W auf host .50 + cfg_old = _pt750w_cfg(slug="office-printer", name="Office Printer", host="192.0.2.50") + ids_old = await upsert_runtime_printers(async_session_empty, [cfg_old]) + old_uuid = ids_old[0] + + # Zweiter Eintrag: gleiche slug, aber anderer host → andere UUID + cfg_new = _pt750w_cfg(slug="office-printer", name="Office Printer", host="192.0.2.99") + new_uuid = derive_printer_id(_PT750W_MODEL, "192.0.2.99", _PT750W_PORT) + assert new_uuid != old_uuid # Sicherheitscheck: UUIDs müssen verschieden sein + + ids_new = await upsert_runtime_printers(async_session_empty, [cfg_new]) + assert ids_new[0] == new_uuid + + # Es gibt nur noch einen Row mit der neuen UUID + result = await async_session_empty.execute(select(Printer)) + rows = list(result.scalars()) + assert len(rows) == 1 + assert rows[0].id == new_uuid + assert rows[0].slug == "office-printer" + + +async def test_multi_printer_transaction_atomicity(async_session_empty): + """PR#98-Gemini: flush()-in-loop + commit()-am-Ende bleibt atomar. + Alle Rows landen in derselben Transaktion; kein Partial-Write bei Fehler. + """ + cfg1 = _pt750w_cfg(slug="atomic-a", name="Atomic A", host="192.0.2.50") + cfg2 = _pt750w_cfg(slug="atomic-b", name="Atomic B", host="192.0.2.51") + + returned_ids = await upsert_runtime_printers(async_session_empty, [cfg1, cfg2]) + assert len(returned_ids) == 2 + + result = await async_session_empty.execute(select(Printer)) + rows = list(result.scalars()) + assert len(rows) == 2 + slugs = {r.slug for r in rows} + assert slugs == {"atomic-a", "atomic-b"} diff --git a/backend/tests/unit/printer_backends/test_brother_ql_backend.py b/backend/tests/unit/printer_backends/test_brother_ql_backend.py index f8556ef..2964862 100644 --- a/backend/tests/unit/printer_backends/test_brother_ql_backend.py +++ b/backend/tests/unit/printer_backends/test_brother_ql_backend.py @@ -74,3 +74,69 @@ async def test_half_cut_ignored_on_ql_with_warning( def test_half_cut_supported_is_false() -> None: assert BrotherQLBackend.half_cut_supported is False + + +# --------------------------------------------------------------------------- +# preflight_check — Phase 1i hotfix (Task 8b) +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_preflight_check_success(monkeypatch: pytest.MonkeyPatch) -> None: + """preflight_check returns PreflightStatus on healthy printer.""" + from app.printer_backends.snmp_helper import PreflightStatus + + async def fake_preflight( + host: str, *, community: str = "public", timeout_s: float = 3.0 + ) -> PreflightStatus: + return PreflightStatus( + hr_printer_status="idle", + loaded_tape_mm=62, + error_flags=[], + ) + + monkeypatch.setattr("app.printer_backends.brother_ql_backend.query_preflight", fake_preflight) + backend = BrotherQLBackend(host="192.0.2.11", model_id="QL-820NWB") + result = await backend.preflight_check() + assert result.hr_printer_status == "idle" + assert result.loaded_tape_mm == 62 + + +@pytest.mark.anyio +async def test_preflight_check_raises_tape_empty(monkeypatch: pytest.MonkeyPatch) -> None: + """preflight_check raises TapeEmptyError when noPaper bit is set.""" + from app.printer_backends.exceptions import TapeEmptyError + from app.printer_backends.snmp_helper import PreflightStatus + + async def fake_preflight( + host: str, *, community: str = "public", timeout_s: float = 3.0 + ) -> PreflightStatus: + return PreflightStatus( + hr_printer_status="other", + loaded_tape_mm=None, + error_flags=["noPaper"], + ) + + monkeypatch.setattr("app.printer_backends.brother_ql_backend.query_preflight", fake_preflight) + backend = BrotherQLBackend(host="x", model_id="QL-820NWB") + with pytest.raises(TapeEmptyError): + await backend.preflight_check() + + +@pytest.mark.anyio +async def test_preflight_check_raises_offline_on_snmp_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """preflight_check raises PrinterOfflineError when SNMP query fails.""" + from app.printer_backends.exceptions import PrinterOfflineError + from app.printer_backends.snmp_helper import PreflightStatus, SnmpQueryError + + async def fake_preflight( + host: str, *, community: str = "public", timeout_s: float = 3.0 + ) -> PreflightStatus: + raise SnmpQueryError("timeout") + + monkeypatch.setattr("app.printer_backends.brother_ql_backend.query_preflight", fake_preflight) + backend = BrotherQLBackend(host="unreachable", model_id="QL-820NWB") + with pytest.raises(PrinterOfflineError, match="preflight SNMP failed"): + await backend.preflight_check() diff --git a/docs/research/2026-06-02-pt750w-layout-diagnose.md b/docs/research/2026-06-02-pt750w-layout-diagnose.md index d53d25e..c4a43db 100644 --- a/docs/research/2026-06-02-pt750w-layout-diagnose.md +++ b/docs/research/2026-06-02-pt750w-layout-diagnose.md @@ -16,7 +16,7 @@ | 18mm | 165px | 112px | +53px | -27px (Crop!) | | 24mm | 256px | 128px | +128px | -64px (Crop!) | -Gemessene Bitmap aus `GET /api/templates/hangar-furniture-12mm/preview.png`: +Gemessene Bitmap aus `GET /api/templates/hangar-furniture-12mm/preview-png`: - **Größe:** 284 × 106px (nach Trim-Crop auf der Längsachse) - **Erwartete Druckhöhe (ptouch native):** 70px