No description
  • Python 73.7%
  • JavaScript 17.5%
  • HTML 7%
  • CSS 1.2%
  • Shell 0.3%
  • Other 0.3%
Find a file
System Operator bd3bc03948 v2.1.0: JavaScript browser test suite (delta 25.2), Checkout box-UUID
scan (delta 25.1)

Until now no JavaScript in this repo had a single test. That is 24 files
and 5,251 lines -- the half of the application an operator actually
touches, and the half every delta changes -- verified only by clicking
through it by hand each time. delta25.2 adds 266 browser tests across
six phases, leaving the default `pytest tests/` run untouched at 741.

They drive a real browser against a running instance rather than
importing the page scripts, because 19 of the 24 files are IIFEs that
export nothing and are reachable no other way. pytest-playwright was
chosen over a JS-native runner so the whole suite stays one runner, one
language and one command, and so no Node toolchain enters a repo whose
deployment target has none.

The suite paid for itself during construction. It found three real
defects, all fixed here in phase 6: a partial print run reported
"Printed 1 of 3 copy", agreeing with how many copies succeeded rather
than the total; every generated Checkout PDF downloaded as the hardcoded
fallback "checkout.pdf", because the page parsed Content-Disposition
with a pattern requiring quotes the server does not send, discarding a
correctly-built checkin/checkout name; and app/pinned_fields.py still
described five pinned fields after Shelf made six. It also caught its
own blind spots -- three assertions that passed for the wrong reason,
and a session-scoped admin fixture that silently went unauthenticated
once a restart regenerated the signing key.

delta25.1 routes a Receiving box's QR code to Import Box when it is
scanned into Checkout's Scan UPC field. That field previously handed the
UUID to /resolve, which reported it as an unknown code and offered to
commit a catalog entry keyed by it -- the mis-scan an operator makes
while holding a box label. The Import Box preview and Confirm still gate
every merge.

Test data is handled by bracketing: the battery takes a system backup up
front and restores it afterwards, and destructive Maintenance tests take
a further per-test bracket that holds restarts with Maintenance Mode so a
purge cannot leave the rest of the run against empty databases. No
assertion states how many rows the instance holds.

Documentation: README gains an Environment setup section covering the
dev workstation and the Debian 13 netinst container host, since nothing
recorded what a machine needs to build, test or run this. dev/tests.md
documents the browser battery, including that the container must be
rebuilt after any change under app/ -- application code is baked into
the image, so tests otherwise exercise stale code and report on it
correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:37:47 -04:00
app v2.1.0: JavaScript browser test suite (delta 25.2), Checkout box-UUID 2026-09-15 20:37:47 -04:00
dev v2.1.0: JavaScript browser test suite (delta 25.2), Checkout box-UUID 2026-09-15 20:37:47 -04:00
rootfs/etc Complete the active_projects migration to ephemera.db 2026-09-01 11:19:33 -04:00
scripts v2.1.0: JavaScript browser test suite (delta 25.2), Checkout box-UUID 2026-09-15 20:37:47 -04:00
spec v2.1.0: JavaScript browser test suite (delta 25.2), Checkout box-UUID 2026-09-15 20:37:47 -04:00
tests v2.1.0: JavaScript browser test suite (delta 25.2), Checkout box-UUID 2026-09-15 20:37:47 -04:00
.dockerignore v1.0.1: quantity number input, version badge, config-release spec 2026-08-04 11:31:46 -04:00
.gitattributes v2.0.1: Checkout mode (delta16) -- scan-and-generate PDF pickup/return receipts 2026-08-18 13:52:06 -04:00
.gitignore v1.9.9-pre.9: stop tracking .claude/settings.local.json 2026-08-17 07:11:01 -04:00
CLAUDE.md Restructure README and move test documentation to dev/ 2026-09-14 07:49:40 -04:00
docker-compose.yml v1.6.4: mDNS self-advertisement and printer discovery (delta10) 2026-08-08 23:48:07 -04:00
Dockerfile Migrate to s6-overlay + supercronic process supervision (delta21 phase 4 sub-phase 1) 2026-08-26 09:07:30 -04:00
README.md v2.1.0: JavaScript browser test suite (delta 25.2), Checkout box-UUID 2026-09-15 20:37:47 -04:00
requirements-dev.txt v2.1.0: JavaScript browser test suite (delta 25.2), Checkout box-UUID 2026-09-15 20:37:47 -04:00
requirements.txt Printed QR code on box labels (delta24 phase 1) 2026-09-01 09:43:35 -04:00

Box Legend

Warehouse tooling built around a shared UPC catalog. Three modes share one container and one nav:

  • Receiving — scan items into a box, enter header fields, print one label carrying that header plus a human-readable contents list. Every successful print is durably logged and can later be viewed, corrected, reprinted or voided.
  • Snapshot — scan items into named counts. Every scan is a signed delta against an append-only event log, so parallel scanners and a live master display always agree. Exports to CSV and PDF.
  • Checkout — scan items leaving or returning to the warehouse and generate a Letter-size PDF receipt.

