- Python 73.7%
- JavaScript 17.5%
- HTML 7%
- CSS 1.2%
- Shell 0.3%
- Other 0.3%
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> |
||
|---|---|---|
| app | ||
| dev | ||
| rootfs/etc | ||
| scripts | ||
| spec | ||
| tests | ||
| .dockerignore | ||
| .gitattributes | ||
| .gitignore | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| Dockerfile | ||
| README.md | ||
| requirements-dev.txt | ||
| requirements.txt | ||
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
- Architecture
- Features — Shared · Receiving · Snapshot · Checkout
- Environment setup
- Running it
- Configuration
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 bywaitress. 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.pyis 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/valueconfigtable. 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), andbox_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 viasubprocesscalls tolp/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 viaprinter-driver-ptouch) lives inapp/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 atapp/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
.xlsximport. 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
/maintenancethe 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_detailsrow; latest wins. - Edit contents — add, adjust or remove items, backed by
box_logdelta 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-venvandpython3-pipare required and easy to miss. Trixie'spython3ships withoutensurepip, sopython3 -m venv .venvfails 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.podmanandpodman-composeare the deployment target.docker-compose.ymlandDockerfileare ordinary OCI artefacts and need no changes; rootlesspodman buildandpodman-compose upconsume them as-is.sqlite3opens the seven registered databases (seeapp/databases.py) by hand;jqfor the JSON endpoints.tea-cliis the Forgejo/Gitea CLI, upstream being Forgejo. Debian installs its binary astea-cli, nottea-- the plainteapackage is an unrelated Qt text editor that owns that name. Upstream Forgejo documentation all saystea; on Debian it istea-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.