Multi-site distribution readiness: settings-driven site config, security closeout, release engineering, v0.5.0
Some checks failed
CI / backend (push) Failing after 2s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

Make the app distributable to other GE Aerospace sites (one self-hosted
instance per site, ADR-004). GE values remain the shipped defaults; every
site-specific behavior is now a Setting an admin can change in the UI.

Settings-driven site config:
- Branding: site/QR/badge logos, favicon, primary color (upload endpoints
  mirror the map-blueprint pattern; new Settings > Branding section).
- ServiceNow: search/incident/change URL templates ({ticket}), ticket
  prefixes, enable toggle. Defaults point at the current
  geaerospaceqa.service-now.com global search. Disabled = plain-text tickets.
- Employee-id regex (employeeid_pattern), printer hostname template,
  QR label targets (qr_target_printer / qr_target_usb, blank = asset page,
  else URL template with placeholders), usb_label_style (barcode|qr).
- West Jefferson floor-plan PNGs removed from the tree; generic placeholder
  ships as the map default and sites upload their own blueprint.

Security closeout:
- dashboarddefaults writes now require admin.
- Collector: generic error messages (no str(exc) leak); API key accepted
  via X-API-Key header only (BREAKING: querystring api_key removed).
- IP-based login rate limiting (AUTH_RATELIMIT_* knobs) atop account lockout.
- Setting.set() creation race fixed (IntegrityError retry).

Release engineering and docs:
- __version__ 0.5.0 (distinct from __contract_version__, ADR-007),
  CHANGELOG.md, Gitea Actions CI config, frontend version aligned.
- One wizard-first install story across README/DEPLOY; new CONFIG.md,
  UPGRADE.md, BACKUP-RESTORE.md; CLAUDE.md and ROADMAP de-staled.
- Dockerfile multi-stage build now bundles the frontend; compose binds
  MySQL to 127.0.0.1; stale database/schema.sql and one-off SQL removed.

Debt and fixes:
- .query.get() -> db.session.get() sweep; datetime.utcnow() removed
  (naive-UTC via timezone-aware now); users.py on authz decorators.
- Fixed 4 stale tests (slides feed shape, shopfloor splitperemployee,
  plugin contract purity) and the USB label page field mapping (both usb
  modes emit the cmmc shape: device_id/device_desc).
- Health endpoint reports the real version.

248 tests pass; naming/style check green; frontend builds; fresh-DB
flask db upgrade + seeds verified; QR targets verified by decoding
rendered codes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-10 15:02:07 -04:00
parent bf9e60e607
commit b8c22244a1
96 changed files with 3818 additions and 1942 deletions

123
docs/BACKUP-RESTORE.md Normal file
View File

@@ -0,0 +1,123 @@
# Backup and Restore
Each site owns its own data (single-tenant, ADR-004), so backups are the site's
responsibility. A complete backup is two parts:
1. **The MySQL database** - all asset, user, audit, and settings data.
2. **The `instance/` directory** - uploaded floor plans, branding assets,
`plugins.json` (the enabled-plugin list), and any tokens or files the app
writes to disk. These are NOT in the database, so a DB-only backup loses
them. Back up `instance/` alongside every database dump.
Restoring the database without the matching `instance/` directory leaves the
app pointing at floor plans and logos that no longer exist.
## What to back up
| Item | Location | Why |
|------|----------|-----|
| Database | MySQL `shopdb_flask` | All application data. |
| `instance/branding/` | repo `instance/` dir | Uploaded logos and favicon. |
| `instance/` floor plans | repo `instance/` dir | Uploaded map blueprints. |
| `instance/plugins.json` | repo `instance/` dir | Which plugins this site enabled. |
| `.env` | repo root (offline, secured) | Secrets needed to bring the stack back up. Store separately from the data backup, in a secrets manager. |
## Backup
### Database (Docker)
```bash
docker compose exec -T db mysqldump \
-u root -p"${MYSQL_ROOT_PASSWORD}" \
--single-transaction --routines --triggers \
shopdb_flask | gzip > shopdb-$(date +%F).sql.gz
```
`--single-transaction` gives a consistent dump without locking the tables (InnoDB).
### Database (external MySQL, no container)
```bash
mysqldump -h <host> -u <user> -p \
--single-transaction --routines --triggers \
shopdb_flask | gzip > shopdb-$(date +%F).sql.gz
```
### instance directory
```bash
tar czf instance-$(date +%F).tar.gz instance/
```
Recommended cadence: nightly database dump to offsite storage, 14-day
retention; `instance/` captured on the same schedule (and always right before an
upgrade). Verify a restore quarterly.
## Restore
Restoring replaces the current database contents. Do it into a known-empty or a
throwaway target first if you are unsure.
### Step 1: Bring up the stack (or a fresh one)
```bash
cp .env.example .env # or restore your saved .env
# ensure MYSQL_* and DATABASE_URL match the dump's database name (shopdb_flask)
docker compose up -d db
```
Wait for the `db` container to report healthy (`docker compose ps`).
### Step 2: Load the database dump
```bash
gunzip -c shopdb-2026-07-10.sql.gz | \
docker compose exec -T db mysql -u root -p"${MYSQL_ROOT_PASSWORD}" shopdb_flask
```
For an external MySQL:
```bash
gunzip -c shopdb-2026-07-10.sql.gz | mysql -h <host> -u <user> -p shopdb_flask
```
If the target database does not exist yet, create it as utf8mb4 first (matching
the schema charset):
```sql
CREATE DATABASE shopdb_flask CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
### Step 3: Restore the instance directory
```bash
tar xzf instance-2026-07-10.tar.gz # restores ./instance/
```
The docker-compose api container reads `instance/` from the repo working
directory; make sure it is present before starting `api`.
### Step 4: Bring up the API and reconcile migrations
```bash
docker compose up -d api
docker compose exec api flask db upgrade
```
`flask db upgrade` is a safety net: if the dump predates the current code, this
applies any newer migrations. If the dump is at the same version it is a no-op.
### Step 5: Verify
- Log in with a known account.
- Confirm the floor map renders (branding and map blueprints resolve from
`instance/`).
- Spot-check a few asset records and the audit log.
- `curl -s -X POST -H "Content-Type: application/json" -d '{}' http://localhost:5001/api/auth/login | jq .`
should return a `VALIDATION_ERROR`, not a 500.
## See also
- [DEPLOY.md](DEPLOY.md) - first-time deploy
- [UPGRADE.md](UPGRADE.md) - upgrade procedure (back up first)
- [CONFIG.md](CONFIG.md) - environment variables and Setting keys