Not an inventory tracking system. The modes are loosely coupled: they share the UPC catalog, the settings and account tables, the pinned-field registry, and the Active Projects lookup, and Checkout reads a Receiving box's contents for its box import.

Contents

Status

Deployed and operational in the live warehouse environment. The exact running build is shown in the footer of every page — that badge is the source of truth, not this file.

Architecture

  • Flask server (app/), served by waitress. Clients are plain browsers pointed at the server's LAN address; they hold no logic.
  • Durable state is SQLite, on files that must live on a mounted volume. app/databases.py is the registry of every database the app manages — catalog, box log, Snapshot, Checkout, the two archives, and ephemera — and drives backup, restore, purge and the Status page uniformly. Adding a database means one registry entry, not several hardcoded lists. All are WAL mode.
  • The catalog (app/db.py) is a UPC → name map with an optional, non-unique local part ID per entry, plus a key/value config table. It is the one deliberate coupling between the three modes.
  • The box log (app/box_log.py) is split across three append-only tables: box_log (one delta row per item per event — current quantity is the sum), box_details (box-level header values, latest row wins), and box_voids (a void marker, not a mutation).
  • The in-progress box is held entirely in the browser (app/static/scan.js) and is never persisted server-side. A reload loses it — that is accepted.
  • Labels are composed with Pillow (app/labels.py) and sent through CUPS (app/printing.py), which runs inside this same container alongside the Flask app. CUPS is driven entirely via subprocess calls to lp/lpadmin/lpoptions, never a Python CUPS binding, to keep GPL-licensed printer drivers at arm's length as separately-invoked system processes rather than linked into this application's process. A curated table of tested printers (currently a Brother QL-820NWB via printer-driver-ptouch) lives in app/printing.py; an untested printer can be driven through Custom mode, which takes a raw CUPS PPD path, physical roll width and device URI directly.
  • Settings resolve through a three-tier ladder (app/config.py): DB override → environment variable → hardcoded default.
  • Navigation and the version footer are shared templates (app/templates/_nav.html, _footer.html) included by every page. Nav links and the settings dropdown are mode-scoped, reusing the same chrome across all three modes.
  • Icons come from one vocabulary (app/static/icons.js, Tabler Icons, vendored at app/static/vendor/tabler-icons/ — no CDN at runtime), always paired with a visible text label. Each page's <h1> carries the same icon as its nav link.

Features

Shared across modes

Modes. The nav's side-switcher (top left) toggles between Receiving, Snapshot and Checkout. Switching is a plain form POST, always allowed regardless of any Require Login toggle, so a visitor blocked from one mode can always reach another. Each mode is its own Flask blueprint (app/routes/); pages with no mode of their own — Catalog Maintenance, Status, User Management, Licenses, login — live on a shared common blueprint. app/modes.py's MODES list is the single source of truth every nav, session and gating mechanic iterates.

Catalog. Scanning a UPC resolves it to a name; an unknown UPC prompts for one, which commits to the catalog permanently. Cancel on that modal aborts a mis-scan cleanly — no catalog write, no box entry. The same modal fixes a mis-named entry in place from the scan page, without navigating away and losing the in-progress box.

Catalog Maintenance (/catalog/manage) edits a name (correcting all future labels), sets a local part ID (nullable, not unique — several UPCs can share one; entered here, never prompted for during scanning), or deletes an entry behind a confirm step. A filter narrows the list to entries missing a local part ID.

Settings pages. Reached from the gear icon, each with its own Save that submits only that page's settings, and its own scoped Reset to defaults — there is no button that wipes everything at once. A reset computes what each of that page's settings would resolve to on the next fallback tier and writes it back as an explicit override.

  • Printer & Media (/config/manage) — pick a printer from the curated list, or switch to Custom for an untested printer (PPD path, roll width, device URI). Saving reprovisions the live CUPS queue and auto-detects the printer's DPI and hardware margin from its own driver, for curated and Custom alike; a manual four-field override covers drivers that do not expose it. Test Print sends a real label through the full pipeline. Detect Printer browses the LAN for IPP Everywhere printers and pre-fills a driverless configuration on selection — the operator still reviews and saves; nothing is provisioned automatically. The browse runs only while the modal is open, never as a standing listener.
  • Label Layout (/config/label-layout) — the two label font sizes and line wrapping.
  • Header Fields (/config/header-fields) — six permanent pinned fields, each with a capture mode (Required / Optional / Hidden), a print abbreviation, and its own print-on-label toggle: Project Number and Purchase Order (Receiving only, default Required), Project Name, Project Manager, Project Lead (Receiving and Checkout, default Optional), and Shelf (Checkout only, default Optional). Shown per mode. Below them, the freeform header field list — add, remove, reorder, each with a screen label, a print label and a Required toggle that blocks Print/Generate until filled.
  • Report Config (/report-config/manage, admin-only) — independent of capture mode, a "required for export" checkbox per applicable pinned field, plus the Archiving fieldset (archive_inactivity_days). Required-for-export never blocks printing or generating a PDF; it gates only the weekly archival job. Checking a field whose capture mode is not Required raises a warning banner on the scan page.
  • Global Settings (nav dropdown) — display timezone and the Active Projects .xlsx import. Genuinely global, not scoped to a mode.

