diff --git a/README.md b/README.md index 11057ce..e1a5471 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ An InvenTree **machine driver** that dispatches label print jobs to a [labelfab](https://github.com/sengine-cloud/labelfab) agent over MQTT. Click *Print* in InvenTree; a job lands on the broker; the agent renders and prints it on a Phomemo -D30. +D30. A second plugin in the same package resolves the printed QR codes back to their +InvenTree items when scanned. ## Why a machine driver, not a `LabelPrintingMixin` @@ -118,6 +119,10 @@ next refresh picks up the truth from the retained topic. - Default path sends `{preset, vars}` — the producer stays dumb and layout ownership stays in the agent. Extractors map a `StockItem` / `Part` / `StockLocation` to a short scannable code plus title/sub text. +- The title leads with the item's InvenTree short barcode and IPN (`INV-SI1 · A1533`), + with the name underneath. The short barcode can be typed into InvenTree's scanner as + is, so a label stays usable when its QR does not scan. The prefix is read from the + builtin barcode plugin's `SHORT_BARCODE_PREFIX`, so it follows the instance. - `server_render` (a per-machine setting or a per-print option) is the escape hatch: render the InvenTree label template to PNG here and send it as a `raw_png` element. One awkward label becomes a checkbox, not a code change. @@ -143,12 +148,31 @@ the Django-Q worker (that is where `print_labels` runs): Per-printer settings (Printer ID, tape width/kind, default preset, server render) live on the machine in the InvenTree admin. +## Scanning labels back + +The QR on a label encodes the agent's `render.qr_base_url` plus a bare code, for example +`https://sngn.top/i/SI1`. A phone opens that as a link, but InvenTree's own scanner only +knows its native short format (`INV-SI1`) and answers "No match found". + +**LabelfabShortlink** (`labelfab-shortlink`) closes that gap. It strips the configured +base, rebuilds `INV-SI1`, and hands it to InvenTree's builtin barcode plugin, so model +codes, permissions and the response are all InvenTree's own. Nothing has to be linked +per item. + +1. **Admin → Plugins**: activate **LabelfabShortlink**. +2. Set **Short link base** to the same value as the agent's `render.qr_base_url`. + Scheme and case are ignored when matching, so labels printed as `SNGN.TOP/I/PA39` + and as `https://sngn.top/i/PA39` both resolve. Empty disables the plugin. + +It depends on the builtin **InvenTreeBarcode** plugin being active, which it is by +default. + ## Development ```bash python -m venv .venv && . .venv/bin/activate pip install -e '.[dev]' -pytest # covers the pure dispatch + extractor logic +pytest # covers the pure dispatch, extractor and short-link logic ruff check inventree_label_dispatch tests ``` diff --git a/inventree_label_dispatch/barcode.py b/inventree_label_dispatch/barcode.py new file mode 100644 index 0000000..a9a28f4 --- /dev/null +++ b/inventree_label_dispatch/barcode.py @@ -0,0 +1,61 @@ +"""Barcode plugin that resolves the short links printed on labelfab labels. + +A plugin class of its own, beside the machine driver, so it can be enabled separately. +It only translates: ``https://sngn.top/i/SI1`` becomes ``INV-SI1`` and goes to +InvenTree's builtin barcode plugin, so model codes, permissions and the response shape +all stay InvenTree's. Without it, InvenTree answers "No match found" for every label +this package prints, and linking barcodes item by item does not scale. +""" + +from __future__ import annotations + +from plugin import InvenTreePlugin +from plugin.mixins import BarcodeMixin, SettingsMixin +from plugin.registry import registry + +from . import shortlink +from .extractors import DEFAULT_PREFIX +from .version import __version__ + +#: InvenTree's builtin barcode plugin. It owns the short format and its prefix. +BUILTIN = "inventreebarcode" + + +def short_prefix() -> str: + """The instance's short barcode prefix (``INV-`` unless someone changed it).""" + builtin = registry.get_plugin(BUILTIN) + if builtin is None: + return DEFAULT_PREFIX + return builtin.get_setting("SHORT_BARCODE_PREFIX") or DEFAULT_PREFIX + + +class LabelfabShortlinkPlugin(BarcodeMixin, SettingsMixin, InvenTreePlugin): + NAME = "LabelfabShortlink" + SLUG = "labelfab-shortlink" + TITLE = "labelfab short links" + DESCRIPTION = "Scan the short-link QR codes on labelfab labels, e.g. https://sngn.top/i/SI1" + AUTHOR = "sengine" + VERSION = __version__ + WEBSITE = "https://github.com/sengine-cloud/inventree-label-dispatch" + MIN_VERSION = "1.4.0" + + SETTINGS = { + "SHORTLINK_BASE": { + "name": "Short link base", + "description": ( + "The same value as the agent's render.qr_base_url, e.g. " + "https://sngn.top/i/. Leave empty to disable." + ), + "default": "", + }, + } + + def scan(self, barcode_data, user, **kwargs): + parsed = shortlink.parse(barcode_data, self.get_setting("SHORTLINK_BASE")) + if parsed is None: + return None + builtin = registry.get_plugin(BUILTIN) + if builtin is None: + return None + code, pk = parsed + return builtin.scan(f"{short_prefix()}{code}{pk}", user, **kwargs) diff --git a/inventree_label_dispatch/driver.py b/inventree_label_dispatch/driver.py index ea99504..5286a92 100644 --- a/inventree_label_dispatch/driver.py +++ b/inventree_label_dispatch/driver.py @@ -192,13 +192,17 @@ def print_labels(self, machine, label, items, **kwargs): tape_kind = machine.get_setting("TAPE_KIND", "D") or "continuous" length = options.get("label_length_mm") + # Read once per batch: the prefix is an InvenTree setting, not per item. + from .barcode import short_prefix + + prefix = short_prefix() labels = [] for item in items: if server_render: png = self._png(label, item) spec = {"elements": [{"type": "raw_png", "data_b64": png, "fit": "contain"}]} else: - spec = {"preset": preset, "vars": extract(item)} + spec = {"preset": preset, "vars": extract(item, prefix)} labels.append(build_label(spec, copies=copies, length_mm=length)) job = build_job( diff --git a/inventree_label_dispatch/extractors.py b/inventree_label_dispatch/extractors.py index 8f2413e..87d22a3 100644 --- a/inventree_label_dispatch/extractors.py +++ b/inventree_label_dispatch/extractors.py @@ -3,42 +3,73 @@ Duck-typed on purpose: each extractor reads attributes, so a plain stand-in object exercises it in a test without a Django database. The agent owns layout; these only decide *what text* a label carries, keyed by a short scannable code. + +Every label leads with the item's InvenTree short barcode (``INV-SI1``) and IPN, so +the text can be typed into InvenTree's scanner when the QR is unreadable. The name +goes underneath as context. ``code`` stays the bare ``SI1``: it is what the QR +encodes, behind the agent's short-link base, and every character there costs module +size on 12mm tape. """ from __future__ import annotations +from decimal import Decimal, InvalidOperation + +#: InvenTree's default ``SHORT_BARCODE_PREFIX``. The driver passes the instance's real +#: setting; this only covers callers that have no InvenTree to ask. +DEFAULT_PREFIX = "INV-" + def _s(value) -> str: return "" if value is None else str(value) -def extract_stock_item(item) -> dict: +def _qty(value) -> str: + """``1.00000`` -> ``1`` and ``250.00000`` -> ``250``; ``1.5`` stays ``1.5``.""" + try: + return format(Decimal(str(value)).normalize(), "f") + except (InvalidOperation, ValueError): + return _s(value) + + +def _headline(short_code: str, ipn) -> str: + ipn = _s(ipn) + return f"{short_code} · {ipn}" if ipn else short_code + + +def extract_stock_item(item, prefix: str = DEFAULT_PREFIX) -> dict: part = getattr(item, "part", None) - location = getattr(item, "location", None) + sub = _s(getattr(part, "name", "") or getattr(part, "full_name", "")) qty = getattr(item, "quantity", None) - sub = _s(getattr(location, "name", "")) if location else "" - if qty is not None: - sub = f"{sub} · {qty}".strip(" ·") + # A serialised item is exactly one by definition, and a count of one says nothing, + # so the quantity only earns space on bulk stock. + if qty is not None and not getattr(item, "serial", None) and _qty(qty) != "1": + sub = f"{sub} · ×{_qty(qty)}".strip(" ·") + code = f"SI{item.pk}" return { - "code": f"SI{item.pk}", - "title": _s(getattr(part, "full_name", None) or getattr(part, "name", "")), + "code": code, + "title": _headline(f"{prefix}{code}", getattr(part, "IPN", None)), "sub": sub, } -def extract_part(item) -> dict: +def extract_part(item, prefix: str = DEFAULT_PREFIX) -> dict: + code = f"PA{item.pk}" return { - "code": f"PA{item.pk}", - "title": _s(getattr(item, "full_name", None) or getattr(item, "name", "")), - "sub": _s(getattr(item, "IPN", "") or getattr(item, "description", "")), + "code": code, + "title": _headline(f"{prefix}{code}", getattr(item, "IPN", None)), + # ``name``, not ``full_name``: InvenTree builds full_name as "IPN | name", and + # the IPN is already in the headline. + "sub": _s(getattr(item, "name", "") or getattr(item, "full_name", "")), } -def extract_stock_location(item) -> dict: +def extract_stock_location(item, prefix: str = DEFAULT_PREFIX) -> dict: + code = f"SL{item.pk}" return { - "code": f"SL{item.pk}", - "title": _s(getattr(item, "name", "")), - "sub": _s(getattr(item, "pathstring", "")), + "code": code, + "title": f"{prefix}{code}", + "sub": _s(getattr(item, "name", "")), } @@ -50,11 +81,11 @@ def extract_stock_location(item) -> dict: } -def extract(item) -> dict: +def extract(item, prefix: str = DEFAULT_PREFIX) -> dict: """Best-effort extraction, dispatched on the item's model class name.""" fn = _BY_MODEL.get(type(item).__name__) if fn is not None: - return fn(item) + return fn(item, prefix) # Unknown model: fall back to the primary key, so a label still prints something # scannable rather than the driver erroring on an unmapped type. return {"code": f"PK{getattr(item, 'pk', '?')}", "title": _s(getattr(item, 'name', '')), "sub": ""} diff --git a/inventree_label_dispatch/shortlink.py b/inventree_label_dispatch/shortlink.py new file mode 100644 index 0000000..7aa7567 --- /dev/null +++ b/inventree_label_dispatch/shortlink.py @@ -0,0 +1,41 @@ +"""Map the short links printed in label QR codes back to InvenTree's short barcodes. + +The agent prints ````, for example ``https://sngn.top/i/SI1``, so a +phone camera gets a URL it can open. InvenTree's own scanner only resolves its native +short format, ``INV-SI1``. Parsing lives here with no InvenTree imports so it can be +tested on its own; the plugin in ``barcode`` does the lookup. +""" + +from __future__ import annotations + +import re + +_SCHEME = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE) +#: Two-character model code, then the primary key. InvenTree's codes are uppercase +#: letters (PA, SI, SL, ...); an optional trailing slash tolerates hand-typed links. +_CODE = re.compile(r"([0-9A-Z]{2})(\d+)/?") + + +def _bare(url: str) -> str: + """Drop the scheme and lowercase, so ``HTTPS://SNGN.TOP/I/`` equals ``sngn.top/i/``.""" + return _SCHEME.sub("", url.strip()).lower() + + +def parse(data, base: str) -> tuple[str, int] | None: + """Return ``(model_code, pk)`` when ``data`` is a short link under ``base``. + + Scheme and case are ignored on both sides: the same label may carry the bare + uppercase form (``SNGN.TOP/I/PA39``, which keeps the QR in alphanumeric mode) or a + full URL (``https://sngn.top/i/PA39``, which phones open directly). An empty + ``base`` matches nothing, so the plugin stays inert until it is configured. + """ + if not base or not isinstance(data, str): + return None + prefix = _bare(base) + bare = _bare(data) + if not bare.startswith(prefix): + return None + match = _CODE.fullmatch(bare.removeprefix(prefix).lstrip("/").upper()) + if match is None: + return None + return match.group(1), int(match.group(2)) diff --git a/pyproject.toml b/pyproject.toml index 781a520..2f81139 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "inventree-label-dispatch" -description = "InvenTree machine driver that dispatches labels to a labelfab print agent" +description = "InvenTree machine driver that dispatches labels to a labelfab print agent, and scans them back" readme = "README.md" requires-python = ">=3.9" license = { text = "MIT" } @@ -23,6 +23,7 @@ dev = ["pytest>=8", "ruff>=0.4"] # deliberately not dependencies here. [project.entry-points."inventree_plugins"] LabelfabDispatch = "inventree_label_dispatch.plugin:LabelfabDispatchPlugin" +LabelfabShortlink = "inventree_label_dispatch.barcode:LabelfabShortlinkPlugin" [project.urls] Homepage = "https://github.com/sengine-cloud/inventree-label-dispatch" diff --git a/tests/test_extractors.py b/tests/test_extractors.py index 9334949..f5f961d 100644 --- a/tests/test_extractors.py +++ b/tests/test_extractors.py @@ -6,12 +6,15 @@ from __future__ import annotations +from decimal import Decimal + from inventree_label_dispatch.extractors import extract class StockItem: - def __init__(self, pk, part, location, quantity): - self.pk, self.part, self.location, self.quantity = pk, part, location, quantity + def __init__(self, pk, part, location=None, quantity=None, serial=None): + self.pk, self.part, self.location = pk, part, location + self.quantity, self.serial = quantity, serial class Part: @@ -33,22 +36,47 @@ def __init__(self, pk, name): self.pk, self.name = pk, name -def test_stock_item(): - item = StockItem(4821, Part(1, full_name="M3x8 hex bolt"), StockLocation(2, "BIN-A4", ""), 250) - out = extract(item) - assert out["code"] == "SI4821" - assert out["title"] == "M3x8 hex bolt" - assert "BIN-A4" in out["sub"] and "250" in out["sub"] +PHONE = Part(39, full_name="A1533 | iPhone 5s", name="iPhone 5s", IPN="A1533") + + +def test_serialised_stock_item_leads_with_short_code_and_ipn(): + out = extract(StockItem(1, PHONE, quantity=Decimal("1.00000"), serial="358814056238878")) + assert out == {"code": "SI1", "title": "INV-SI1 · A1533", "sub": "iPhone 5s"} + + +def test_bulk_stock_shows_quantity_without_trailing_zeros(): + bolt = Part(7, name="M3x8 hex bolt", IPN="M3-8") + out = extract(StockItem(4821, bolt, StockLocation(2, "BIN-A4", ""), Decimal("250.00000"))) + assert out["title"] == "INV-SI4821 · M3-8" + assert out["sub"] == "M3x8 hex bolt · ×250" + + +def test_a_count_of_one_is_not_printed(): + out = extract(StockItem(5, Part(7, name="Bracket"), quantity=Decimal("1.00000"))) + assert out["sub"] == "Bracket" + + +def test_fractional_quantity_keeps_its_fraction(): + out = extract(StockItem(6, Part(8, name="Solder"), quantity=Decimal("1.50000"))) + assert out["sub"] == "Solder · ×1.5" + + +def test_part_uses_name_not_full_name(): + # full_name is "IPN | name"; the IPN is already in the headline. + assert extract(PHONE) == {"code": "PA39", "title": "INV-PA39 · A1533", "sub": "iPhone 5s"} + + +def test_part_without_ipn_is_just_the_short_code(): + assert extract(Part(12, name="Widget"))["title"] == "INV-PA12" -def test_part(): - out = extract(Part(12, full_name="Widget", IPN="WID-001")) - assert out == {"code": "PA12", "title": "Widget", "sub": "WID-001"} +def test_prefix_follows_the_instance_setting(): + assert extract(PHONE, "ACME-")["title"] == "ACME-PA39 · A1533" def test_stock_location(): out = extract(StockLocation(3, "Shelf 3", "Warehouse/Shelf 3")) - assert out == {"code": "SL3", "title": "Shelf 3", "sub": "Warehouse/Shelf 3"} + assert out == {"code": "SL3", "title": "INV-SL3", "sub": "Shelf 3"} def test_unknown_model_falls_back_to_pk(): diff --git a/tests/test_shortlink.py b/tests/test_shortlink.py new file mode 100644 index 0000000..0f75d6f --- /dev/null +++ b/tests/test_shortlink.py @@ -0,0 +1,57 @@ +"""Short-link parsing: what the QR on a label resolves to, with no InvenTree needed.""" + +from __future__ import annotations + +import pytest + +from inventree_label_dispatch.shortlink import parse + +BASE = "https://sngn.top/i/" + + +@pytest.mark.parametrize( + "data, expected", + [ + ("https://sngn.top/i/SI1", ("SI", 1)), + # Labels printed before the scheme was added: bare and uppercase. + ("SNGN.TOP/I/PA39", ("PA", 39)), + ("http://sngn.top/i/sl5", ("SL", 5)), + ("https://sngn.top/i/PA39/", ("PA", 39)), + (" https://sngn.top/i/SI1\n", ("SI", 1)), + ], +) +def test_matches(data, expected): + assert parse(data, BASE) == expected + + +@pytest.mark.parametrize( + "data", + [ + "INV-SI1", # InvenTree's own format; the builtin plugin handles it + "SI1", # a bare code is not a link; only the base makes it one + "https://example.com/i/SI1", + "https://sngn.top.example.com/i/SI1", + "https://sngn.top/x/SI1", + "https://sngn.top/i/", + "https://sngn.top/i/SI", + "https://sngn.top/i/S1", + "https://sngn.top/i/SI1x", + "https://sngn.top/i/SI1?utm=x", + "", + ], +) +def test_rejects(data): + assert parse(data, BASE) is None + + +def test_base_scheme_and_trailing_slash_are_optional(): + assert parse("https://sngn.top/i/SI1", "sngn.top/i") == ("SI", 1) + + +def test_empty_base_disables_matching(): + assert parse("https://sngn.top/i/SI1", "") is None + + +@pytest.mark.parametrize("data", [None, {"stockitem": 1}, 42]) +def test_non_string_data_is_ignored(data): + assert parse(data, BASE) is None