View File

@@ -11,6 +11,12 @@ Auth: API key header `X-API-Key: <key>`, resolved as `COLLECTOR_API_KEY_COMPUTER
then the shared `COLLECTOR_API_KEY` (ADR-006). Idempotent upsert keyed on
`hostname`.
> **Breaking change:** the API key must be sent in the `X-API-Key` header. The
> old `?api_key=<key>` querystring fallback has been removed, on every collector
> endpoint (`/api/collector/<plugin>`, `/pc`, `/apps`, `/heartbeat`, `/bulk`,
> `/status`). Querystring keys leak into access logs and proxy history. Update
> any caller still passing `api_key` in the URL to use the header instead.
## Payload (project naming convention: lowercase concatenated)
| Field | Meaning | Flask target |

242
docs/CONFIG.md Normal file
View File

@@ -0,0 +1,242 @@
# Configuration Reference
shopdb-flask reads configuration from two places, and the split is deliberate:
- **Environment variables** (`.env` / container env) hold **secrets and
deploy-time wiring**: database credentials, signing keys, CORS origins, ports,
API keys. These are read once at boot by `shopdb/config.py`. Never put a
secret in the Settings table.
- **The Settings table** (seeded by `flask seed settings`, edited in the UI
under Settings or the setup wizard) holds **site preferences**: branding,
ServiceNow links, floor-map images, search toggles, facility identity. These
can change at runtime without a restart and are per-instance.
Rule of thumb: if leaking it would be a security incident, it is an environment
variable. If it is a site preference an admin should be able to change in the
UI, it is a Setting.
---
## Part 1: Environment variables
Defined in `shopdb/config.py`. Copy `.env.example` to `.env` and fill in
values. In `production` (`FLASK_ENV=production`), `ProductionConfig.validate()`
refuses to boot if `SECRET_KEY`, `JWT_SECRET_KEY`, `DATABASE_URL`, or
`CORS_ORIGINS` are missing or set to the dev defaults.
### Flask core
| Variable | Required | Default | Notes |
|----------|----------|---------|-------|
| `FLASK_APP` | No | `wsgi.py` | Entry point for the `flask` CLI. |
| `FLASK_ENV` | Yes | `development` | `production` for live sites (triggers `validate()`). Other values: `development`, `testing`. |
| `SECRET_KEY` | Yes (prod) | dev default | Flask session/signing key. Generate: `python -c "import secrets; print(secrets.token_urlsafe(64))"`. |
| `JWT_SECRET_KEY` | Yes (prod) | dev default | JWT signing key. Different value from `SECRET_KEY`. |
| `JWT_ACCESS_TOKEN_EXPIRES` | No | `3600` | Access-token TTL in seconds. |
| `JWT_REFRESH_TOKEN_EXPIRES` | No | `2592000` | Refresh-token TTL in seconds (30 days). |
| `CORS_ORIGINS` | Yes (prod) | `http://localhost:5173` | Comma-separated explicit origins. Wildcard `*` is rejected in production. |
| `LOG_LEVEL` | No | `INFO` | Logging verbosity. |
### Database
| Variable | Required | Default | Notes |
|----------|----------|---------|-------|
| `DATABASE_URL` | Yes (prod) | dev localhost URL | `mysql+pymysql://<user>:<pass>@<host>:<port>/<db>?charset=utf8mb4`. Keep `?charset=utf8mb4`. |
### Authentication rate limiting
IP-based fixed-window limit on the login endpoint, defense-in-depth atop the
per-account lockout. Uses the existing cache extension (per-process, so the
limit is approximate across multiple gunicorn workers).
| Variable | Required | Default | Notes |
|----------|----------|---------|-------|
| `AUTH_RATELIMIT_ENABLED` | No | `True` | Set `False` to disable (TestingConfig disables it). |
| `AUTH_RATELIMIT_MAX` | No | `30` | Max login attempts per source IP per window before 429. |
| `AUTH_RATELIMIT_WINDOW_SECONDS` | No | `300` | Window length in seconds. |
### Collector ingest (ADR-006)
| Variable | Required | Default | Notes |
|----------|----------|---------|-------|
| `COLLECTOR_API_KEY` | No | (empty) | Shared key for `/api/collector/*`. Endpoint fails closed (denies) when unset. Sent as the `X-API-Key` header. |
| `COLLECTOR_API_KEY_<PLUGIN>` | No | (empty) | Per-plugin override, e.g. `COLLECTOR_API_KEY_COMPUTERS`. Checked before the shared key. |
### Zabbix (printer supply monitoring)
| Variable | Required | Default | Notes |
|----------|----------|---------|-------|
| `ZABBIX_ENABLED` | No | `false` | Enable the Zabbix integration. |
| `ZABBIX_URL` | No | (empty) | Zabbix API URL. |
| `ZABBIX_TOKEN` | No | (empty) | Zabbix API bearer token. |
Note: Zabbix can also be configured via the Settings table (`zabbix_enabled`,
`zabbix_url`, `zabbix_token`). The environment values are the boot-time wiring;
prefer the Settings entries for runtime changes.
### Employee directory database (optional, read-only)
Separate HR/employee lookup DB consumed by the notifications plugin and the
public kiosks. There is no safe default for the password; an unset password
fails loud rather than trying a guessed credential.
| Variable | Required | Default | Notes |
|----------|----------|---------|-------|
| `EMPLOYEE_DB_HOST` | No | `localhost` | HR DB host. |
| `EMPLOYEE_DB_USER` | No | (empty) | HR DB user. |
| `EMPLOYEE_DB_PASSWORD` | No | (empty) | HR DB password. No safe default. |
| `EMPLOYEE_DB_NAME` | No | `wjf_employees` | HR DB name. |
Only used when `employee_directory_mode` (Setting) is `external`.
### CMMC USB database (optional, read-write)
Separate MySQL DB used by the USB plugin for check-in/out, lockers, and the log.
| Variable | Required | Default | Notes |
|----------|----------|---------|-------|
| `CMMC_USB_DB_HOST` | No | `localhost` | USB DB host. |
| `CMMC_USB_DB_USER` | No | (empty) | USB DB user. |
| `CMMC_USB_DB_PASSWORD` | No | (empty) | USB DB password. No safe default. |
| `CMMC_USB_DB_NAME` | No | `cmmc_usb` | USB DB name. |
Only used when `usb_directory_mode` (Setting) is `external`.
### docker-compose only
Read by `docker-compose.yml`, not by the Flask app directly.
| Variable | Required | Default | Notes |
|----------|----------|---------|-------|
| `MYSQL_ROOT_PASSWORD` | Yes | (none) | Root password for the bundled MySQL container. |
| `MYSQL_PASSWORD` | Yes | (none) | App-user password; must match the `DATABASE_URL` password. |
| `MYSQL_PORT` | No | `3306` | Host port for MySQL. Bound to `127.0.0.1` only. |
| `API_PORT` | No | `5001` | Host port for the API container. |
---
## Part 2: Settings table keys
Seeded by `flask seed settings` (idempotent; re-running adds anything missing).
Edited in the UI under Settings, or captured in the first-run setup wizard.
Values are stored as strings and typed by `valuetype`. Secrets in this table
(anything whose key contains `password`, `token`, or `secret`) are masked when
read back through the API.
### site
| Key | Default | Notes |
|-----|---------|-------|
| `setup_complete` | `false` | Set true once the first-run wizard finishes; gates the `/setup` route. |
| `employee_directory_mode` | `selfhosted` | `selfhosted` (tables in this app) or `external` (a separate HR database, see `EMPLOYEE_DB_*`). |
| `usb_directory_mode` | `selfhosted` | `selfhosted` or `external` (a separate `cmmc_usb` database, see `CMMC_USB_DB_*`). |
| `site_base_url` | (empty) | Public base URL (scheme + host) for QR codes and absolute links. Blank = use the browsing origin. |
| `facility_name` | (empty) | Facility name in the dashboard header. Blank = frontend falls back to `ShopDB`. |
| `pc_access_domain` | `device.geaerospace.net` | Domain appended to a PC hostname for remote-access links. Blank = hostname as-is. |
| `employeeid_pattern` | `^\d{9}$` | Regex a search term must match to be treated as an employee id. Invalid regex falls back to the default and never 500s. |
| `printer_hostname_template` | `Printer-{ip}.printer.geaerospace.net` | Printer hostname template. `{ip}` is the dash-separated IP address. |
### branding
Blank values fall back to the shipped GE default asset so an un-reconfigured
install still renders. Upload replacements at Settings > Branding, which saves
them under `instance/branding/`.
| Key | Default | Notes |
|-----|---------|-------|
| `site_logo` | `/ge-aerospace-logo.svg` | Header and login-page logo. |
| `qr_logo` | `/ge-monogram.svg` | Logo composited in printer QR labels. Blank = no overlay. |
| `badge_logo` | `/ge-aerospace-logo.svg` | Logo on the equipment badge print page. |
| `site_favicon` | (empty) | Browser tab favicon. Blank = shipped `/favicon.svg`. |
| `brand_primary_color` | (empty) | Primary brand color as a CSS color value. Blank = built-in theme color. |
### printing
| Key | Default | Notes |
|-----|---------|-------|
| `qr_target_printer` | (empty) | Custom URL template for printer QR labels. Blank = link to the printer page on this instance. Placeholders: `{printerid}`, `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{ip}`, `{hostname}`. |
| `qr_target_usb` | (empty) | Custom URL template for USB label QR codes. Blank = link to the USB device page. Placeholders: `{id}`, `{serialnumber}`, `{alias}`. |
| `usb_label_style` | `barcode` | USB mini-label code style: `barcode` (CODE128 of the serial) or `qr` (QR code linking to the USB QR target). |
### map
| Key | Default | Notes |
|-----|---------|-------|
| `map_blueprint_light` | `/static/images/floorplan-placeholder.svg` | Floor-map blueprint (light theme). Re-upload your own in Settings > Map. |
| `map_blueprint_dark` | `/static/images/floorplan-placeholder.svg` | Floor-map blueprint (dark theme). |
| `map_width` | `3300` | Blueprint native width in pixels. |
| `map_height` | `2550` | Blueprint native height in pixels. |
### integrations
| Key | Default | Notes |
|-----|---------|-------|
| `servicenow_enabled` | `true` | Enable ServiceNow ticket recognition and links. Disabled = tickets render as plain text. |
| `servicenow_search_url` | geaerospaceqa.service-now.com global-search template | `{ticket}` is substituted. |
| `servicenow_ticket_prefixes` | `GEINC,GECHG,GERIT,GESCT` | Comma-separated prefixes recognized as ServiceNow tickets. |
| `servicenow_incident_url` | geaerospaceqa.service-now.com global-search template | `{ticket}` is substituted. Replace with a direct incident URL if your instance has one. |
| `servicenow_change_url` | geaerospaceqa.service-now.com global-search template | `{ticket}` is substituted. Replace with a direct change URL if your instance has one. |
| `zabbix_enabled` | `false` | Enable Zabbix for printer supply monitoring. |
| `zabbix_url` | (empty) | Zabbix API URL. |
| `zabbix_token` | (empty) | Zabbix API token (masked). |
| `warranty_dell_enabled` | `false` | Enable Dell warranty (service-tag) lookups. |
| `warranty_dell_clientid` | (empty) | Dell TechDirect API client id. |
| `warranty_dell_clientsecret` | (empty) | Dell TechDirect API client secret (masked). |
| `warranty_dell_tokenurl` | (empty) | Dell OAuth token URL. Blank = Dell default. |
| `warranty_dell_apiurl` | (empty) | Dell warranty API URL. Blank = Dell default. |
### email
| Key | Default | Notes |
|-----|---------|-------|
| `smtp_enabled` | `false` | Enable email notifications and alerts. |
| `smtp_host` | (empty) | SMTP server hostname. |
| `smtp_port` | `587` | SMTP port (587 TLS, 465 SSL, 25 plain). |
| `smtp_username` | (empty) | SMTP auth username. |
| `smtp_password` | (empty) | SMTP auth password (masked). |
| `smtp_use_tls` | `true` | Use TLS for the SMTP connection. |
| `smtp_from_address` | (empty) | From address for outgoing email. |
| `smtp_from_name` | `ShopDB` | From name for outgoing email. |
| `alert_recipients` | (empty) | Default alert recipients (comma-separated). |
### audit
| Key | Default | Notes |
|-----|---------|-------|
| `audit_retention_days` | `90` | Days to retain audit logs (0 = keep forever). |
### auth
| Key | Default | Notes |
|-----|---------|-------|
| `saml_enabled` | `false` | Enable SAML SSO. |
| `saml_idp_metadata_url` | (empty) | SAML IdP metadata URL. |
| `saml_entity_id` | (empty) | SAML SP entity id. |
| `saml_acs_url` | (empty) | SAML Assertion Consumer Service URL. |
| `saml_allow_local_login` | `true` | Allow local username/password login when SAML is on. |
| `saml_auto_create_users` | `true` | Auto-create users on first SAML login. |
| `saml_admin_group` | (empty) | SAML group name that grants the admin role. |
### identifiers (dynamic)
One boolean key per asset identifier per asset type, keyed
`identifier_<name>_<assettype>_enabled` (default `true`). Admins choose which
optional identifiers show on which asset types. See ADR-001. The exact set is
generated from `IDENTIFIER_LABELS` x `IDENTIFIER_ASSETTYPES` in
`shopdb/core/api/settings.py`.
### search (dynamic)
One boolean key per search domain, keyed `search_<type>_enabled` (default
`true`). Toggles whether a domain appears in global search results. The set is
generated from `SEARCH_DOMAINS` in `shopdb/core/api/settings.py`.
---
## See also
- [DEPLOY.md](DEPLOY.md) - per-site deployment runbook
- [UPGRADE.md](UPGRADE.md) - upgrading an existing site
- [BACKUP-RESTORE.md](BACKUP-RESTORE.md) - backup and restore
- `shopdb/config.py` - authoritative env-var definitions
- `shopdb/core/api/settings.py` (`build_default_settings`) - authoritative Setting defaults