Active Projects. An operator-uploaded .xlsx populates a lookup of Project Number / Name / Manager / Lead, keyed by Project Number — a full replace on every import, never a merge. Columns are read by header name, not position: "Proposal #" → Project Number, "Project Title" (Proposal # prefix stripped) → Project Name, "Installing PM" falling back to "Selling PM" → Project Manager, "Project Lead" → Project Lead. A row with no Proposal # is skipped. On the Receiving and Checkout scan pages, typing into Project Number prefix-matches this table and offers a dropdown; selecting an entry fills Name/Manager/Lead. The footer link shows the last import time, turning red past 144 hours.

Login and roles. One fixed admin account (username never changes) and any number of admin-managed operator accounts. Require Login is four independent toggles, all off by default: one per mode plus one for the Status page. A mode-owned page is always gated by that mode's own toggle, never by which mode the nav is showing; common pages follow whichever mode is active. /maintenance, its unlinked companion page and Licenses are always exempt.

Two kinds of page are admin-only regardless of any toggle — User Management (/users/manage) and each mode's own Report Config. That is a flat role check, not a login redirect: an unauthenticated visitor and a logged-in operator both get a plain Access Denied, never a step-up prompt. A Login/Logout button showing the current username sits in the header at all times, so admin always has a way in with every toggle off.

Sessions are Flask's signed cookie, keyed by a secret generated in memory on every process start and never persisted — a restart logs everyone out, by design. Passwords are hashed with PBKDF2-HMAC-SHA256. Admin can always change their own password; a separate global toggle controls whether operators can change theirs (off by default). box_log/box_details/box_voids each carry a username column, NULL when there was no session. Catalog edits and config changes are not attributed.

Archiving. A weekly job (Sunday, via supercronic) moves inactive records into their own archive database, keyed off the record's most recent write rather than a creation date — archive_inactivity_days on Report Config, 0 disables it. Receiving's boxes move from box_log.db to box_archive.db; Checkout's reports move from checkout.db to checkout_archive.db. A record missing any currently required-for-export field is held back indefinitely rather than archived incomplete. Both archive databases are covered by the Maintenance tooling like any other registered database.

Maintenance (/maintenance). Unlinked, reachable from no nav — an emergency escape hatch, not an auth system. Shows the system ID and a challenge word; entering the matching response unlocks the page. No session or cookie anywhere in this flow: every action re-proves itself, and access lasts only as long as the page stays open. The page body is buttons only; anything needing a selection, filename or file picker opens its own modal.

  • Live actions — Reset Admin Password, which touches only the admin account.
  • Purges — Config, Catalog and Users are separately-scoped table-level purges within catalog.db (Users reseeds the default accounts, since an empty users table is a lockout, not a valid resting state). Purge Database handles whatever is its own database file, chosen from a dropdown. Purge Everything deletes every registered database outright. Every purge restarts the app afterward — deferred, not skipped, while Maintenance Mode is active.
  • Backup & Restore — manual and operator-triggered. Download a full system backup (every non-database file plus a consistency-safe SQLite backup of every database) or a single database. Restore a single database (validated against its expected table set before overwriting), a full system zip, or an arbitrary uploaded file to any filename in the data directory — the last with no validation at all, a deliberate low-safety escape hatch. Every restore restarts the app.
  • Maintenance Mode — blocks user-facing writes app-wide while active without blocking Maintenance's own tools, deferring their restarts until it is switched off. A banner appears on /maintenance the instant it is enabled, and a thin banner on every user-facing page while it is active.

Status (/status). Read-only operational facts, computed fresh on every load: system ID, app version, uptime, primary LAN IP, whether mDNS advertisement started successfully, disk free/used/total, every database in the registry with its path and size, and the debug-label directory. Nothing here is editable.

mDNS self-advertisement. The app is reachable on the LAN as boxlegend.local and browsable via any Bonjour/mDNS tool as Box Legend (<system_id>), registered under both _http._tcp and _boxlegend._tcp. Implemented with zeroconf — pure Python, no avahi/D-Bus dependency. Requires host networking, since multicast does not traverse bridge/NAT networking. When another unit already holds the name, the next free boxlegend-2.local, boxlegend-3.local and so on is claimed instead; Status reports which name this unit holds.

Licenses (/licenses, footer of every page). Third-party dependency table with each dependency's actual licence file included verbatim in a collapsible box — several of these licences require the text itself ship readable with the software, not merely be named.

Receiving

The original label-printing app: scan items into a box, fill the header, print one label.

Scan and print. Per-item quantity is a numeric field (Enter confirms) plus +/− and remove. Copies, beside Print, prints the assembled box N times (capped at 20) in one action and retains the count across prints; a failed copy stops the run, reports how many succeeded, and leaves the box intact to retry.

The header is entirely config-driven. It is collapsible — expanded by default, auto-collapsing once the first item is scanned, forced back open if Print is attempted with a required field empty. Visibility never affects required-field enforcement. A required field left empty is outlined in red with a "Required" placeholder. A separate banner warns when no printer is configured at all ("Data will be stored but will not be printed"), so a box can still be scanned and logged without one.

Print renders the header plus the contents list sorted alphanumerically by item name. Long header values and item names wrap onto continuation lines rather than running off the label edge, independently toggleable for the header block and the item list. Item wrapping keeps a fixed-width quantity column so it stays a clean vertical rule; header continuations indent by an offset that scales with the field font size.

Box log. Every successful print appends contents and header values to box_log.db, keyed by one shared box_id per box regardless of how many copies printed. The box_id also prints at the bottom of the label, and as a scannable QR code. It is minted fresh on every Print click but stored only once the print succeeds — a failed print discards it rather than reusing it.

Box Maintenance (/boxes/manage). One substring search box (Box ID, Project Number, PO) filters client-side. Every logged box renders as a card — Box ID beside an aligned Project Number / Purchase Order / Project Name / staleness line, actions right-aligned below — with the whole card flagged red if a required-for-export field is blank, and a staleness flag for boxes whose items may be missing a local part ID. Per box:

  • Reprint — same box_id, current catalog names, no new log entry.
  • Edit details — corrects header values via a modal, appending a new box_details row; latest wins.
  • Edit contents — add, adjust or remove items, backed by box_log delta rows. Driving an item's net quantity below zero is rejected, not clamped.
  • Void — behind a confirm step. Hides the box from this list and from Counts; never deletes its history.

Counts (/counts/manage). A read-only rollup of every non-voided box's contents, nested Project Number → PO → part, grouped by local part ID where set and falling back to the UPC itself where not — never lumped into one opaque bucket. Rendered as one continuous table so quantity columns stay aligned across every PO and project. A project/PO filter narrows the view. Each visible project gets a detail panel listing the configured header fields as captured on that project's most recent box. A box missing Project Number or PO groups under a visible "(missing)" heading rather than being dropped.

Reports (/reports/manage). On-demand CSV/PDF export of Counts data, filtered by a Project/PO substring, a Project Manager/Lead substring with autocomplete drawn from previously-seen values, an include archived boxes toggle (on by default — archive and live data collate into one rollup, archive header fields winning when newer), and a date/time range against the scan-log timestamp. A matching part's quantity aggregates across the whole range in one line, never fragmented by day.

CSV is a single flat table: Project, PO, UPC, Part Name, Local Part ID, Quantity, Project Manager, plus one column per header field actually captured in the filtered data — not the live config, so a since-changed field does not vanish from historical exports. PDF mirrors Counts' grouping, extended with the UPC and Part Name columns. Both echo the filtered range on the artifact itself: UTC in the CSV, the configured display timezone in the PDF.

Snapshot

Inventory counting. Items scan into one or more named reports, each independently open, closed, reopened or archived.

Every scan, +/−, manual edit and void posts a signed delta to an append-only events log, never an absolute set, so concurrent scanners and the live master-display view cannot race each other into a wrong total.

Scan works like Receiving's, sharing the same catalog and naming modal, but has no print step. A per-device Scan History list and the per-line displayed quantities are browser-ephemeral and reset on reload — the events already landed server-side, so only the local display is lost.

Reports Management creates reports and drives the lifecycle. open → closed, closed → open, and closed → archived (one-way, confirmed) are the only three legal transitions, enforced server-side regardless of what the UI shows.

Live Counts is the live report view: a report picker (select-and-confirm, auto-selecting only when exactly one report is open), a polling aggregate table with an on-page refresh-interval control, and a signed bulk-correction control per line.

Closed and archived reports re-export as CSV and PDF from the same derived aggregate, so the two cannot drift apart. None of Snapshot's report or event actions are admin-gated; only its own Report Config page is, which mirrors Receiving's in shape but is scoped to its own settings.

Checkout

Pickup and return receipts. Project managers scan items leaving or returning to the warehouse and generate a Letter-size PDF. Deliberately standalone — no lifecycle, and the only element shared with the other modes is the catalog's UPC → name lookup.

A record captures a UUID minted per report, a direction (Check Out, the default, or Check In), the mode-aware pinned header — Project Number (permanently required), Project Name, Project Manager, Project Lead and Checkout's exclusive Shelf — whatever freeform fields are configured, and a flat list of (upc, quantity) rows.

Scan has full parity with Receiving's: a collapsible header fieldset with a compact summary and Clear button, Active Projects autocomplete, and an export-warning banner. "Generate PDF" replaces Print — one POST both writes the record and returns the PDF. No printer is involved anywhere in this mode. Names resolve fresh from the catalog at PDF-build time, never stored.

Import Box pulls a previously-logged Receiving box's contents into the current sheet by scanning its QR code, live or archived, rather than re-scanning by hand. Contents are matched or rejected on Project Number against the current sheet, and quantities are adjustable or zeroable before merging.

The generated PDF carries a large centred "Check-In Sheet" / "Check-Out Sheet" title, header fields in a two-column grid (a blank Project Lead still reserves its position so Shelf's row never shifts; freeform fields reflow around each other when omitted), the item table, a PC Initials box, a signature box (Technician Name / Signature / Date), and a per-page footer with the report UUID, page number and configured-timezone timestamp. Generated and reprinted PDFs share one filename convention.

Records is a read-only, filterable history with a Reprint link per row that rebuilds the identical PDF from stored data on demand. Records are immutable — no edit, and voiding hides a record from display and rollups without blocking anything. An inactive record archives into the Checkout Archive database.

Environment setup

Two machines with deliberately different requirements: the dev/build workstation where the code is edited, tested and imaged, and the container host where the image actually runs. The deployment target is a Debian 13 (trixie) netinst -- no desktop, and notably no CUPS.

Dev/build workstation

Debian 13; everything below is stock trixie archive:

sudo apt install -y git tea-cli podman podman-compose \
                    python3-venv python3-pip sqlite3 jq
  • python3-venv and python3-pip are required and easy to miss. Trixie's python3 ships without ensurepip, so python3 -m venv .venv fails outright without them, and PEP 668 (EXTERNALLY-MANAGED) rules out installing into the system interpreter. Absent these, the test suite cannot run natively at all.
  • podman and podman-compose are the deployment target. docker-compose.yml and Dockerfile are ordinary OCI artefacts and need no changes; rootless podman build and podman-compose up consume them as-is.
  • sqlite3 opens the seven registered databases (see app/databases.py) by hand; jq for the JSON endpoints.
  • tea-cli is the Forgejo/Gitea CLI, upstream being Forgejo. Debian installs its binary as tea-cli, not tea -- the plain tea package is an unrelated Qt text editor that owns that name. Upstream Forgejo documentation all says tea; on Debian it is tea-cli.

Optional, but earns its place in this repo:

sudo apt install -y shellcheck ripgrep skopeo

shellcheck covers scripts/run-cups-tests.sh and the four s6 run scripts under rootfs/; skopeo inspects image manifests without pulling them.

Browser tests. pytest-playwright comes from requirements-dev.txt with everything else, but the browser itself does not. Chromium and its system libraries are two further one-time installs:

sudo ./.venv/bin/playwright install-deps chromium
./.venv/bin/playwright install chromium

The first needs root and pulls Debian packages; the second downloads ~115 MB into ~/.cache/ms-playwright as your own user. Browser tests drive a running instance rather than importing the page scripts, so the stack has to be up — and rebuilt after any change under app/, since application code is baked into the image. See dev/tests.md.

A workstation that is a full desktop install also needs the CUPS step below -- a netinst does not.

Container host (Debian 13 netinst)

sudo apt install -y podman podman-compose

Rootless throughout: the service account needs subuid/subgid ranges, and lingering enabled if the stack must come up without a login. The two host-level settings this application itself depends on follow.

net.ipv4.ip_unprivileged_port_start

The app binds port 80 directly rather than through a ports: mapping (network_mode: host, see Running it below), and a rootless container cannot bind below 1024 by default:

echo 'net.ipv4.ip_unprivileged_port_start=80' | sudo tee /etc/sysctl.d/80-boxlegend.conf
sudo sysctl --system

Pinning the floor to 80 unprivileges exactly the port in use. Setting it to 0 unprivileges every port for every rootless process on the box -- simpler, and defensible on a single-purpose host, but broader than this needs. See the provisioning spec's Port binding section for the tradeoff.

No CUPS on the host

The container runs its own cupsd, and network_mode: host means it shares the host's network namespace: one network stack, one port 631. A netinst has no CUPS, so there is nothing to do. On a full desktop install the host's cupsd claims 631 first at boot, and the container's then logs:

E Unable to open listen socket for address 127.0.0.1:631 - Address already in use.

The application keeps working regardless -- app/printing.py reaches the container's cupsd over /run/cups/cups.sock, which lives in the container's own mount namespace and is unaffected -- but the container's IPP/TCP listener is dead, and anything resolving localhost:631 reaches the host's daemon instead of the container's. Disable all four units; the socket and path units re-activate cupsd on demand if left enabled:

sudo systemctl disable --now cups.service cups.socket cups.path cups-browsed.service

Running it

Container

podman-compose up --build

Then point a browser at http://<host>/ (port 80), or at http://boxlegend.local/. Databases persist in ./data, bind-mounted. PRINTER_IP in docker-compose.yml is empty by default, which runs the app in dry-run mode; each site sets its own printer through Printer & Media rather than baking an address into the compose file.

The container runs three processes under s6-overlay supervision: cupsd, the Flask app under waitress, and supercronic for scheduled jobs. waitress starts only once a cups-ready check confirms cupsd is up. CUPS queue state lives in /etc/cups inside the container and is deliberately not a mounted volume — it is fully re-derivable from the config table on every save, so nothing is lost by not persisting it.

docker-compose.yml uses network_mode: host rather than a ports: mapping, so mDNS self-advertisement and printer discovery work at all; multicast does not reliably traverse bridge/NAT networking, rootless or not. Port 80 is therefore the host's own port 80. Binding it from a rootless container requires the net.ipv4.ip_unprivileged_port_start sysctl covered under Environment setup above.

The image is an ordinary OCI build, so any compatible runtime will run it provided the port and host-networking requirements are respected. Podman is the deployment target and the only configuration verified.

Local dev

python3 -m venv .venv
./.venv/bin/pip install -r requirements-dev.txt
CATALOG_DB_PATH=./data/catalog.db DEBUG_LABEL_DIR=./debug_labels \
  ./.venv/bin/waitress-serve --host=0.0.0.0 --port=8080 --call app:create_app

Local dev uses an unprivileged port, since it does not need the sysctl and is not emulating the real deployment's port 80.

With PRINTER_IP unset the app runs in dry-run mode: /print still renders the label but writes the PNG to DEBUG_LABEL_DIR instead of sending it. This is the only mode that works outside a container — there is no cupsd/lp/lpadmin on a bare dev machine, so saving a printer config or attempting a real print will fail there.

Testing is covered in dev/tests.md.

Configuration

Every variable the app reads, in one place. The first three are infra-only (never DB-overridable, never shown on any settings page). The rest sit under the three-tier ladder -- DB override (via the relevant settings page) beats this env var beats the hardcoded default -- and an empty value at any tier, including a blank field saved from a settings page, is treated as "not set" and falls through.

Variable Default DB-overridable? Purpose
CATALOG_DB_PATH /data/catalog.db No SQLite file (catalog + config); mount a volume here
BOX_LOG_DB_PATH /data/box_log.db No SQLite file for the append-only box log; separate from the catalog, same mounted volume
SNAPSHOT_DB_PATH /data/snapshot.db No SQLite file for Snapshot mode's events/reports tables (WAL mode); separate from the catalog, same mounted volume
CHECKOUT_DB_PATH /data/checkout.db No SQLite file for Checkout mode's reports/items tables (WAL mode); separate from the catalog, same mounted volume
BOX_ARCHIVE_DB_PATH /data/box_archive.db No SQLite file for Box Tracking's archived boxes; separate from the catalog, same mounted volume
CHECKOUT_ARCHIVE_DB_PATH /data/checkout_archive.db No SQLite file for Checkout's archived reports; separate from the catalog, same mounted volume
EPHEMERA_DB_PATH /data/ephemera.db No SQLite file for rebuildable lookup data (Active Projects, PM/Lead list); separate from the catalog, same mounted volume
PRINT_DRY_RUN 0 No Force dry-run even if a printer IP is resolved
DEBUG_LABEL_DIR /data/debug_labels No Where dry-run PNGs are saved
SYSTEM_ID_PATH /data/system_id No Flat file holding the 8-char Maintenance system ID (generated on first startup if missing); survives every purge, including "Everything"
PRINTER_IP (empty -> test mode) Yes Printer's LAN address
PRINTER_MODEL QL-820NWB Yes A curated key from app/printing.py's CURATED_PRINTERS table, or custom
MEDIA_TYPE 62 Yes A curated media key (see CURATED_PRINTERS), or (in custom mode) a raw CUPS MediaType option value
CUSTOM_PPD (empty) Yes CUPS PPD path -- required when PRINTER_MODEL=custom
WIDTH_MM (empty) Yes Physical roll width in mm -- required when PRINTER_MODEL=custom; must match the loaded roll exactly
DPI 300 Yes Printer resolution; auto-detected from the printer and saved on every /config save that touches a printer field -- this default is only the floor before that first detection
DEVICE_URI (empty -> derived) Yes CUPS device URI; blank derives socket://<PRINTER_IP>:9100
MARGIN_MM [0, 0, 0, 0] Yes JSON [left, top, right, bottom] mm -- the fixed hardware margin the printer reserves and silently clips content within; auto-detected from the printer's own driver (its PPD's *DefaultImageableArea) and saved alongside DPI, for curated and custom printers alike -- this default is only the floor before that first detection, or if a printer's driver doesn't expose this data at all
FIELD_FONT_SIZE 60 Yes Header field font size, in px
ITEM_FONT_SIZE 56 Yes Contents list font size, in px
HEADER_FIELDS see below Yes JSON array of {screen, print, required} header field pairs, in print order
LINE_WRAP_MODE 3 Yes 0=off, 1=header fields only, 2=item list only, 3=both
PROJECT_NUMBER_MODE required Yes required / optional / hidden -- capture mode for the first-class Project Number field (Receiving and Checkout)
PROJECT_NUMBER_PRINT_LABEL Prj# Yes Print abbreviation for Project Number, used whenever its mode isn't hidden
PROJECT_NUMBER_PRINT_ON_LABEL true Yes Whether Project Number (when captured) also renders on the physical label/PDF
PROJECT_NAME_MODE optional Yes required / optional / hidden -- capture mode for Project Name (Receiving and Checkout)
PROJECT_NAME_PRINT_LABEL Prj Yes Print abbreviation for Project Name, used whenever its mode isn't hidden
PROJECT_NAME_PRINT_ON_LABEL true Yes Whether Project Name (when captured) also renders on the physical label/PDF
PROJECT_MANAGER_MODE optional Yes required / optional / hidden -- capture mode for Project Manager (Receiving and Checkout)
PROJECT_MANAGER_PRINT_LABEL PM Yes Print abbreviation for Project Manager, used whenever its mode isn't hidden
PROJECT_MANAGER_PRINT_ON_LABEL true Yes Whether Project Manager (when captured) also renders on the physical label/PDF
PROJECT_LEAD_MODE optional Yes required / optional / hidden -- capture mode for Project Lead (Receiving and Checkout)
PROJECT_LEAD_PRINT_LABEL Lead Yes Print abbreviation for Project Lead, used whenever its mode isn't hidden
PROJECT_LEAD_PRINT_ON_LABEL true Yes Whether Project Lead (when captured) also renders on the physical label/PDF
PO_MODE required Yes required / optional / hidden -- capture mode for the first-class PO field (Receiving only)
PO_PRINT_LABEL PO Yes Print abbreviation for PO, used only when PO_PRINT_ON_LABEL is also true
PO_PRINT_ON_LABEL false Yes Whether PO (when captured) also renders on the physical label -- it can be logged without being printed
SHELF_MODE optional Yes required / optional / hidden -- capture mode for Shelf (Checkout only, its own exclusive pinned field)
SHELF_PRINT_LABEL Shelf Yes Print abbreviation for Shelf, used whenever its mode isn't hidden
SHELF_PRINT_ON_LABEL true Yes Whether Shelf (when captured) also renders on the generated PDF
REPORT_REQUIRED_PROJECT_NUMBER true Yes Report Config page: is Project Number required for archival export? Independent of PROJECT_NUMBER_MODE
REPORT_REQUIRED_PROJECT_NAME false Yes Report Config page: is Project Name required for archival export? Independent of PROJECT_NAME_MODE
REPORT_REQUIRED_PROJECT_MANAGER false Yes Report Config page: is Project Manager required for archival export? Independent of PROJECT_MANAGER_MODE
REPORT_REQUIRED_PROJECT_LEAD false Yes Report Config page: is Project Lead required for archival export? Independent of PROJECT_LEAD_MODE
REPORT_REQUIRED_PO true Yes Report Config page: is PO required for archival export? Independent of PO_MODE
REPORT_REQUIRED_SHELF false Yes Report Config page: is Shelf required for archival export? Independent of SHELF_MODE
REPORT_REQUIRED_LOCAL_PART_ID true Yes Report Config page: is the catalog's local part ID required for archival export
ARCHIVE_INACTIVITY_DAYS 60 Yes Days of inactivity before a box/report becomes eligible for the weekly archival job; 0 disables archiving. Shared by both Box Tracking's and Checkout's archival jobs
SNAPSHOT_REPORT_REQUIRED_LOCAL_PART_ID true Yes Snapshot's own Report Config page: is the catalog's local part ID required for a report export? Separate key/page from Receiving's REPORT_REQUIRED_LOCAL_PART_ID above -- no job/PO concept in Snapshot
REQUIRE_LOGIN_RECEIVING false Yes Whether Receiving mode (outside the Maintenance pages) requires a login session
REQUIRE_LOGIN_SNAPSHOT false Yes Same, for Snapshot mode -- independent of Receiving's toggle
REQUIRE_LOGIN_CHECKOUT false Yes Same, for Checkout mode -- independent of the other two toggles
REQUIRE_LOGIN_STATUS false Yes Whether the Status page requires login -- independent of any mode's toggle; Status is reachable/gated on its own
OPERATOR_SELF_PASSWORD_CHANGE false Yes Whether operators may change their own password (admin can always change theirs regardless)
TIMEZONE UTC Yes Display-only IANA timezone name for on-screen and PDF-export timestamps -- stored data stays UTC; CSV exports stay raw UTC too. Global Settings page (nav dropdown, above Status)

HEADER_FIELDS's hardcoded default, exactly as app/config.py ships it -- note Project Number is not in this list; it's a separate, permanent first-class field (PROJECT_NUMBER_MODE etc., above), not a freeform entry:

[{"screen": "Project name", "print": "Prj", "required": false}, {"screen": "Project manager", "print": "PM", "required": false}]

Every variable, with example (non-default) values, as a docker-compose.yml environment: block:

name: box-legend

services:
  web:
    build: .
    network_mode: host
    volumes:
      - ./data:/data
    environment:
      # --- infra-only: deployment concerns, never DB-overridable ---
      CATALOG_DB_PATH: /data/catalog.db      # default shown -- rarely changed
      BOX_LOG_DB_PATH: /data/box_log.db      # default shown -- rarely changed
      SNAPSHOT_DB_PATH: /data/snapshot.db    # default shown -- rarely changed
      CHECKOUT_DB_PATH: /data/checkout.db    # default shown -- rarely changed
      BOX_ARCHIVE_DB_PATH: /data/box_archive.db           # default shown -- rarely changed
      CHECKOUT_ARCHIVE_DB_PATH: /data/checkout_archive.db # default shown -- rarely changed
      DEBUG_LABEL_DIR: /data/debug_labels    # default shown -- rarely changed
      PRINT_DRY_RUN: "0"                     # "1" forces dry-run even with a printer IP set

      # --- user-facing: env is just the fallback tier under /config/manage ---
      PRINTER_IP: "192.168.1.76"             # default: "" (empty -> test mode)
      PRINTER_MODEL: "QL-820NWB"             # default: QL-820NWB (or "custom" -- see CUSTOM_PPD/WIDTH_MM)
      MEDIA_TYPE: "62"                       # default: 62
      # CUSTOM_PPD: "some/vendor.ppd"        # only used when PRINTER_MODEL=custom
      # WIDTH_MM: "50"                       # only used when PRINTER_MODEL=custom
      # DEVICE_URI: "socket://10.0.0.9:9100" # default: "" -> derived from PRINTER_IP
      DPI: "300"                             # default: 300 (floor -- auto-detected and saved on next config save)
      MARGIN_MM: "[0, 0, 0, 0]"              # default: no compensation (floor -- auto-detected and saved on next config save)
      FIELD_FONT_SIZE: "60"                  # default: 60
      ITEM_FONT_SIZE: "56"                   # default: 56
      HEADER_FIELDS: '[{"screen": "Project name", "print": "Prj", "required": false}, {"screen": "Project manager", "print": "PM", "required": false}]'
      LINE_WRAP_MODE: "3"                    # default: 3 (0=off, 1=header only, 2=items only, 3=both)
      PROJECT_NUMBER_MODE: "required"        # default: required (required | optional | hidden)
      PROJECT_NUMBER_PRINT_LABEL: "Prj#"     # default: Prj#
      PO_MODE: "required"                    # default: required (required | optional | hidden)
      PO_PRINT_LABEL: "PO"                   # default: PO
      PO_PRINT_ON_LABEL: "false"             # default: false -- PO can be logged without being printed
      REPORT_REQUIRED_PROJECT_NUMBER: "true" # default: true
      REPORT_REQUIRED_PO: "true"             # default: true
      REPORT_REQUIRED_LOCAL_PART_ID: "true"  # default: true
      REQUIRE_LOGIN_RECEIVING: "false"       # default: false -- Receiving stays fully open
      REQUIRE_LOGIN_SNAPSHOT: "false"        # default: false -- independent of Receiving's toggle
      REQUIRE_LOGIN_CHECKOUT: "false"        # default: false -- independent of the other two toggles
      REQUIRE_LOGIN_STATUS: "false"          # default: false -- independent of any mode's toggles
      OPERATOR_SELF_PASSWORD_CHANGE: "false" # default: false -- admin's own change is unaffected either way
      TIMEZONE: "America/New_York"           # default: UTC -- display-only, storage stays UTC
      SHELF_MODE: "optional"                 # default: optional (required | optional | hidden) -- Checkout only
      ARCHIVE_INACTIVITY_DAYS: "60"          # default: 60 -- shared by Box Tracking's and Checkout's archival jobs; 0 disables archiving
    restart: unless-stopped

Saving a settings page's current values into the compose env var, then hitting that page's own Reset, is how a known-good DB override gets promoted to the deployment-wide baseline.