View File

@@ -43,6 +43,13 @@ docker compose build
docker compose up -d
```
The Docker image builds the Vue frontend in a first stage and copies the
compiled SPA into the API image, so `docker compose build` produces a
self-contained image with the UI already built. No separate Node step is
needed for a container deploy. (For a bare-metal/venv install instead, build
the frontend by hand: `cd frontend && npm ci && npm run build`, which writes
`frontend/dist/` for Flask to serve.)
The MySQL container initializes its volume on first run. The API container waits for `db` to be healthy via `healthcheck`. Check logs:
```bash
@@ -67,17 +74,30 @@ CREATE DATABASE shopdb_flask CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
`DATABASE_URL` must keep `?charset=utf8mb4` so the connection matches. On MySQL older than 5.7 also enable `innodb_large_prefix=ON` + `innodb_file_format=Barracuda`, or the utf8mb4 indexes exceed the 767-byte prefix limit (error 1071). MySQL 5.7+ and 8.0 need no extra config.
## Step 4: Seed reference data
## Step 4: Seed permissions, settings, and reference data
Run all three seeders. They are idempotent, so re-running them on an existing
database is safe (it adds anything missing and leaves existing rows alone).
```bash
docker compose exec api flask seed permissions
docker compose exec api flask seed settings
docker compose exec api flask seed reference-data
```
Creates: default `Vendor`, `Location`, `BusinessUnit`, `OperatingSystem`, `AssetStatus`, `RelationshipType` rows seeded with the platform contract values (`partof`, `controls`, `connectedto`).
- `seed permissions` - creates the RBAC permission rows and the default roles
the app checks with `@require_permission`. Run this before anyone logs in or
permission checks have nothing to match.
- `seed settings` - writes the default Setting rows (branding, ServiceNow
integration, floor-map placeholders, search toggles, site identity). A site
overrides these later in Settings or the setup wizard.
- `seed reference-data` - creates default `Vendor`, `Location`, `BusinessUnit`,
`OperatingSystem`, `AssetStatus`, `RelationshipType` rows seeded with the
platform contract values (`partof`, `controls`, `connectedto`).
## Step 5: Pick plugins to enable
The image bundles all six plugins (computers, equipment, network, notifications, printers, usb). Only enabled plugins are loaded.
The image bundles ten plugins (computers, employees, equipment, knowledgebase, network, notifications, printers, slides, usb, warranty). Only enabled plugins are loaded.
```bash
docker compose exec api flask plugin list
@@ -88,7 +108,16 @@ docker compose exec api flask plugin install equipment
To install a sister-site or third-party plugin (per ADR-003), drop its directory into `<repo>/plugins/<name>/` (the docker-compose mounts this read-only into the container) and run `flask plugin install <name>`.
## Step 6: Create the admin user
## Step 6: Create the first admin (setup wizard)
The primary path is the first-run setup wizard. Once the stack is up and the DB
is seeded, browse to the site (through the reverse proxy configured in Step 7,
or directly at the API port during bring-up) and go to `/setup`. The wizard
creates the first admin account and captures site identity (facility name,
optional logo). It runs only while `setup_complete` is false; after it finishes
the route redirects to the app.
Headless alternative (no browser, e.g. automated provisioning):
```bash
docker compose exec api flask seed admin --username admin --email admin@facility.example.com
@@ -129,7 +158,10 @@ Per-site MySQL backups are the site's responsibility. Recommended: nightly `mysq
docker compose exec -T db mysqldump -u root -p"${MYSQL_ROOT_PASSWORD}" shopdb_flask | gzip > backup-$(date +%F).sql.gz
```
Verify a restore quarterly.
Verify a restore quarterly. Back up the `instance/` directory alongside the DB;
it holds uploaded floor plans, branding, `plugins.json`, and tokens that are not
in MySQL. See [docs/BACKUP-RESTORE.md](BACKUP-RESTORE.md) for the full backup and
restore procedure.
## Step 9: Updates
@@ -140,7 +172,7 @@ docker compose up -d api
docker compose exec api flask db upgrade
```
The framework's `__contract_version__` may have moved. Check `docs/adr/` for any new ADRs since the last update. If an ADR introduces a breaking change, the upgrade may require coordinated work; the ADR's "Consequences" section documents it.
The framework's `__contract_version__` may have moved. Check `docs/adr/` for any new ADRs since the last update. If an ADR introduces a breaking change, the upgrade may require coordinated work; the ADR's "Consequences" section documents it. See [docs/UPGRADE.md](UPGRADE.md) for the full upgrade procedure, including re-seeding and the v0.5+ floor-plan note.
## Common issues
@@ -169,4 +201,7 @@ If this returns a 500 or no JSON, the container is unhealthy. Check `docker comp
- [docs/adr/ADR-003-plugin-distribution.md](adr/ADR-003-plugin-distribution.md) - bundled vs external plugins
- [docs/adr/ADR-006-collector-contract.md](adr/ADR-006-collector-contract.md) - per-plugin collector endpoints
- [docs/PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) - building a custom plugin for your site
- [docs/CONFIG.md](CONFIG.md) - every environment variable and every Setting key
- [docs/UPGRADE.md](UPGRADE.md) - upgrade procedure for an existing site
- [docs/BACKUP-RESTORE.md](BACKUP-RESTORE.md) - backup and restore procedure
- [shopdb/config.py](../shopdb/config.py) - all the env-vars in one place

194
docs/INSTALL-WINDOWS-IIS.md Normal file
View File

@@ -0,0 +1,194 @@
# ShopDB - Windows + IIS install runbook
A step-by-step, **tested** install for a new site on Windows Server / Windows 11
with IIS in front of the Flask app (HttpPlatformHandler -> waitress), backed by
MySQL. This runbook was validated end to end on a win11 + IIS + MySQL 5.6 box.
`APP_ROOT` below = the deploy folder, e.g. `C:\shopdb-flask` (where `wsgi.py`
lives). Run PowerShell as Administrator.
---
## 0. Prerequisites
| Need | Notes |
| --- | --- |
| **Python 3.12** (64-bit) | `python --version` |
| **IIS** with **HttpPlatformHandler** | https://www.iis.net/downloads/microsoft/httpplatformhandler (direct MSI: `download.microsoft.com/download/8/1/3/813AC4E6-9203-4F7A-8DD5-F3D54D10C5CD/httpPlatformHandler_amd64.msi`) |
| **MySQL 5.7+/8.0** (or 5.6 with the flags in step 1) | reachable from the app host |
| URL Rewrite (optional) | only for the real-client-IP rule; skip it and the app still runs |
The app itself pulls in `waitress` and `tzdata` from `requirements.txt` (step 4).
---
## 1. MySQL: flags (5.6 only) + database + user
On **MySQL 5.6 only**, add to `my.ini`/`my.cnf` under `[mysqld]` and restart MySQL
(5.7+/8.0 need none of this):
```
innodb_file_per_table = 1
innodb_file_format = Barracuda
innodb_large_prefix = 1
```
Without them, `flask db upgrade` fails with **error 1071** ("key too long") - the
migrations use `ROW_FORMAT=DYNAMIC`, which needs the 3072-byte prefix these unlock.
Then create the database (utf8mb4) and an app user:
```sql
CREATE DATABASE shopdb_flask CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'shopdb'@'%' IDENTIFIED BY 'CHANGE_ME';
GRANT ALL PRIVILEGES ON shopdb_flask.* TO 'shopdb'@'%';
FLUSH PRIVILEGES;
```
---
## 2. Deploy the app files
Copy the release (the repo minus `venv/`, `.git/`, `node_modules/`,
`frontend/src/`) to `APP_ROOT`. It must contain `wsgi.py`, `shopdb/`, `plugins/`,
`migrations/`, `requirements.txt`, and the pre-built `frontend/dist/`.
---
## 3. Virtual env + dependencies
```powershell
cd APP_ROOT
python -m venv venv
venv\Scripts\python -m pip install -r requirements.txt
```
This installs Flask, SQLAlchemy, PyMySQL, **waitress** (the WSGI server IIS
launches) and **tzdata** (Windows has no IANA tz database; without it the
notifications plugin fails with "No time zone found with key America/New_York").
---
## 4. Secrets + connection (.env)
Create `APP_ROOT\.env` (read by `wsgi.py` via `load_dotenv()`). Lock its ACLs to
the app-pool identity + admins.
```
FLASK_ENV=production
SECRET_KEY=<64+ random chars>
JWT_SECRET_KEY=<another 64+ random chars>
DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@<mysql-host>:3306/shopdb_flask?charset=utf8mb4
CORS_ORIGINS=http://<the site's own hostname-or-ip:port>
```
Generate a key: `venv\Scripts\python -c "import secrets;print(secrets.token_urlsafe(64))"`.
Production **refuses to boot** if any of `SECRET_KEY`, `JWT_SECRET_KEY`,
`DATABASE_URL`, `CORS_ORIGINS` is missing or a dev default.
---
## 5. Preflight (catch problems before installing)
```powershell
$env:FLASK_APP="shopdb"
venv\Scripts\flask db-utils preflight
```
Checks Python, required env, DB connectivity, and the MySQL 5.6 index flags, and
prints exactly what to fix. Fix any **FAIL** before continuing.
---
## 6. Schema + data + plugins + admin
```powershell
$env:FLASK_APP="shopdb"
venv\Scripts\flask db upgrade # creates every table (to head)
venv\Scripts\flask seed reference-data # statuses, machine/location/rel types
venv\Scripts\flask seed permissions
venv\Scripts\flask seed settings
# enable the plugins this site tracks (registry is empty on a fresh box).
# usb + employees install DISABLED by default - enable them later in the wizard
# if the site wants those (they create extra tables).
foreach ($p in "computers","equipment","network","notifications","printers","knowledgebase","slides","warranty") {
venv\Scripts\flask plugin install $p
}
# first admin (password generated + printed once - store it):
venv\Scripts\flask seed admin --username admin --email admin@yourfacility.example.com
```
> Prefer no CLI? Skip `seed admin` (and even the seed steps): start the site, and
> the login page offers to **create the first admin** on a fresh instance, then
> the setup wizard can seed reference data. Either path works.
---
## 7. IIS site
1. Copy `deploy\windows\web.config` to `APP_ROOT\web.config`. If `APP_ROOT` is not
`C:\shopdb-flask`, fix the paths inside it. Create `APP_ROOT\logs`.
2. Create an app pool with **No Managed Code**:
```powershell
Import-Module WebAdministration
New-WebAppPool -Name shopdbflask
Set-ItemProperty IIS:\AppPools\shopdbflask -Name managedRuntimeVersion -Value ""
```
3. Grant the app-pool identity access:
```powershell
icacls APP_ROOT /grant "IIS AppPool\shopdbflask:(OI)(CI)RX" /T
icacls APP_ROOT\logs /grant "IIS AppPool\shopdbflask:(OI)(CI)M" /T
```
4. **Unlock the handler sections** (locked server-wide by default; without this
IIS returns **HTTP 500.19**):
```powershell
%windir%\system32\inetsrv\appcmd unlock config /section:system.webServer/handlers
%windir%\system32\inetsrv\appcmd unlock config /section:system.webServer/httpPlatform
```
5. Create the site (own port; the classic ASP site can keep 8080):
```powershell
New-Website -Name shopdb-flask -Port 8090 -PhysicalPath APP_ROOT -ApplicationPool shopdbflask
New-NetFirewallRule -DisplayName "shopdb-flask 8090" -Direction Inbound -Protocol TCP -LocalPort 8090 -Action Allow
Start-Website shopdb-flask
```
IIS launches `waitress-serve --port=%HTTP_PLATFORM_PORT% wsgi:app` per the
web.config and reverse-proxies the site port to it. First request takes ~15s
(the app boots + connects to MySQL).
> The `X-Forwarded-For` URL Rewrite rule in web.config is **commented out by
> default**. It needs the URL Rewrite module; with it active but the module
> absent, IIS returns 500.19. Install URL Rewrite, then uncomment the
> `<rewrite>` block, to record real client IPs in audit logs.
---
## 8. Smoke test + first run
```powershell
(Invoke-WebRequest http://localhost:8090/ -UseBasicParsing).StatusCode # 200 (SPA)
Invoke-WebRequest http://localhost:8090/api/auth/login -Method POST `
-Body '{"username":"admin","password":"<the printed password>"}' `
-ContentType application/json -UseBasicParsing # 200 + token
```
Browse to `http://<host>:8090`, sign in as the admin, and the **setup wizard**
walks through site name, features (per-plugin: create tables here vs connect a DB),
floor-map upload, and starter data. Multiple Flask apps can share one IIS box -
each gets its own site, app pool, port, and venv.
---
## Troubleshooting
| Symptom | Cause / fix |
| --- | --- |
| `flask db upgrade` -> error **1071** | MySQL 5.6 without the step-1 flags (or server not restarted). |
| IIS **500.19** | handler sections not unlocked (step 7.4), or the `<rewrite>` block active without URL Rewrite. |
| **500** with an empty HttpPlatform log | app-pool identity can't read `APP_ROOT` / run the venv (step 7.3), or `.env` missing/invalid. |
| "No time zone found with key America/New_York" | `tzdata` not installed (`pip install tzdata`). |
| Nav missing Equipment/PCs/... | plugins not installed (step 6 `flask plugin install`), or site not recycled. |
| ConfigError on boot | a required `.env` var missing or left at a dev default. |

View File

@@ -1,6 +1,6 @@
# Roadmap
shopdb-flask is at `__contract_version__ = '0.2.0'` (pre-1.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs.
shopdb-flask is at `__contract_version__ = '0.5.0'` (pre-1.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs.
## Phase status
@@ -12,7 +12,7 @@ shopdb-flask is at `__contract_version__ = '0.2.0'` (pre-1.0). This document cap
| 3 - Manifest-first loader, shopdb.api namespace, auto-register blueprints | DONE | `6f085a1` |
| 4 - Plugin scaffolding (`flask plugin new`) | DONE | `8eb9362` |
| 5 - Alembic baseline, per-site deploy, ADRs to docs/adr | DONE | `d4e3ac9` |
| 6 - Polish (this phase) | IN PROGRESS | this commit |
| 6 - Multi-site distribution readiness (settings-driven branding/ServiceNow/floor plan, security closeout, docs + Docker frontend build, release engineering) | IN PROGRESS | this phase |
## What's left before tagging 1.0.0
@@ -26,6 +26,9 @@ shopdb-flask is at `__contract_version__ = '0.2.0'` (pre-1.0). This document cap
### Nice-to-have
- **Bundle the Roboto font locally.** `frontend/src/assets/style.css:2` imports Roboto from Google Fonts (`fonts.googleapis.com`). Air-gapped facilities have no route to that host, so the font silently falls back to a system font. Vendor the woff2 files into `frontend/src/assets/` and `@font-face` them locally so every site renders identically offline.
- **Full palette theming.** `brand_primary_color` is settings-driven, but the rest of the CSS palette (surfaces, borders, accents) is still hardcoded in `style.css`. A complete theming pass would expose the palette as CSS variables a site can override, not just the one primary color.
- **Frontend plugin contract.** The backend hook system has no Vue-side equivalent yet (routes/views still ship in core; nav is already backend-driven). See the must-have entry above; this is the design ADR that unblocks external plugins shipping their own UI.
- `measuringtools` plugin built using the scaffold (validates the scaffold under realistic conditions).
- Frontend scaffolding skill (the backend has `flask plugin new`; the frontend stub is currently manual copy-paste).
- Marketplace listing site (PLUGINS.md is a one-pager; a proper listing with links to sister-site plugins becomes useful when there are more than three external plugins).

112
docs/UPGRADE.md Normal file
View File

@@ -0,0 +1,112 @@
# Upgrading an existing site
This is the procedure for moving a running shopdb-flask instance to a newer
version. For a first-time install use [DEPLOY.md](DEPLOY.md) instead.
Each site is single-tenant (ADR-004), so an upgrade touches only that site's
own stack. Read the ADRs added since your last update (`docs/adr/`) and the
`CHANGELOG.md` before starting; a breaking ADR may require coordinated work.
## Step 0: Back up first
Never upgrade without a fresh backup you have tested. Take a full database dump
and copy the `instance/` directory. See [BACKUP-RESTORE.md](BACKUP-RESTORE.md).
```bash
# Docker:
docker compose exec -T db mysqldump -u root -p"${MYSQL_ROOT_PASSWORD}" shopdb_flask | gzip > pre-upgrade-$(date +%F).sql.gz
cp -a instance/ instance-backup-$(date +%F)/
```
## Step 1: Get the new code / image
```bash
git pull origin main
```
The application is distributed through the internal GE Aerospace Gitea; pull
from there. There is no external image registry.
## Step 2: Rebuild
The Docker image builds the Vue frontend in-image, so a container rebuild picks
up frontend changes automatically:
```bash
docker compose build api
docker compose up -d api
```
Bare-metal / venv install: rebuild the frontend by hand and refresh Python
dependencies:
```bash
source venv/bin/activate
pip install -r requirements.txt
cd frontend && npm ci && npm run build && cd ..
```
## Step 3: Apply migrations
```bash
# Docker:
docker compose exec api flask db upgrade
# venv:
flask db upgrade
```
`flask db upgrade` applies any new migrations in the core Alembic chain. It is
idempotent; running it when already at head is a no-op.
## Step 4: Re-seed permissions and settings
New versions may add RBAC permissions or default Settings keys. Both seeders are
idempotent - they add anything missing and leave existing rows untouched, so
your site's customized values are preserved.
```bash
# Docker:
docker compose exec api flask seed permissions
docker compose exec api flask seed settings
# venv:
flask seed permissions
flask seed settings
```
## Step 5: Restart
```bash
docker compose restart api
# venv: restart your process manager, e.g. pm2 restart shopdb-flask-api shopdb-flask-ui
```
Confirm the app is healthy (login page renders, `/api/auth/login` returns a
`VALIDATION_ERROR` for an empty body rather than a 500).
## Version-specific notes
### Upgrading to v0.5.0 or later: bundled West Jefferson floor plan removed
Versions before 0.5 shipped the West Jefferson facility floor-plan PNGs as the
map default (`/static/images/sitemap2025-light.png` and `-dark.png`). v0.5+
removes those bundled PNGs and ships a generic placeholder SVG instead.
If your instance's `map_blueprint_light` / `map_blueprint_dark` Settings still
point at `/static/images/sitemap2025-*`, the map will 404 those images after the
upgrade. Re-upload your own floor plan in **Settings > Map**. Uploaded floor
plans are stored under `instance/` and survive upgrades, so a site that already
uploaded its own plan is unaffected. Only instances still using the old bundled
default need to act.
To check what your instance points at:
```bash
docker compose exec api flask shell -c "from shopdb.core.models.setting import Setting; print(Setting.query.filter(Setting.key.like('map_blueprint%')).all())"
```
## See also
- [BACKUP-RESTORE.md](BACKUP-RESTORE.md) - what to back up and how to restore
- [CONFIG.md](CONFIG.md) - environment variables and Setting keys
- [DEPLOY.md](DEPLOY.md) - first-time deploy runbook
- `CHANGELOG.md` - what changed in each release

View File

@@ -0,0 +1,115 @@
# ADR-007: Product versioning and releases
- **Status:** ACCEPTED
- **Date:** 2026-07-10
- **Deciders:** cproudlock
- **Supersedes:** none
## Context
ADR-002 established semantic versioning for the plugin contract via
`__contract_version__` in `shopdb/__init__.py`. That number answers one
question only: is a given plugin compatible with this platform build. It
says nothing about the state of the product as a whole. A sister site
standing up its own instance needs a different answer: which release am I
running, and what changed since the last one.
Until now the product had no version, no tags, no changelog, and no CI.
ADR-002's pinning model implicitly assumes that a downstream site can
pin a known-good build, but there were no tags to pin to. Adopters had no
release record to read before upgrading, and no automated gate confirming
that a given commit builds and passes tests.
## Decision
The product carries its own release version, separate from the plugin
contract version.
1. **Product version** (`shopdb/__init__.py`): a single `__version__`
constant. This is the version of the shopdb-flask product as a whole.
It follows semantic versioning of the product's user-visible and
operator-visible behavior. It is deliberately NOT part of the
`shopdb.api` contract surface, so it is never re-exported through
`shopdb.api`; exporting it would itself be a contract change under
ADR-002.
2. **Plugin contract version** (`__contract_version__`): unchanged from
ADR-002. It moves only when the plugin contract surface changes, per
the major/minor/patch rules in ADR-002.
The two are distinct series with independent bump rules. They happen to
coincide at `0.5.0` for this release; that is a coincidence of timing,
not a coupling. A product release that changes no contract surface bumps
`__version__` while leaving `__contract_version__` fixed, and vice versa.
3. **Release record** (`CHANGELOG.md`, repo root): the canonical,
human-readable record of what changed in each release, in
Keep-a-Changelog format. Every release has an entry; work in flight
accumulates under `## [Unreleased]`.
4. **Git tags**: each product release is tagged `vX.Y.Z`, where `X.Y.Z`
matches `__version__` at the tagged commit. Tags are what ADR-002's
downstream-pinning model pins to.
5. **Frontend version** (`frontend/package.json`): kept in lock-step with
`__version__` so the shipped single-page app reports the same product
version as the backend that serves it.
### Release procedure
To cut release `X.Y.Z`:
1. Bump `__version__` in `shopdb/__init__.py` to `X.Y.Z`.
2. Bump `version` in `frontend/package.json` to the same `X.Y.Z`.
3. Move the accumulated `## [Unreleased]` notes in `CHANGELOG.md` into a
new `## [X.Y.Z] - YYYY-MM-DD` section, leaving a fresh empty
`## [Unreleased]` above it.
4. If the plugin contract surface changed this release, bump
`__contract_version__` per ADR-002 (independently of `__version__`).
5. Commit, then tag: `git tag -a vX.Y.Z -m "shopdb-flask X.Y.Z"`.
6. Push the commit and the tag.
## Consequences
### Positive
- Adopters have a real release to name in bug reports and a changelog to
read before upgrading.
- ADR-002's pinning story finally has tags to pin to.
- Product and contract can evolve at their own pace without one dragging
the other into a misleading bump.
### Negative / cost
- Two version numbers to keep straight. The comment in
`shopdb/__init__.py` and this ADR exist to keep the distinction clear.
- Release discipline: the changelog and both version constants must be
updated together, or the tagged build misreports itself.
### Neutral
- CI (`.gitea/workflows/ci.yml`) runs the backend tests, the naming/style
gate, and the frontend build on push and PR. It is best-effort: Gitea
Actions availability on the host is unverified, so the workflow is
config-only until a runner is confirmed.
## Alternatives considered
1. **Reuse `__contract_version__` as the product version.** Conflates two
independent concerns; a docs-only or UI-only release would either
falsely bump the contract or leave the product looking unchanged.
Rejected.
2. **Calendar versioning for the product** (e.g. `2026.07.0`). Easy to
bump but poor at signaling breaking operator-facing changes. Rejected
for the same reasons ADR-002 rejected it for the contract.
3. **No product version, rely on git SHAs.** Opaque to adopters and
unpinnable in any human-meaningful way. Rejected.
## References
- ADR-001 (defines the contract surface)
- ADR-002 (plugin contract versioning; `__contract_version__` bump rules)
- `shopdb/__init__.py` (`__version__`, `__contract_version__`)
- `CHANGELOG.md` (release record)
- `frontend/package.json` (frontend version, kept in lock-step)
- `.gitea/workflows/ci.yml` (CI gate)

View File

@@ -19,6 +19,7 @@ Each ADR captures a single architectural decision: the context, the decision its
| [004](ADR-004-deployment-topology.md) | Deployment topology (per-site instances) | ACCEPTED |
| [005](ADR-005-equipment-vs-measuringtools.md) | Equipment vs measuringtools plugin scope | ACCEPTED |
| [006](ADR-006-collector-contract.md) | Plugin collector contract pattern | ACCEPTED |
| [007](ADR-007-product-versioning-and-releases.md) | Product versioning and releases | ACCEPTED |
## Authoring