Windows installer: payload hash-locking, SBOM, client-IP handling, operator docs

This commit is contained in:
2026-08-03 15:08:20 -04:00
parent ff61a4c5e9
commit 3d43cb3d86
51 changed files with 9002 additions and 190 deletions

View File

@@ -29,9 +29,9 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
python-version: '3.14'
cache: pip
- run: pip install -r requirements.txt
- run: pip install -r requirements-dev.txt
- run: python -m pytest -q
naming:

View File

@@ -12,6 +12,54 @@ ADR-007 and ADR-002.
### Added
- Air-gapped Windows installer (`deploy/windows/installer/`). One `.exe` per
site, built from that site's plugin profile, containing Python, the wheels,
the SPA, the IIS modules and optionally MySQL. Operator docs:
`docs/INSTALL-WINDOWS.md` and `docs/OPERATE-WINDOWS.md`, both shipped onto the
server. `docs/INSTALL-WINDOWS-IIS.md` and `docs/DEPLOY-WINDOWS-IIS.md` are now
reference-only, for hand-built servers.
- `bundle-lock.json`: an exact sha256 + size record of the installer's
third-party payload (wheels, Python installer, IIS MSIs). Verified as set
equality by both builders and again on the server before anything runs; there
is no install-time override. Regenerate with `refresh-bundle-lock.ps1`.
- CycloneDX SBOM (`sbom.cdx.json`) generated on every build from
`requirements.txt` and `package-lock.json`, covering both ecosystems, staged
into the application tree so an air-gapped server can answer "do we carry this
component" locally: `shopdb-admin.ps1 verify -Path <name>`.
- `shopdb-admin.ps1`, the operator console: status, start/stop/restart, logs,
health check, backup, plugins, verify. `check -Json` emits secret-free
structured state for pasting into a support ticket or an AI assistant.
- `build-installer.ps1`, the whole build natively on Windows, so a work PC needs
no Bash. Shares `scripts/resolve_plugin_closure.py` with `build-site.sh`.
- URL Rewrite is bundled and the wizard asks where client IPs come from
(`-ClientIpSource direct|proxy`). Without the rule IIS sends no
`X-Forwarded-For` at all and every client reads as 127.0.0.1.
### Changed
- `requirements.txt` and `requirements-dev.txt` are compiled `--universal
--generate-hashes`. Installs run under `pip --require-hashes`, so a wheel whose
sha256 is not listed is refused. The dev lockfile is constrained to the
production pins; the two had drifted.
- Python 3.14 across the Dockerfile, CI, `web.config` and the docs, which
previously declared four different versions.
- The naming/style gate covers Markdown, JSON and YAML, not just code.
### Fixed
- `plugins/employees` imported `shopdb.core.models` directly, failing the
contract-surface test on `main` since the dashboard employee-name resolver
landed.
- `scripts/build-site.sh` copied all of `deploy/` into its own output directory,
which recursed when the output was staged inside it - the documented Windows
build could not complete.
- Plugin baseline migrations inherited the MySQL server's default charset: the
utf8mb4 compiler hook lived in `migrations/env.py` and so covered the core
chain only. It is now `shopdb/utils/mysql_charset.py`, imported by both.
`flask db-utils preflight` reports the database's default charset.
### Added
- Configurable site timezone: a `site_timezone` site setting (default
`America/New_York`, public-readable), editable in Settings > Site >
Localization. Notification start/end times are entered and displayed in this
@@ -53,7 +101,7 @@ ADR-007 and ADR-002.
- Shopfloor kiosk header text is readable (light on the dark navy header).
### Added
### Added (continued)
- Collector-driven PC -> printer relationships. The computers collector schema
gained optional `defaultprinter` (string) and `printers` (array of strings)

View File

@@ -32,7 +32,7 @@ RUN npm run build
# Output lands in /build/dist (Vite default), copied into the final stage below.
# ---- Stage 2: Python application image ----
FROM python:3.12-slim AS base
FROM python:3.14-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \

View File

@@ -20,7 +20,7 @@ ShopDB tracks and manages:
## Tech Stack
**Backend:**
- Python 3.12 with Flask
- Python 3.14 with Flask
- SQLAlchemy ORM
- MySQL 5.7+ database (5.6 works with extra utf8mb4 config; see the DEPLOY wiki page)
- JWT authentication
@@ -36,26 +36,26 @@ ShopDB tracks and manages:
```
shopdb-flask/
├── shopdb/ # Flask application
│ ├── core/
│ │ ├── api/ # REST API endpoints
│ │ ├── models/ # SQLAlchemy models
│ │ ├── schemas/ # Validation schemas
│ │ └── services/ # Business logic
│ ├── plugins/ # Plugin system
│ └── utils/ # Shared utilities
├── frontend/ # Vue 3 application
│ ├── src/
│ │ ├── api/ # API client
│ │ ├── components/ # Reusable components
│ │ ├── views/ # Page components
│ │ ├── router/ # Route definitions
│ │ └── stores/ # Pinia stores
│ └── public/ # Static assets
├── plugins/ # Bundled and external plugins
├── migrations/ # Alembic migration chain (flask db upgrade)
├── scripts/ # Import and utility scripts
└── tests/ # Test suite
+-- shopdb/ # Flask application
| +-- core/
| | +-- api/ # REST API endpoints
| | +-- models/ # SQLAlchemy models
| | +-- schemas/ # Validation schemas
| | `-- services/ # Business logic
| +-- plugins/ # Plugin system
| `-- utils/ # Shared utilities
+-- frontend/ # Vue 3 application
| +-- src/
| | +-- api/ # API client
| | +-- components/ # Reusable components
| | +-- views/ # Page components
| | +-- router/ # Route definitions
| | `-- stores/ # Pinia stores
| `-- public/ # Static assets
+-- plugins/ # Bundled and external plugins
+-- migrations/ # Alembic migration chain (flask db upgrade)
+-- scripts/ # Import and utility scripts
`-- tests/ # Test suite
```
## Naming Conventions
@@ -98,7 +98,7 @@ To maintain consistency with the legacy ShopDB database and codebase, the follow
### Prerequisites
- Python 3.12
- Python 3.14
- Node.js 18+
- MySQL 5.7+ (5.6 works with extra utf8mb4 config; see the DEPLOY wiki page)
@@ -174,8 +174,15 @@ token plus the IMPORT-API page in the project wiki drive the whole migration
through documented endpoints (`X-Import-Mode` preserves original timestamps).
`scripts/site_imports/wjf/` is the West Jefferson reference loader.
For the full per-site deployment runbook see the DEPLOY page in the project wiki;
for every environment variable and Setting key see the CONFIG page in the project wiki.
### Which deployment route
| Target | Use |
|---|---|
| **Windows Server + IIS** (how sister sites run) | **the INSTALL-WINDOWS page in the project wiki** - one installer `.exe`, offline, no manual IIS work. Day 2: the OPERATE-WINDOWS page in the project wiki |
| Linux / Docker, air-gapped | the DEPLOY-AIRGAP page in the project wiki |
| Linux / Docker, connected | the DEPLOY page in the project wiki |
For every environment variable and Setting key see the CONFIG page in the project wiki.
## Configuration
@@ -217,7 +224,7 @@ Query parameters for list endpoints:
ShopDB supports plugins for extending functionality. See `CONTRIBUTING.md` for plugin development guidelines.
The image bundles twelve plugins; only the ones a site installs are loaded:
The image bundles thirteen plugins; only the ones a site installs are loaded:
- **computers** - Shopfloor PCs and workstations, collector fleet ingest
- **employees** - Employee directory
@@ -227,6 +234,7 @@ The image bundles twelve plugins; only the ones a site installs are loaded:
- **knowledgebase** - Documentation and troubleshooting guides
- **network** - Network devices and subnets
- **notifications** - Shopfloor notifications and recognition feed
- **printedparts** - 3D-printed part catalogue, kiosk issue tracking and stock alerts
- **printers** - Extended printer management with Zabbix integration
- **slides** - TV/kiosk slideshows
- **usb** - CMMC USB check-in/out tracking

6
deploy/windows/installer/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
# Build output and staged payload - regenerable, and ~220MB.
bundle/
Output/
# Generated by build-installer.sh from the staged bundle.
plugins.iss
version.iss

View File

@@ -0,0 +1,245 @@
# Windows installer
Builds a single self-contained `.exe` that installs ShopDB-Flask on an
**air-gapped** Windows Server. Nothing here ever touches the network at install
time: Python, the wheels, the SPA and (optionally) MySQL all ship inside it.
Lives with the application on purpose. The installer depends on app internals -
`flask plugin` verbs, `site-profile.json`, `MOUNT_PATH`, the plugin registry -
so a separate repo would drift out of step with the thing it installs.
## Files
| File | What it is |
|---|---|
| `shopdb-preflight.ps1` | Stage 1. Read-only. Changes nothing, reports what this server is missing. |
| `shopdb-install.ps1` | Stages 0 and 2-5 plus `uninstall`. All the actual work. |
| `shopdb-admin.ps1` | Operator console installed alongside the app: status, restart, logs, backup, plugins. |
| `ShopDBFlask.iss` | Inno Setup wizard. A thin wrapper - it collects input and runs the stages. |
| `build-installer.sh` | Stages the bundle from a site profile. |
| `make-branding.py` | Generates wizard artwork and the icon from `frontend/public/*.svg`. |
| `*.bmp`, `shopdb.ico` | Generated artwork, committed so a Windows build box needs no Python. |
| `build-installer.ps1` | The same build, natively on Windows. No Bash needed. |
| `bundle-lock.json` | The exact third-party payload this installer ships. Reviewed by commit. |
| `bundle-lock.ps1` | Creates and checks that lock. Also runs on the target server. |
| `refresh-bundle-lock.ps1` | Regenerates the lock, after showing what changed. |
| `verify_bundle_lock.py` | The same check for the Bash builder, so Linux needs no pwsh. |
## Building
Two builders, same result. Use whichever machine you are on - `build-installer.ps1`
does the whole job natively so a Windows work PC needs no Bash.
```bash
# Linux / WSL
./build-installer.sh ../../site-profile.example.json
```
```powershell
# Windows
.\build-installer.ps1 -Profile ..\..\site-profile.example.json
```
Both stage the app tree, build the SPA twice, write `plugins.iss`, copy the
installer scripts **from this directory**, and then verify the third-party
payload against `bundle-lock.json`. They exit non-zero if it does not match.
The payload itself is added by hand, and no longer needs Windows to produce:
```
bundle/wheels/ pip download -r requirements.txt --only-binary=:all: \
--platform win_amd64 --python-version 314 \
--implementation cp --abi cp314 -d wheels
bundle/python/ python-3.14.x-amd64.exe
bundle/httpplatformhandler/ httpPlatformHandler_amd64.msi
bundle/urlrewrite/ rewrite_amd64.msi (client-IP rule; see below)
bundle/mysql/ mysql-8.0.x-winx64.msi (bundled-database option only)
```
Then compile on Windows:
```
iscc ShopDBFlask.iss
```
**Inno Setup 6.6.0 or newer.** The wizard uses the built-in `windows11` custom
style, which earlier versions reject. The script fails at compile time with that
sentence rather than with a bare "WizardStyle is invalid".
The wheelhouse is **cp314-locked**. A different Python minor version means a
different wheelhouse; the installer will not use a Python it did not install.
## What is pinned, and where
Three layers, because no one of them covers the whole problem.
| Layer | Covers | Enforced |
|---|---|---|
| `requirements.txt` sha256 per package | every wheel is genuinely what upstream published | `pip --require-hashes` at install; aborts on mismatch |
| `bundle-lock.json` | the EXACT payload: wheels, Python installer, MSIs | both builders, and again on the server before anything runs |
| git | the application tree | code review |
`--require-hashes` alone is not enough. pip lists every artifact of a pinned
version - `cffi 2.1.0` has 100 hashes - so it proves the wheel is genuine, not
that it is the wheel this bundle was built and tested with. It also ignores
extra files in the wheelhouse, and says nothing about the Python installer or
the MSIs, all of which run as SYSTEM on the target server.
So `bundle-lock.json` records an exact file set with a sha256 and a byte size
each, and verification is **set equality**: a missing file, an unexpected extra
file, or changed content all fail. There is no install-time override.
The lockfile is `--universal`, so one file serves Linux (dev, Docker, CI) and the
Windows wheelhouse. A Linux-only resolve had silently omitted `colorama`, a
win32-only dependency of `click` - which in hash-checking mode is a hard error
rather than a quiet omission.
### Verifying the installer itself
`bundle-lock.json` is **inside** the thing it describes, so it proves the payload
was not altered between build and install - not that the `.exe` you received is
the one that was built. That needs something out of band. Two options, in order
of preference:
1. **Authenticode-sign the `.exe`** with a GE code-signing certificate. Windows
then shows a real publisher instead of "Unknown", which is also what stops an
operator learning to click through the SmartScreen warning.
2. **Publish a sha256 per release** through a different channel than the file
itself, and have the receiving site check it:
`Get-FileHash ShopDBFlask_Installer_*.exe -Algorithm SHA256`
Neither is wired up yet. Until one is, an installer is only as trustworthy as
the share it arrived on.
### Changing what ships
```powershell
.\refresh-bundle-lock.ps1 # show what changed, write nothing
.\refresh-bundle-lock.ps1 -Yes # write it
```
Then **commit `bundle-lock.json`**. That commit is the review - it is the only
place a change to what runs as SYSTEM on a customer's server becomes visible to
a human. `refresh-bundle-lock.ps1` refuses to overwrite an existing lock until
you have seen the diff, for that reason.
To stage a bundle before its lock exists: `ALLOW_UNLOCKED=1` (Bash) or
`-AllowUnlocked` (PowerShell). Bundles built that way must not be shipped.
Verifying a live server, months later and offline:
```powershell
shopdb-admin.ps1 verify
```
## Servers this installer did not build
It is built for greenfield: its own Python, its own venv, its own IIS objects.
Its upgrade path assumes the thing being upgraded came out of a previous run.
Two guards keep it from damaging a server that was deployed by hand.
**Python minor version.** An existing venv is reused, which is right for a repair
or an upgrade. It is wrong when the venv belongs to a different Python - the
wheelhouse is tagged for one minor version, so pip would die at the first
compiled package, *after* Python was installed and the app tree replaced. The
installer compares the two up front and stops with both version numbers.
**IIS objects.** Switching deployment method removes the other method's artifact,
which is correct when the installer owns both and dangerous when it does not: a
wrong `-MountAlias` would delete a live mount with no prompt. It now refuses
unless there is a version stamp proving it made the install, or you pass
`-AdoptExisting`. The refusal lists exactly what it would have removed.
An existing `web.config` is never overwritten in either case.
For the West Jefferson production server specifically, this is a **migration, not
an upgrade** - prod runs Python 3.13 against a hand-built deployment, so it needs
a deliberate window, a database backup, and web.config reconciled by hand.
## Bill of materials
Every build stages a CycloneDX 1.6 SBOM at `sbom.cdx.json`, inside the
application tree, so it installs onto the server with the app. Both ecosystems,
in one document:
- **Python** - every pin in `requirements.txt`, with the sha256 the installer
enforces. Environment markers are ignored: a `sys_platform == 'win32'`
dependency still installs on the target.
- **npm** - every package in `frontend/package-lock.json`. Build-only packages
are marked `scope: excluded` rather than dropped, so "not here" is
distinguishable from "not looked for".
It ships to the server because an air-gapped site cannot be scanned from
anywhere else. When a CVE lands, the answer is already on the box:
```powershell
shopdb-admin.ps1 verify # counts, and which bundle this is
shopdb-admin.ps1 verify -Path leaflet # is that component here, at what version
```
Generated by `scripts/generate_sbom.py` from files that are already pinned and
committed, so it is a translation rather than a scan - no network, no extra
toolchain on the build box, and byte-identical output for the same inputs. It is
deliberately not in `bundle-lock.json`: its provenance is git, not the payload.
## Client IP addresses
IIS does not set `X-Forwarded-For` on its own, and HttpPlatformHandler connects
from loopback. Without a rule, **every client reads as 127.0.0.1** - so the
GE-Enforce IP allowlist, the dashboard's visitor-location lookup and per-host
login rate limiting all stop working, silently.
The wizard asks, because the two answers are mutually exclusive:
- **Clients connect directly** (`-ClientIpSource direct`, the default) - installs
URL Rewrite from the bundle and sets `X-Forwarded-For` from `REMOTE_ADDR`.
Overwriting the header is what stops a client spoofing its own.
- **A proxy sits in front** (`-ClientIpSource proxy`) - leaves the rule off.
Behind ARR or a load balancer `REMOTE_ADDR` is the *proxy*, so applying the
rule would discard the real client IP.
An existing `web.config` is never overwritten - it is the one file on a server
that legitimately carries hand-edits. The installer reports what it found
instead.
## Deployment methods
Chosen in the wizard, and the bundle carries a SPA build for each because Vite
compiles the base path in - it cannot be switched at install time.
- **Its own site** on a port (default 8090).
- **Subpath** under an existing site, e.g. `http://<server-fqdn>/shopdb/`. Needs
no new DNS record. Three things must agree - the IIS application alias,
`MOUNT_PATH` in `.env`, and the SPA's build-time base - so the alias is fixed
per bundle (`SUBPATH_ALIAS`, default `shopdb`) and the installer refuses if the
bundle's build does not match what was asked for.
Switching between methods removes the other one's IIS artifact and reconciles
`MOUNT_PATH` and `CORS_ORIGINS`, so a server never ends up with both.
## Upgrades
Run a newer installer over an existing install. It:
- backs the database up first, **verifies** the dump is complete, and refuses to
migrate if it cannot;
- restores from that backup if migrations fail, and reports honestly that DDL
the failed migration committed cannot be undone;
- refuses to run a bundle older than what is installed;
- keeps `.env` unless new credentials are supplied, and copies it aside first;
- stops the app pool before replacing files, then starts it again.
Whether a run is an upgrade is decided by probing the **target database**, not by
whether the app directory exists - a rebuilt server pointed at an existing
database is an upgrade, and treating it as fresh would drop tables.
## Testing notes
Verified end to end on Windows Server 2025 against both a bundled MySQL 8.0 and
an existing MySQL 5.6: fresh install, upgrade, re-run idempotency, failure and
rollback, uninstall, and both deployment methods including switching between
them.
**Not yet verified:** a fully air-gapped run with the network disabled at the
hypervisor, and any load in a real browser (all HTTP checks so far used curl,
which sends no `Origin` header - so `CORS_ORIGINS` is untested in anger).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,301 @@
<#
.SYNOPSIS
Stage a lean per-site installer bundle on Windows, and verify its third-party
payload against bundle-lock.json.
.DESCRIPTION
The Windows equivalent of build-installer.sh, for a work PC with no Bash. It
does the whole job natively - it does NOT shell out to build-site.sh - so the
only tools needed are Python, Node and (to compile) Inno Setup.
What it does, in order:
1. Resolves the site's plugin closure from its profile.
2. Builds the SPA twice, because Vite compiles the base path in and it
therefore cannot be chosen at install time: once for /<alias> (subpath
deployment) and once for / (its own site).
3. Stages the backend tree: core, the chosen plugins only, and the runtime
files a deployable tree needs.
4. Writes plugins.iss so the wizard's plugin page matches the payload.
5. Copies the installer scripts from THIS directory.
6. Verifies wheels\, python\ and the MSIs against bundle-lock.json, and
FAILS if the bundle is not exactly what the lock describes.
Step 6 is the point of the script. A bundle that does not match its lock is
not shipped, and "the wheelhouse was missing" is caught here rather than by
an operator halfway through installing on a server with no network.
.PARAMETER Profile
Path to the site profile (see deploy\site-profile.example.json).
.PARAMETER RepoRoot
The repository. Defaults to four levels up from this script, which is correct
for a normal checkout.
.PARAMETER SkipFrontend
Reuse the SPA builds already staged in the bundle. For iterating on the
installer itself, where two Vite builds per run is most of the wall clock.
Never use it for a bundle you intend to ship.
.PARAMETER AllowUnlocked
Stage the bundle and report payload problems WITHOUT failing. For assembling
a bundle before its lock exists. A bundle built this way must not be shipped;
run refresh-bundle-lock.ps1, commit the lock, then build again without this.
.EXAMPLE
.\build-installer.ps1 -Profile ..\..\site-profile.example.json
.\build-installer.ps1 -Profile C:\sites\wjf.json -SkipFrontend
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)] [string] $Profile,
[string] $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path,
[string] $BundleRoot = (Join-Path $PSScriptRoot 'bundle'),
[string] $SubpathAlias = 'shopdb',
[switch] $SkipFrontend,
[switch] $AllowUnlocked
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'bundle-lock.ps1')
function Say { param($m, $c = 'Gray') Write-Host $m -ForegroundColor $c }
function Step { param($m) Write-Host ''; Write-Host "==> $m" -ForegroundColor Cyan }
function Die { param($m, $fix = '') Write-Host ''; Write-Host " $m" -ForegroundColor Red
if ($fix) { Write-Host " $fix" -ForegroundColor Yellow }; exit 1 }
function Invoke-Tool {
# Runs a build tool and stops on a non-zero exit. npm writes progress to
# stderr on SUCCESS, so stderr alone must never be treated as failure.
param([string] $Exe, [string[]] $Arguments, [string] $WorkDir, [string] $What)
Push-Location $WorkDir
try {
$prev = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
& $Exe @Arguments 2>&1 | ForEach-Object { Say " $_" 'DarkGray' }
$code = $LASTEXITCODE
$ErrorActionPreference = $prev
if ($code -ne 0) { Die "$What failed (exit $code)" }
} finally { Pop-Location }
}
if (-not (Test-Path $Profile)) { Die "profile not found: $Profile" }
if (-not (Test-Path $RepoRoot)) { Die "repo not found: $RepoRoot" }
$Profile = (Resolve-Path $Profile).Path
$RepoRoot = (Resolve-Path $RepoRoot).Path
$AppOut = Join-Path $BundleRoot 'app'
$python = (Get-Command python -ErrorAction SilentlyContinue)
if (-not $python) { $python = Get-Command py -ErrorAction SilentlyContinue }
if (-not $python) { Die 'Python not found on PATH' 'Install Python 3.14 and re-open the shell.' }
$npm = Get-Command npm.cmd -ErrorAction SilentlyContinue
if (-not $npm -and -not $SkipFrontend) { Die 'npm not found on PATH' 'Install Node, or pass -SkipFrontend to reuse the staged SPA.' }
Say ''
Say " repo : $RepoRoot"
Say " profile : $Profile"
Say " bundle : $BundleRoot"
# --- 1. plugin closure ------------------------------------------------------
# Same resolver the Linux builder uses, so both produce the same set from one
# profile instead of two implementations of the closure rules.
Step 'Resolving plugin closure'
$closure = (& $python.Source (Join-Path $RepoRoot 'scripts\resolve_plugin_closure.py') $Profile $RepoRoot)
if ($LASTEXITCODE -ne 0 -or -not $closure) { Die 'could not resolve the plugin closure from the profile' }
$closure = $closure.Trim()
Say " $closure" 'White'
# --- 2. frontend ------------------------------------------------------------
$frontend = Join-Path $RepoRoot 'frontend'
$subStaged = Join-Path $BundleRoot 'spa-subpath'
$rootStaged= Join-Path $BundleRoot 'spa-root'
if ($SkipFrontend) {
if (-not (Test-Path $subStaged) -or -not (Test-Path $rootStaged)) {
Die '-SkipFrontend was passed but no SPA build is staged' 'Run once without it.'
}
Say ''
Say ' Reusing the staged SPA builds (-SkipFrontend). NOT shippable if the frontend changed.' 'Yellow'
} else {
Step "Building the SPA for /$SubpathAlias/"
$env:SITE_PLUGINS = $closure
$env:VITE_BASE_PATH = "/$SubpathAlias/"
try { Invoke-Tool $npm.Source @('run','build','--silent') $frontend 'subpath frontend build' }
finally { Remove-Item Env:\VITE_BASE_PATH -ErrorAction SilentlyContinue }
Remove-Item $subStaged -Recurse -Force -ErrorAction SilentlyContinue
Copy-Item (Join-Path $frontend 'dist') $subStaged -Recurse -Force
# The alias is fixed at BUILD time; the installer reads this and refuses to
# publish under a different one rather than serving a page that cannot load
# its own assets.
Set-Content -Path (Join-Path $subStaged '.alias') -Value $SubpathAlias -Encoding ASCII
# Root build LAST, so frontend\dist is left in the state a developer expects.
Step 'Building the SPA for /'
try { Invoke-Tool $npm.Source @('run','build','--silent') $frontend 'root frontend build' }
finally { Remove-Item Env:\SITE_PLUGINS -ErrorAction SilentlyContinue }
Remove-Item $rootStaged -Recurse -Force -ErrorAction SilentlyContinue
Copy-Item (Join-Path $frontend 'dist') $rootStaged -Recurse -Force
}
# --- 3. backend tree --------------------------------------------------------
Step "Staging the application tree"
Remove-Item $AppOut -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path (Join-Path $AppOut 'plugins') -Force | Out-Null
Copy-Item (Join-Path $RepoRoot 'shopdb') $AppOut -Recurse -Force
foreach ($name in $closure.Split(',')) {
$src = Join-Path $RepoRoot ('plugins\' + $name.Trim())
if (-not (Test-Path $src)) { Die "plugin in the closure is not on disk: $name" }
Copy-Item $src (Join-Path $AppOut 'plugins') -Recurse -Force
}
# Runtime files a deployable tree needs beyond the Python packages. Without
# these the tree imports but cannot be run or migrated.
foreach ($f in @('wsgi.py', 'requirements.txt')) {
Copy-Item (Join-Path $RepoRoot $f) $AppOut -Force
}
Copy-Item (Join-Path $RepoRoot 'migrations') $AppOut -Recurse -Force
# ONLY web.config, not all of deploy\. The bundle is staged INSIDE deploy\, so
# copying the whole tree would recurse into its own output, and the rest of
# deploy\ is installer source that has no business on an application server.
# shopdb-install.ps1 reads it from exactly this path.
$cfgSrc = Join-Path $RepoRoot 'deploy\windows\web.config'
if (Test-Path $cfgSrc) {
New-Item -ItemType Directory -Path (Join-Path $AppOut 'deploy\windows') -Force | Out-Null
Copy-Item $cfgSrc (Join-Path $AppOut 'deploy\windows') -Force
}
# A CycloneDX SBOM of everything this tree depends on, Python and npm together.
# Staged INTO the tree so it installs onto the server with the application: an
# air-gapped site cannot be scanned remotely, so the only way to answer "are we
# exposed to this CVE, and where" is for the answer to be sitting on the box.
Step 'Generating SBOM'
& $python.Source (Join-Path $RepoRoot 'scripts\generate_sbom.py') $RepoRoot `
-o (Join-Path $AppOut 'sbom.cdx.json') | ForEach-Object { Say " $_" 'White' }
if ($LASTEXITCODE -ne 0) { Die 'SBOM generation failed' }
# Docs the running site serves, plus the runbooks an air-gapped server has no
# other way to reach. Without openapi.json and llms.txt the self-hosted /api/docs
# page is broken on every installed server.
Step 'Staging docs'
$docsOut = Join-Path $AppOut 'docs'
New-Item -ItemType Directory -Path $docsOut -Force | Out-Null
foreach ($doc in @('openapi.json', 'llms.txt', 'api-inventory.json',
'INSTALL-WINDOWS.md', 'OPERATE-WINDOWS.md',
'BACKUP-RESTORE.md', 'UPGRADE.md')) {
$src = Join-Path $RepoRoot ('docs\' + $doc)
if (Test-Path $src) { Copy-Item $src $docsOut -Force; Say " $doc" }
}
# Stage the profile INTO the tree: `flask plugin apply-profile` at provisioning
# reads the same profile the tree was staged from, so the installed plugin set
# and the shipped plugin code cannot drift.
Copy-Item $Profile (Join-Path $AppOut 'site-profile.json') -Force
# The installer expects frontend\dist and frontend\dist-subpath.
$feOut = Join-Path $AppOut 'frontend'
New-Item -ItemType Directory -Path $feOut -Force | Out-Null
Copy-Item $rootStaged (Join-Path $feOut 'dist') -Recurse -Force
Copy-Item $subStaged (Join-Path $feOut 'dist-subpath') -Recurse -Force
Get-ChildItem $AppOut -Recurse -Directory -Filter '__pycache__' -ErrorAction SilentlyContinue |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem $AppOut -Recurse -File -Filter '*.pyc' -ErrorAction SilentlyContinue |
Remove-Item -Force -ErrorAction SilentlyContinue
# --- 4. plugins.iss ---------------------------------------------------------
Step 'Writing plugins.iss'
$shipped = (Get-ChildItem (Join-Path $AppOut 'plugins') -Directory | Select-Object -ExpandProperty Name) -join ','
$aliasBuilt = ''
$aliasFile = Join-Path $feOut 'dist-subpath\.alias'
if (Test-Path $aliasFile) { $aliasBuilt = (Get-Content $aliasFile -TotalCount 1).Trim() }
@"
; GENERATED by build-installer.ps1 - do not edit.
; The plugins present in bundle\app\plugins at build time.
#define AvailablePlugins "$shipped"
; The alias the subpath SPA was built for, or empty if this bundle has no
; subpath build - in which case the wizard must not offer that option.
#define SubpathAlias "$aliasBuilt"
"@ | Set-Content -Path (Join-Path $PSScriptRoot 'plugins.iss') -Encoding ASCII
Say " $shipped" 'White'
# The product version, read from the code rather than restated in the .iss.
Step 'Writing version.iss'
$initText = Get-Content (Join-Path $RepoRoot 'shopdb\__init__.py') -Raw
if ($initText -notmatch "(?m)^__version__\s*=\s*'([^']+)'") { Die 'could not read __version__ from shopdb\__init__.py' }
$appVersion = $Matches[1]
@"
; GENERATED by build-installer.ps1 from shopdb/__init__.py - do not edit.
#define AppVersion "$appVersion"
"@ | Set-Content -Path (Join-Path $PSScriptRoot 'version.iss') -Encoding ASCII
Say " $appVersion" 'White'
# --- 5. installer scripts ---------------------------------------------------
# From THIS directory, which is the reviewed copy under version control. They
# used to be copied from a downloads folder, so the logic that shipped was not
# the logic that was committed and the build only worked on one machine.
Step 'Copying installer scripts'
foreach ($f in @('shopdb-install.ps1', 'shopdb-preflight.ps1', 'bundle-lock.ps1')) {
$src = Join-Path $PSScriptRoot $f
if (-not (Test-Path $src)) { Die "installer script missing from the repo: $f" }
Copy-Item $src $BundleRoot -Force
Say " $f"
}
# --- 6. payload verification ------------------------------------------------
Step 'Verifying the third-party payload against bundle-lock.json'
$lockPath = Join-Path $PSScriptRoot 'bundle-lock.json'
$lock = Read-BundleLock $lockPath
if (-not $lock) {
$msg = "no bundle-lock.json at $lockPath"
if ($AllowUnlocked) { Say " $msg - continuing because -AllowUnlocked was passed" 'Yellow' }
else {
Die $msg @'
Add the wheels and installers to the bundle, then:
.\refresh-bundle-lock.ps1 (review the list)
.\refresh-bundle-lock.ps1 -Yes (write it)
and commit bundle-lock.json. Pass -AllowUnlocked to stage a bundle without one -
it must not be shipped.
'@
}
} else {
$problems = Test-BundleLock -BundleRoot $BundleRoot -Lock $lock
if ($problems.Count -eq 0) {
Say (" payload matches the lock ({0}, {1})" -f `
(Get-JsonProperty $lock 'pythontag' 'unknown'), (Get-JsonProperty $lock 'platform' 'unknown')) 'Green'
# Ships WITH the bundle: the installer re-checks the payload on the
# target server before running any of it, so tampering between build and
# install is caught too.
Copy-Item $lockPath $BundleRoot -Force
} else {
Say ''
foreach ($p in $problems) { Say " $p" 'Red' }
Say ''
if ($AllowUnlocked) {
Say ' -AllowUnlocked: continuing anyway. DO NOT SHIP this bundle.' 'Yellow'
} else {
Die ("{0} payload problem(s) - the bundle is not what the lock describes" -f $problems.Count) @'
Either the payload is wrong (fix the bundle) or it changed on purpose (run
refresh-bundle-lock.ps1, read the diff, and commit the new lock).
'@
}
}
}
# --- summary ----------------------------------------------------------------
Write-Host ''
Say " Bundle staged at: $BundleRoot" 'White'
foreach ($d in @('app', 'wheels', 'python', 'httpplatformhandler', 'urlrewrite', 'mysql')) {
$p = Join-Path $BundleRoot $d
if (Test-Path $p) {
$mb = ((Get-ChildItem $p -Recurse -File | Measure-Object Length -Sum).Sum / 1MB)
Say (" {0,-22} {1,8:N1} MB" -f $d, $mb)
} else {
Say (" {0,-22} {1}" -f $d, 'absent') 'DarkGray'
}
}
Write-Host ''
Say ' Compile with: iscc ShopDBFlask.iss' 'White'
Say ' (Inno Setup 6.6.0 or newer - the wizard uses the windows11 custom style.)' 'DarkGray'
Write-Host ''

View File

@@ -0,0 +1,140 @@
#!/bin/bash
# Stage a lean per-site bundle next to ShopDBFlask.iss, ready for Inno Setup.
#
# The bundle is built FOR ONE SITE from its plugin profile (ADR-013): plugins the
# site did not choose are absent from the payload entirely. Build one installer
# per site, not one universal installer.
#
# Usage: build-installer.sh <site-profile.json> [repo-path]
#
# The wheelhouse cannot be built here. Wheels are cp314 win_amd64 and must be
# produced ON Windows with the matching Python:
# pip download -r requirements.txt -d wheels --only-binary=:all:
# Copy that wheels\ directory in before compiling.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROFILE="${1:?usage: build-installer.sh <site-profile.json> [repo-path]}"
REPO="${2:-$HOME/projects/shopdb-flask}"
BUNDLE="$HERE/bundle"
[ -f "$PROFILE" ] || { echo "profile not found: $PROFILE"; exit 1; }
[ -d "$REPO" ] || { echo "repo not found: $REPO"; exit 1; }
echo "==> Staging lean app tree from $PROFILE"
rm -rf "$BUNDLE/app"
bash "$REPO/scripts/build-site.sh" "$PROFILE" "$BUNDLE/app"
# build-site.sh emits the SPA as frontend-dist; the installer's web.config and
# static route expect frontend\dist.
if [ -d "$BUNDLE/app/frontend-dist" ]; then
mkdir -p "$BUNDLE/app/frontend"
rm -rf "$BUNDLE/app/frontend/dist"
mv "$BUNDLE/app/frontend-dist" "$BUNDLE/app/frontend/dist"
fi
# The /shopdb-based build, used when the operator picks the subpath deployment.
if [ -d "$BUNDLE/app/frontend-dist-subpath" ]; then
rm -rf "$BUNDLE/app/frontend/dist-subpath"
mv "$BUNDLE/app/frontend-dist-subpath" "$BUNDLE/app/frontend/dist-subpath"
fi
# Tell the .iss which plugins this bundle actually carries, so the wizard's
# plugin page always matches the payload instead of a hand-maintained list.
echo "==> Writing plugins.iss"
PLUGINS=$(ls "$BUNDLE/app/plugins" 2>/dev/null | tr '\n' ',' | sed 's/,$//')
# The subpath SPA is built with its base path compiled in, so whether the wizard
# can OFFER a subpath install is a property of the bundle, not a runtime choice.
SUBPATH_ALIAS_BUILT=""
if [ -f "$BUNDLE/app/frontend/dist-subpath/.alias" ]; then
SUBPATH_ALIAS_BUILT="$(cat "$BUNDLE/app/frontend/dist-subpath/.alias")"
fi
cat > "$HERE/plugins.iss" <<EOF
; GENERATED by build-installer.sh - do not edit.
; The plugins present in bundle\\app\\plugins at build time.
#define AvailablePlugins "$PLUGINS"
; The alias the subpath SPA was built for, or empty if this bundle has no
; subpath build - in which case the wizard must not offer that option.
#define SubpathAlias "$SUBPATH_ALIAS_BUILT"
EOF
echo " $PLUGINS"
# The product version, read from the code rather than restated here. A hardcoded
# AppVersion in the .iss had drifted two minor versions from shopdb/__init__.py.
echo "==> Writing version.iss"
APPVERSION=$(sed -n "s/^__version__ = '\\(.*\\)'/\\1/p" "$REPO/shopdb/__init__.py" | head -1)
[ -n "$APPVERSION" ] || { echo "could not read __version__ from shopdb/__init__.py"; exit 1; }
cat > "$HERE/version.iss" <<EOF
; GENERATED by build-installer.sh from shopdb/__init__.py - do not edit.
#define AppVersion "$APPVERSION"
EOF
echo " $APPVERSION"
# From THIS directory, which is the reviewed copy under version control. These
# used to be copied from $HOME/Downloads, so the installer logic that shipped was
# not the logic that was committed, and the build only worked on one machine.
echo "==> Copying installer scripts"
mkdir -p "$BUNDLE"
for f in shopdb-install.ps1 shopdb-preflight.ps1 bundle-lock.ps1; do
[ -f "$HERE/$f" ] || { echo "installer script missing from the repo: $f"; exit 1; }
cp "$HERE/$f" "$BUNDLE/"
done
echo ""
echo "Bundle staged at: $BUNDLE"
for d in app wheels python httpplatformhandler urlrewrite mysql; do
if [ -d "$BUNDLE/$d" ]; then
printf ' %-20s %s\n' "$d" "$(du -sh "$BUNDLE/$d" | cut -f1)"
else
printf ' %-20s absent\n' "$d"
fi
done
echo ""
echo " plugins shipped: $(ls "$BUNDLE/app/plugins" 2>/dev/null | tr '\n' ' ')"
# --- payload verification ---------------------------------------------------
# The third-party payload is the part git does not record: the wheels, the Python
# installer and the MSIs that run as SYSTEM on the target server. It must be
# EXACTLY what bundle-lock.json describes - no missing file, no stale extra wheel
# left over from a previous build, no changed content - or this is not a bundle
# anyone reviewed. Previously a missing wheelhouse printed MISSING and the script
# still exited 0, so an empty bundle compiled into a shippable installer and the
# failure surfaced on an air-gapped server with no way to fix it.
#
# ALLOW_UNLOCKED=1 downgrades this to a warning, for assembling a bundle before
# its lock exists. A bundle built that way must not be shipped.
echo ""
echo "==> Verifying the third-party payload against bundle-lock.json"
if python3 "$HERE/verify_bundle_lock.py" "$BUNDLE" "$HERE/bundle-lock.json"; then
echo " payload matches the lock"
# Ships WITH the bundle: the installer re-checks the payload on the target
# server before running any of it, so tampering between build and install is
# caught too.
cp "$HERE/bundle-lock.json" "$BUNDLE/"
elif [ "${ALLOW_UNLOCKED:-0}" = "1" ]; then
echo ""
echo " ALLOW_UNLOCKED=1: continuing anyway. DO NOT SHIP this bundle."
else
echo ""
echo " The bundle is not what the lock describes."
echo ""
echo " Add the missing pieces by hand:"
echo " wheels/ pip download -r requirements.txt --only-binary=:all: \\"
echo " --platform win_amd64 --python-version 314 \\"
echo " --implementation cp --abi cp314 -d wheels"
echo " python/ python-3.14.x-amd64.exe"
echo " httpplatformhandler/ httpPlatformHandler_amd64.msi"
echo " urlrewrite/ rewrite_amd64.msi (client-IP rule; see README)"
echo " mysql/ mysql-8.0.x-winx64.msi (bundled-database option only)"
echo ""
echo " If the payload changed ON PURPOSE, regenerate and COMMIT the lock:"
echo " pwsh ./refresh-bundle-lock.ps1 # review the diff"
echo " pwsh ./refresh-bundle-lock.ps1 -Yes # write it"
echo ""
echo " To stage a bundle before its lock exists: ALLOW_UNLOCKED=1 $0 ..."
exit 1
fi
echo ""
echo "Then compile on Windows: iscc ShopDBFlask.iss"
echo "(Inno Setup 6.6.0 or newer - the wizard uses the windows11 custom style.)"

View File

@@ -0,0 +1,197 @@
{
"schema": 1,
"generated": "2026-08-03T15:45:11Z",
"pythontag": "cp314",
"platform": "win_amd64",
"note": "Exact third-party payload of the installer bundle. Regenerate with refresh-bundle-lock.ps1 and COMMIT the change as a reviewed dependency bump.",
"payloads": {
"wheels": {
"required": true,
"files": {
"jinja2-3.1.6-py3-none-any.whl": {
"size": 134899,
"sha256": "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"
},
"itsdangerous-2.2.0-py3-none-any.whl": {
"size": 16234,
"sha256": "c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"
},
"cffi-2.1.0-cp314-cp314-win_amd64.whl": {
"size": 187937,
"sha256": "1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb"
},
"email_validator-2.3.0-py3-none-any.whl": {
"size": 35604,
"sha256": "80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"
},
"urllib3-2.7.0-py3-none-any.whl": {
"size": 131087,
"sha256": "9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"
},
"flask_jwt_extended-4.7.3-py2.py3-none-any.whl": {
"size": 22698,
"sha256": "905ac807b52b5409bc9244dbcca434968c13ca6f9d91bffe7d4cb71e0e6231cb"
},
"flask_sqlalchemy-3.1.1-py3-none-any.whl": {
"size": 25125,
"sha256": "4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0"
},
"pycparser-3.0-py3-none-any.whl": {
"size": 48172,
"sha256": "b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"
},
"requests-2.33.1-py3-none-any.whl": {
"size": 64947,
"sha256": "4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"
},
"blinker-1.9.0-py3-none-any.whl": {
"size": 8458,
"sha256": "ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"
},
"greenlet-3.5.0-cp314-cp314-win_amd64.whl": {
"size": 239835,
"sha256": "3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033"
},
"marshmallow_sqlalchemy-1.5.0-py3-none-any.whl": {
"size": 16582,
"sha256": "3865232672f3dd38c4d5e4e85fdedce76904200742c3594948a2d11d0af93258"
},
"alembic-1.18.4-py3-none-any.whl": {
"size": 263893,
"sha256": "a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a"
},
"pyjwt-2.12.1-py3-none-any.whl": {
"size": 29726,
"sha256": "28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"
},
"mako-1.3.12-py3-none-any.whl": {
"size": 78521,
"sha256": "8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9"
},
"waitress-3.0.2-py3-none-any.whl": {
"size": 56232,
"sha256": "c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e"
},
"colorama-0.4.6-py2.py3-none-any.whl": {
"size": 25335,
"sha256": "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"
},
"certifi-2026.4.22-py3-none-any.whl": {
"size": 135707,
"sha256": "3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"
},
"cachelib-0.13.0-py3-none-any.whl": {
"size": 20914,
"sha256": "8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516"
},
"pymysql-1.1.3-py3-none-any.whl": {
"size": 45356,
"sha256": "8164ba62c552f6105f3b11753352d0f16b90d1703ba67d81923d5a8a5d1c5289"
},
"werkzeug-3.1.8-py3-none-any.whl": {
"size": 226459,
"sha256": "63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50"
},
"idna-3.13-py3-none-any.whl": {
"size": 68629,
"sha256": "892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3"
},
"python_dotenv-1.2.2-py3-none-any.whl": {
"size": 22101,
"sha256": "1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"
},
"flask_marshmallow-1.5.0-py3-none-any.whl": {
"size": 12161,
"sha256": "99951c77e5654111ed733811c6dc9310bfb4c3688c78a9e76f80b5ae0b2279a6"
},
"markupsafe-3.0.3-cp314-cp314-win_amd64.whl": {
"size": 15341,
"sha256": "bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"
},
"flask_cors-6.0.2-py3-none-any.whl": {
"size": 13257,
"sha256": "e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a"
},
"flask_caching-2.4.0-py3-none-any.whl": {
"size": 28727,
"sha256": "d15b8135f055c4f28f6f7dbcf8d36a3de4af1224def975ae6e0b43cbfa684486"
},
"tzdata-2026.3-py2.py3-none-any.whl": {
"size": 348168,
"sha256": "dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931"
},
"charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl": {
"size": 159634,
"sha256": "92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"
},
"sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl": {
"size": 2144204,
"sha256": "77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5"
},
"marshmallow-4.3.0-py3-none-any.whl": {
"size": 49148,
"sha256": "46c4fe6984707e3cbd485dfebbf0a59874f58d695aad05c1668d15e8c6e13b46"
},
"dnspython-2.8.0-py3-none-any.whl": {
"size": 331094,
"sha256": "01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"
},
"typing_extensions-4.15.0-py3-none-any.whl": {
"size": 44614,
"sha256": "f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"
},
"flask-3.1.3-py3-none-any.whl": {
"size": 103424,
"sha256": "f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c"
},
"tabulate-0.10.0-py3-none-any.whl": {
"size": 39814,
"sha256": "f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3"
},
"mysql_connector_python-9.7.0-cp314-cp314-win_amd64.whl": {
"size": 18197576,
"sha256": "5a5abbc152bc28cb2e64a04605ecd9941eff6b0dc5f9528cb84adb873e9a1e49"
},
"Flask_Migrate-4.1.0-py3-none-any.whl": {
"size": 21237,
"sha256": "24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d"
},
"cryptography-50.0.0-cp311-abi3-win_amd64.whl": {
"size": 3840395,
"sha256": "bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30"
},
"click-8.3.3-py3-none-any.whl": {
"size": 110502,
"sha256": "a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613"
}
}
},
"python": {
"required": true,
"files": {
"python-3.14.6-amd64.exe": {
"size": 30774112,
"sha256": "14b3e9a710a3fcf0bd9b55ab6b60412bd91227563f813fc49040cabc0209e0bd"
}
}
},
"urlrewrite": {
"required": false,
"files": {
"rewrite_amd64_en-US.msi": {
"size": 6078464,
"sha256": "37342ff2f585f263f34f48e9de59eb1051d61015a8e967dbde4075716230a32a"
}
}
},
"httpplatformhandler": {
"required": true,
"files": {
"httpPlatformHandler_amd64.msi": {
"size": 557056,
"sha256": "90f8d4905a0ab4f2c95223b3c79e2807a0b74507747d240e43c4302e8db4b5ef"
}
}
}
}
}

View File

@@ -0,0 +1,233 @@
<#
.SYNOPSIS
Hash manifest for the installer's third-party payload: create it, and check a
bundle against it.
.DESCRIPTION
Dot-source this. It defines two functions and runs nothing on its own:
New-BundleLock hash a staged bundle and return the lock object
Test-BundleLock compare a staged bundle against a lock, return problems
WHAT IT COVERS, and why pip's own hash checking is not enough.
requirements.txt carries a sha256 for every wheel, so pip refuses an artifact
upstream did not publish. Three gaps remain, and all three are what actually
goes wrong with a hand-assembled offline bundle:
1. pip lists EVERY artifact of a pinned version - cffi 2.1.0 alone has 100
hashes. It proves the wheel is genuine, not that it is the wheel this
bundle was built and tested with.
2. pip ignores extra files in the wheelhouse. A stale wheel left behind by
a previous build sits there unnoticed until a resolve picks it up.
3. pip says nothing about the rest of the payload - the Python installer,
the HttpPlatformHandler MSI, URL Rewrite, MySQL. Those are executables
that run as SYSTEM on the target server and were, until this file, the
only unverified thing the installer would run.
So the lock records an exact file set with a sha256 and a byte size each, and
verification is SET EQUALITY: a missing file, an unexpected extra file, or a
changed file all fail. Nothing is skipped and nothing is "close enough".
The app tree is deliberately NOT covered. It is built from the repository on
every run and changes with every commit; hashing it would make the lock churn
constantly and train everyone to regenerate it without reading it. Git is the
record for the app tree. This file is the record for everything that comes
from outside the repository.
.NOTES
Stock Windows PowerShell 5.1, and also runs under pwsh on Linux so the Bash
builder can call the same checker.
#>
# Payload directories under the bundle root. 'required' means the installer
# cannot work without it; the optional ones are per-deployment choices, and an
# absent optional directory is fine. A PRESENT directory is always checked in
# full, optional or not.
$script:BundlePayloads = @(
@{ Name = 'wheels'; Required = $true; What = 'Python wheels for the offline install' }
@{ Name = 'python'; Required = $true; What = 'the Python installer' }
@{ Name = 'httpplatformhandler'; Required = $true; What = 'the IIS module that launches waitress' }
@{ Name = 'urlrewrite'; Required = $false; What = 'IIS URL Rewrite, for the client-IP rule' }
@{ Name = 'mysqlclient'; Required = $false; What = 'mysql/mysqldump, for backups against a remote database' }
@{ Name = 'mysql'; Required = $false; What = 'MySQL, for the bundled-database option' }
)
function Get-JsonProperty {
# shopdb-install.ps1 runs under Set-StrictMode 2.0, where reading a property
# that does not exist on a PSCustomObject THROWS instead of returning $null.
# A truncated or hand-edited bundle-lock.json would therefore blow up with
# "Property 'payloads' cannot be found" rather than saying what is wrong with
# the lock. Every read of parsed JSON goes through here.
param($Object, [string] $Name, $Default = $null)
if ($null -eq $Object) { return $Default }
$prop = $Object.PSObject.Properties[$Name]
if ($null -eq $prop) { return $Default }
return $prop.Value
}
function Get-FileDigest {
param([string] $Path)
$sha = [System.Security.Cryptography.SHA256]::Create()
$stream = [System.IO.File]::OpenRead($Path)
try { return (-join ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') })) }
finally { $stream.Dispose(); $sha.Dispose() }
}
function Get-PayloadFiles {
# Every file in the directory, keyed by its path RELATIVE to that directory
# with forward slashes, so a lock generated on Windows reads the same from
# the Bash builder.
param([string] $Dir)
$out = @{}
if (-not (Test-Path $Dir)) { return $out }
$root = (Resolve-Path $Dir).Path.TrimEnd('\', '/')
foreach ($f in (Get-ChildItem $Dir -Recurse -File)) {
$rel = $f.FullName.Substring($root.Length).TrimStart('\', '/').Replace('\', '/')
$out[$rel] = @{ sha256 = (Get-FileDigest $f.FullName); size = $f.Length }
}
return $out
}
function New-BundleLock {
<#
Hash a staged bundle. The caller writes the result to bundle-lock.json;
this returns the object so a caller can diff it against the committed lock
before overwriting anything.
#>
param(
[Parameter(Mandatory = $true)] [string] $BundleRoot,
[string] $PythonTag = 'cp314',
[string] $Platform = 'win_amd64'
)
$payloads = @{}
foreach ($p in $script:BundlePayloads) {
$dir = Join-Path $BundleRoot $p.Name
if (-not (Test-Path $dir)) {
if ($p.Required) { throw ("payload directory is missing: {0} ({1})" -f $p.Name, $p.What) }
continue
}
$files = Get-PayloadFiles $dir
if ($files.Count -eq 0 -and $p.Required) {
throw ("payload directory is empty: {0} ({1})" -f $p.Name, $p.What)
}
$payloads[$p.Name] = @{ required = $p.Required; files = $files }
}
return [ordered]@{
schema = 1
generated = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
pythontag = $PythonTag
platform = $Platform
note = 'Exact third-party payload of the installer bundle. Regenerate with refresh-bundle-lock.ps1 and COMMIT the change as a reviewed dependency bump.'
payloads = $payloads
}
}
function Test-BundleLock {
<#
Compare a staged bundle against a lock. Returns an array of problem
strings - EMPTY means the bundle is exactly what the lock describes.
Returning problems rather than throwing is deliberate: an operator fixing a
wheelhouse wants the whole list at once, not one failure per rebuild.
#>
param(
[Parameter(Mandatory = $true)] [string] $BundleRoot,
[Parameter(Mandatory = $true)] $Lock
)
$problems = @()
$payloads = Get-JsonProperty $Lock 'payloads'
if ($null -eq $payloads) { return @('bundle-lock.json has no "payloads" section') }
foreach ($p in $script:BundlePayloads) {
$name = $p.Name
$dir = Join-Path $BundleRoot $name
$present = Test-Path $dir
$entry = Get-JsonProperty $payloads $name
$locked = ($null -ne $entry)
if (-not $locked) {
# Not in the lock at all. An unlocked directory that exists is a
# payload nobody reviewed, which is exactly what this is here to stop.
if ($present) { $problems += "$name/ is present but is not in bundle-lock.json - regenerate the lock" }
elseif ($p.Required) { $problems += "$name/ is required but is in neither the bundle nor the lock" }
continue
}
if (-not $present) {
if ($p.Required -or (Get-JsonProperty $entry 'required' $false)) {
$problems += "$name/ is in the lock but missing from the bundle ($($p.What))"
}
continue
}
$expected = @{}
$lockedFiles = Get-JsonProperty $entry 'files'
if ($null -ne $lockedFiles) {
foreach ($prop in $lockedFiles.PSObject.Properties) { $expected[$prop.Name] = $prop.Value }
}
$actual = Get-PayloadFiles $dir
# Sorted so the report reads the same way twice, and so it matches the
# order verify_bundle_lock.py produces.
foreach ($rel in ($expected.Keys | Sort-Object)) {
if (-not $actual.ContainsKey($rel)) { $problems += "$name/$rel is in the lock but missing from the bundle"; continue }
if ($actual[$rel].sha256 -ne $expected[$rel].sha256) {
$problems += "$name/$rel does NOT match the lock (expected sha256 $($expected[$rel].sha256.Substring(0,12))..., got $($actual[$rel].sha256.Substring(0,12))...)"
} elseif ([int64] $actual[$rel].size -ne [int64] $expected[$rel].size) {
# Cannot happen for a matching sha256, so it means the lock itself
# was hand-edited. Say so rather than passing it.
$problems += "$name/$rel size disagrees with the lock - the lock has been edited by hand"
}
}
foreach ($rel in ($actual.Keys | Sort-Object)) {
if (-not $expected.ContainsKey($rel)) { $problems += "$name/$rel is in the bundle but NOT in the lock (unexpected extra file)" }
}
}
$problems += Test-WheelhouseCoversRequirements -BundleRoot $BundleRoot
return $problems
}
function Test-WheelhouseCoversRequirements {
<#
The lock records what IS in the wheelhouse, not what the application NEEDS.
Without this an incomplete wheelhouse gets locked, blessed, and shipped,
and the install fails on an air-gapped server.
Not hypothetical: assembling the wheelhouse anywhere other than Windows
silently omits colorama, a win32-only dependency of click, because pip
evaluates environment markers against the machine doing the downloading
rather than the machine being targeted. Markers are therefore IGNORED here
- a requirement guarded by sys_platform == 'win32' is precisely the one
that has to be present.
#>
param([Parameter(Mandatory = $true)] [string] $BundleRoot)
$wheels = Join-Path $BundleRoot 'wheels'
$reqs = Join-Path $BundleRoot 'app\requirements.txt'
if (-not (Test-Path $wheels) -or -not (Test-Path $reqs)) { return @() }
$have = @(Get-ChildItem $wheels -File -ErrorAction SilentlyContinue | ForEach-Object { $_.Name.ToLower() })
$problems = @()
$pins = @{}
foreach ($line in (Get-Content $reqs)) {
$trimmed = $line.Trim()
if (-not $trimmed -or $trimmed.StartsWith('#')) { continue }
if ($trimmed -match '^([A-Za-z0-9._-]+)==([^\s;\\]+)') {
# PEP 427 wheel filename form: runs of non-alphanumerics become one _.
$pins[([regex]::Replace($Matches[1], '[^A-Za-z0-9.]+', '_')).ToLower()] = $Matches[2]
}
}
foreach ($name in ($pins.Keys | Sort-Object)) {
$prefix = "$name-$($pins[$name])-"
if (-not ($have | Where-Object { $_.StartsWith($prefix) })) {
$problems += ("wheels/ has no wheel for {0}=={1}, which requirements.txt pins " +
"(a marked-out dependency still installs on Windows)") -f $name, $pins[$name]
}
}
return $problems
}
function Read-BundleLock {
param([Parameter(Mandatory = $true)] [string] $Path)
if (-not (Test-Path $Path)) { return $null }
return (Get-Content $Path -Raw | ConvertFrom-Json)
}

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Generate Inno Setup wizard artwork from the app's own brand assets.
Everything here is derived from frontend/public/*.svg so the installer and the
running application are visibly the same product. Nothing is redrawn by hand.
Inno stretches artwork to fit and does not resample well, so render at the exact
sizes it asks for and supply the 125%/250% variants for high-DPI displays.
WizardImageFile 164x314, 192x386, 384x772
WizardSmallImageFile 55x55, 64x64, 138x138
SetupIconFile .ico with 16/24/32/48/64/128/256
Usage: python3 make-branding.py [output-dir]
"""
import io
import sys
from pathlib import Path
import cairosvg
from PIL import Image, ImageDraw, ImageFont
ASSETS = Path.home() / "projects/shopdb-flask/frontend/public"
OUT = Path(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).parent)
# Sampled from the application's own palette so the installer does not look like
# a different product wearing the same badge.
NAVY = (10, 34, 74) # deep base
BLUE = (16, 74, 150) # GE blue
CYAN = (0, 158, 224) # accent
WHITE = (255, 255, 255)
MUTED = (176, 197, 226)
def render_svg(name, width=None, height=None):
png = cairosvg.svg2png(url=str(ASSETS / name), output_width=width, output_height=height)
return Image.open(io.BytesIO(png)).convert("RGBA")
def recolour(img, colour):
"""Replace RGB while keeping the alpha mask. Source marks are dark-on-light;
on a dark panel they must be inverted or they disappear."""
solid = Image.new("RGBA", img.size, colour + (255,))
solid.putalpha(img.getchannel("A"))
return solid
def font(size, bold=False):
for path in (
f"/usr/share/fonts/truetype/dejavu/DejaVuSans{'-Bold' if bold else ''}.ttf",
f"/usr/share/fonts/truetype/liberation/LiberationSans{'-Bold' if bold else '-Regular'}.ttf",
):
if Path(path).exists():
return ImageFont.truetype(path, size)
return ImageFont.load_default()
def vertical_gradient(size, top, bottom):
w, h = size
img = Image.new("RGB", size)
draw = ImageDraw.Draw(img)
for y in range(h):
t = y / max(1, h - 1)
# Ease the ramp so the middle does not look flat.
t = t * t * (3 - 2 * t)
draw.line(
[(0, y), (w, y)],
fill=tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3)),
)
return img
def banner(w, h):
img = vertical_gradient((w, h), BLUE, NAVY)
draw = ImageDraw.Draw(img)
k = w / 164.0 # scale factor from the 100% design
# Faint diagonal wash: stops the flat area under the text reading as empty.
glow = Image.new("RGBA", (w, h), (0, 0, 0, 0))
gd = ImageDraw.Draw(glow)
gd.polygon([(0, int(h * 0.52)), (w, int(h * 0.30)), (w, h), (0, h)],
fill=(255, 255, 255, 10))
img = Image.alpha_composite(img.convert("RGBA"), glow).convert("RGB")
draw = ImageDraw.Draw(img)
margin = int(22 * k)
# GE Aerospace wordmark at the top, above the product name - the corporate
# mark leads, the product sits under it. (Previously the bare monogram was
# here and the wordmark was stranded at the bottom.)
mark_w = w - (margin * 2)
mark = render_svg("ge-aerospace-logo.svg", mark_w, int(mark_w * 32 / 138))
mark = recolour(mark, WHITE)
img.paste(mark, (margin, int(34 * k)), mark)
# Product name, directly beneath it.
y = int(34 * k) + mark.height + int(30 * k)
draw.text((margin, y), "ShopDB", font=font(int(26 * k), bold=True), fill=WHITE)
y += int(31 * k)
# Hairline rule, then the descriptor. Cheap way to look considered.
draw.rectangle([margin, y, margin + int(30 * k), y + max(1, int(2 * k))], fill=CYAN)
y += int(14 * k)
for line in ("Asset management", "for the shop floor"):
draw.text((margin, y), line, font=font(int(10.5 * k)), fill=MUTED)
y += int(15 * k)
# Accent bar flush to the bottom edge.
bar = max(2, int(4 * k))
draw.rectangle([0, h - bar, w, h], fill=CYAN)
return img
def small(size):
"""Header mark on every page after the welcome page. White plate so it sits
correctly on the wizard's own header, in light or dark mode."""
img = Image.new("RGB", (size, size), WHITE)
m = int(size * 0.80)
mono = recolour(render_svg("ge-monogram.svg", m, m), BLUE)
off = (size - m) // 2
img.paste(mono, (off, off), mono)
return img
def icon(path):
"""Installer icon. Rounded navy tile with the monogram, so it reads at 16px
instead of turning into mush."""
base = 256
img = Image.new("RGBA", (base, base), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.rounded_rectangle([0, 0, base - 1, base - 1], radius=int(base * 0.22), fill=BLUE + (255,))
d.rounded_rectangle([0, 0, base - 1, int(base * 0.5)], radius=int(base * 0.22),
fill=(30, 96, 175, 255))
d.rounded_rectangle([0, int(base * 0.3), base - 1, base - 1], radius=int(base * 0.22),
fill=BLUE + (255,))
m = int(base * 0.62)
mono = recolour(render_svg("ge-monogram.svg", m, m), WHITE)
img.paste(mono, ((base - m) // 2, (base - m) // 2), mono)
img.save(path, sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64),
(128, 128), (256, 256)])
def main():
OUT.mkdir(parents=True, exist_ok=True)
made = []
for w, h, name in [(164, 314, "wizard-image.bmp"),
(192, 386, "wizard-image@125.bmp"),
(384, 772, "wizard-image@250.bmp")]:
banner(w, h).save(OUT / name, "BMP"); made.append((name, f"{w}x{h}"))
for s, name in [(55, "wizard-small.bmp"), (64, "wizard-small@125.bmp"),
(138, "wizard-small@250.bmp")]:
small(s).save(OUT / name, "BMP"); made.append((name, f"{s}x{s}"))
icon(OUT / "shopdb.ico"); made.append(("shopdb.ico", "multi-size"))
for name, dims in made:
print(f" {name:<26} {dims:<10} {(OUT / name).stat().st_size // 1024} KB")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,110 @@
<#
.SYNOPSIS
Regenerate bundle-lock.json from the staged bundle, after showing what would
change.
.DESCRIPTION
Run this on the machine that assembled the wheelhouse, once the bundle holds
the payload you intend to ship:
bundle\wheels\ the wheels, built with the matching Python
bundle\python\ the Python installer
bundle\httpplatformhandler\ the IIS module MSI
bundle\urlrewrite\ URL Rewrite MSI (optional)
bundle\mysql\ MySQL MSI (optional)
Then COMMIT the resulting bundle-lock.json. That commit is the review: it is
the only place a change to what runs as SYSTEM on a customer's server becomes
visible to a human. A lock regenerated and committed without reading the diff
provides nothing, so this refuses to overwrite an existing lock until you
have seen the change and passed -Yes.
.EXAMPLE
.\refresh-bundle-lock.ps1 # show the diff, write nothing
.\refresh-bundle-lock.ps1 -Yes # write it
.NOTES
Building the wheelhouse itself no longer requires Windows. From any machine:
pip download -r requirements.txt -d wheels --only-binary=:all: `
--platform win_amd64 --python-version 314 --implementation cp --abi cp314
Do it wherever you like; this script records what came out.
#>
[CmdletBinding()]
param(
[string] $BundleRoot = (Join-Path $PSScriptRoot 'bundle'),
[string] $LockPath = (Join-Path $PSScriptRoot 'bundle-lock.json'),
[string] $PythonTag = 'cp314',
[string] $Platform = 'win_amd64',
[switch] $Yes
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'bundle-lock.ps1')
function Say { param($m, $c = 'Gray') Write-Host $m -ForegroundColor $c }
if (-not (Test-Path $BundleRoot)) {
Say "bundle not found: $BundleRoot" 'Red'
Say 'Stage it first with build-installer.ps1 (or build-installer.sh), then add' 'Yellow'
Say 'the wheels and installers by hand.' 'Yellow'
exit 1
}
Say ''
Say " Hashing $BundleRoot" 'Cyan'
$new = New-BundleLock -BundleRoot $BundleRoot -PythonTag $PythonTag -Platform $Platform
foreach ($name in $new.payloads.Keys) {
Say (" {0,-22} {1,4} files" -f $name, $new.payloads[$name].files.Count)
}
$old = Read-BundleLock $LockPath
if (-not $old) {
Say ''
Say ' No existing lock - this will be the first one.' 'Yellow'
} else {
# Diff by file, per payload, so the operator sees exactly which artifacts
# changed rather than "the lock is different".
Say ''
Say ' Changes against the committed lock:' 'Cyan'
$changes = 0
foreach ($name in $new.payloads.Keys) {
$oldFiles = @{}
if ($old.payloads.PSObject.Properties.Name -contains $name) {
foreach ($p in $old.payloads.$name.files.PSObject.Properties) { $oldFiles[$p.Name] = $p.Value.sha256 }
}
$newFiles = $new.payloads[$name].files
foreach ($rel in ($newFiles.Keys | Sort-Object)) {
if (-not $oldFiles.ContainsKey($rel)) { Say " + $name/$rel" 'Green'; $changes++ }
elseif ($oldFiles[$rel] -ne $newFiles[$rel].sha256) { Say " ~ $name/$rel (content changed)" 'Yellow'; $changes++ }
}
foreach ($rel in ($oldFiles.Keys | Sort-Object)) {
if (-not $newFiles.ContainsKey($rel)) { Say " - $name/$rel" 'Red'; $changes++ }
}
}
foreach ($p in $old.payloads.PSObject.Properties.Name) {
if (-not $new.payloads.Contains($p)) { Say " - $p/ (whole payload gone)" 'Red'; $changes++ }
}
if ($changes -eq 0) {
Say ' none - the bundle already matches the lock' 'Green'
exit 0
}
Say ''
Say (" {0} change(s)." -f $changes) 'White'
}
if (-not $Yes) {
Say ''
Say ' Nothing written. Read the list above, then re-run with -Yes.' 'Yellow'
Say ' Commit the resulting bundle-lock.json - that commit IS the review.' 'Yellow'
exit 2
}
# ConvertTo-Json defaults to a depth of 2, which silently flattens the per-file
# entries into "System.Collections.Hashtable" strings and produces a lock that
# verifies against nothing.
$new | ConvertTo-Json -Depth 8 | Set-Content -Path $LockPath -Encoding UTF8
Say ''
Say " Written: $LockPath" 'Green'
Say ' Commit it.' 'Green'

View File

@@ -0,0 +1,816 @@
<#
.SYNOPSIS
Day-to-day control of a ShopDB-Flask installation, on the server itself.
.DESCRIPTION
Installed alongside the application so an operator never has to open IIS
Manager, hunt for a log, or remember an appcmd incantation.
Run it with no arguments for a menu, or pass a command directly:
.\shopdb-admin.ps1 status
.\shopdb-admin.ps1 restart
.\shopdb-admin.ps1 backup D:\backups
Everything here is safe to run at any time EXCEPT backup/restore, which are
called out explicitly.
.NOTES
Written for stock Windows PowerShell 5.1. No modules to install.
#>
[CmdletBinding()]
param(
[ValidateSet('menu','status','start','stop','restart','logs','open','backup',
'check','sessions','plugins','add-plugin','verify','uninstall')]
[string] $Command = 'menu',
[string] $Path = '',
# Machine-readable output for 'check'. The people running this are expected to
# ask an AI assistant for help, and pasting a screenshot of a console into a
# chat window loses most of what matters. One structured blob they can paste
# gives the assistant real state to reason about instead of guesses.
[switch] $Json,
[string] $AppRoot = 'C:\shopdb-flask',
[string] $SiteName = 'shopdb-flask',
[string] $AppPool = 'shopdbflask',
[int] $SitePort = 8090
)
$ErrorActionPreference = 'Continue'
$AppCmd = Join-Path $env:windir 'System32\inetsrv\appcmd.exe'
# --- bitness ---------------------------------------------------------------
# IIS's management COM objects are 64-BIT ONLY. Under the 32-bit PowerShell,
# Import-Module WebAdministration SUCCEEDS but Get-Website then fails with
# Retrieving the COM class factory ... REGDB_E_CLASSNOTREG
# which this script caught and reported as "cannot read IIS" - indistinguishable
# from IIS being absent.
#
# A 32-bit launcher is easy to end up with: Inno Setup is a 32-bit process, so
# anything it starts gets SysWOW64 powershell through WOW64 redirection. Rather
# than fix every launcher, relaunch under the native PowerShell. 'Sysnative' is
# the alias that lets a 32-bit process reach the real System32, and it exists
# ONLY for 32-bit processes - hence the guard.
# Every relaunch below must carry the ORIGINAL arguments through. They used to be
# dropped, so a console started from the Start Menu with -AppRoot D:\shopdb
# relaunched itself with the C:\shopdb-flask default and reported a healthy
# install as missing - and the operator had done nothing wrong.
function Get-ForwardedArgs {
$forward = @('-NoProfile', '-ExecutionPolicy', 'Bypass',
'-File', ('"' + $PSCommandPath + '"'), $Command)
if ($Path) { $forward += @('-Path', ('"' + $Path + '"')) }
if ($AppRoot) { $forward += @('-AppRoot', ('"' + $AppRoot + '"')) }
if ($SiteName) { $forward += @('-SiteName', ('"' + $SiteName + '"')) }
if ($AppPool) { $forward += @('-AppPool', ('"' + $AppPool + '"')) }
if ($SitePort) { $forward += @('-SitePort', $SitePort) }
if ($Json) { $forward += '-Json' }
return $forward
}
if ([Environment]::Is64BitOperatingSystem -and -not [Environment]::Is64BitProcess) {
$native = Join-Path $env:windir 'Sysnative\WindowsPowerShell\v1.0\powershell.exe'
if (Test-Path $native) {
Start-Process -FilePath $native -ArgumentList (Get-ForwardedArgs) -Wait -NoNewWindow
return
}
}
# --- elevation -------------------------------------------------------------
# Reading IIS state needs Administrator: the WebAdministration module and the
# IIS: drive both fail without it. Unelevated, this tool used to report
# "IIS not available / application pool: not installed", which reads as "your
# install is broken" when it actually means "I cannot see it". Relaunch elevated
# instead of reporting a false state.
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
# 'open' just launches a browser. Prompting for administrator to do that trains
# people to click through UAC, and the Start Menu shortcut uses this command.
if ($Command -ne 'open' -and
-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Host ''
Write-Host ' Administrator rights are needed to read IIS state.' -ForegroundColor Yellow
Write-Host ' Re-launching elevated - approve the prompt.' -ForegroundColor Yellow
$argList = @('-NoExit') + (Get-ForwardedArgs)
try {
Start-Process -FilePath 'powershell.exe' -ArgumentList $argList -Verb RunAs | Out-Null
} catch {
Write-Host ''
Write-Host ' Elevation was declined.' -ForegroundColor Red
Write-Host ' Right-click the shortcut and choose "Run as administrator".' -ForegroundColor Red
Read-Host ' Press Enter to close' | Out-Null
}
return
}
# --- brand header ----------------------------------------------------------
# Deliberately typographic, NOT ASCII art. The GE monogram is fine cursive
# linework; rendered as block characters at console resolution it reads as noise,
# which looks worse than no mark at all. A clean rule and correct wordmark says
# "considered"; mushy art says the opposite.
#
# Box-drawing characters used here are all in code page 437 (the Windows console
# default), and this file is saved UTF-8 WITH BOM so PowerShell 5.1 reads them
# correctly rather than assuming the ANSI code page.
function Show-Banner {
$rule = ([string][char]0x2500) * 58
Write-Host ''
Write-Host ' GE AEROSPACE' -ForegroundColor Blue
Write-Host (' ' + $rule) -ForegroundColor DarkGray
Write-Host ' ShopDB-Flask' -ForegroundColor White
Write-Host ' Asset management for the shop floor' -ForegroundColor DarkGray
Write-Host ''
}
function Say { param($m, $c = 'Gray') Write-Host $m -ForegroundColor $c }
function Head {
param($m)
Write-Host ''
Write-Host (' ' + $m) -ForegroundColor Cyan
Write-Host (' ' + (([string][char]0x2500) * $m.Length)) -ForegroundColor DarkGray
}
function Get-EnvValue {
param([string] $Key)
$envFile = Join-Path $AppRoot '.env'
if (-not (Test-Path $envFile)) { return '' }
$line = Get-Content $envFile | Where-Object { $_ -like "$Key=*" } | Select-Object -First 1
if ($line) { return $line.Substring($Key.Length + 1) }
return ''
}
function Get-Deployment {
<#
Which way was this installed?
Method A: its own IIS site on $SitePort.
Method B: an IIS Application under an existing site, at /<alias>, reached
on that site's port. MOUNT_PATH in .env is what distinguishes
them - it is the same value wsgi.py uses to mount the app.
Without this the console looked for a SITE that method B never creates and
reported "web site: not installed" on a perfectly healthy server, then
probed the wrong port and said it was not responding.
#>
$mount = (Get-EnvValue 'MOUNT_PATH').Trim()
if (-not $mount) {
return @{ Subpath = $false; Alias = ''; BaseUrl = "http://localhost:$SitePort"; Port = $SitePort }
}
$alias = $mount.Trim('/')
$port = 80
$parent = 'Default Web Site'
try {
Import-Module WebAdministration -ErrorAction Stop
foreach ($site in (Get-Website)) {
$app = Get-WebApplication -Site $site.Name -Name $alias -ErrorAction SilentlyContinue
if ($app) {
$parent = $site.Name
$b = $site.bindings.Collection | Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1
if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $port = [int]$Matches[1] }
break
}
}
} catch { }
$base = "http://localhost:$port/$alias"
if ($port -eq 80) { $base = "http://localhost/$alias" }
return @{ Subpath = $true; Alias = $alias; Parent = $parent; BaseUrl = $base; Port = $port }
}
function Get-DbParts {
# Pull host/port/name/user out of DATABASE_URL without printing the password.
$url = Get-EnvValue 'DATABASE_URL'
if ($url -match '://([^:]+):([^@]*)@([^:/]+):(\d+)/([^?]+)') {
# The password is PERCENT-ENCODED in DATABASE_URL (the installer applies
# [uri]::EscapeDataString). SQLAlchemy unescapes it; so must we. Without
# this, every command here fails to authenticate whenever the password
# contains a space, @, %, ! or /, and the console reports the database as
# unreachable on a server where the application is running perfectly.
return @{ User = [uri]::UnescapeDataString($Matches[1])
Pass = [uri]::UnescapeDataString($Matches[2])
Host = $Matches[3]; Port = $Matches[4]; Name = $Matches[5] }
}
return $null
}
function Find-MysqlClient {
$candidates = @(
'C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql.exe',
'C:\mysql56\bin\mysql.exe'
) + @(Get-ChildItem 'C:\Program Files\MySQL' -Filter mysql.exe -Recurse -EA SilentlyContinue |
Select-Object -ExpandProperty FullName)
foreach ($c in $candidates) { if ($c -and (Test-Path $c)) { return $c } }
return ''
}
# --------------------------------------------------------------------------
function Show-Status {
Head 'Status'
$deploy = Get-Deployment
$siteState = 'not installed'
$poolState = 'not installed'
try {
Import-Module WebAdministration -ErrorAction Stop
if ($deploy.Subpath) {
# Method B: there is no site of our own - we are an Application.
$app = Get-WebApplication -Site $deploy.Parent -Name $deploy.Alias -ErrorAction SilentlyContinue
if ($app) {
$parentSite = Get-Website -Name $deploy.Parent -ErrorAction SilentlyContinue
$siteState = ("/{0} under '{1}' ({2})" -f $deploy.Alias, $deploy.Parent,
$(if ($parentSite) { $parentSite.State } else { 'unknown' }))
}
} else {
$site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
if ($site) { $siteState = $site.State }
}
if (Test-Path "IIS:\AppPools\$AppPool") { $poolState = (Get-Item "IIS:\AppPools\$AppPool").State }
} catch {
# Should be unreachable now that the script self-elevates, but if IIS is
# genuinely absent say THAT, rather than implying ShopDB is missing.
# Name the real cause instead of implying ShopDB is missing.
if (-not (Get-Service W3SVC -ErrorAction SilentlyContinue)) {
$siteState = 'IIS is not installed on this server'
} else {
$siteState = 'could not read IIS - ' + $_.Exception.Message
}
$poolState = $siteState
}
$colour = if (($siteState -eq 'Started') -or ($siteState -like '*Started*')) { 'Green' } else { 'Yellow' }
Say (" published as : {0}" -f $siteState) $colour
Say (" application pool: {0}" -f $poolState) $colour
Say (" installed at : {0}" -f $(if (Test-Path $AppRoot) { $AppRoot } else { 'not found' }))
# Does it actually answer? A "Started" pool proves nothing. Under method B
# this must include the mount path, or it tests a URL that never existed.
$url = $deploy.BaseUrl + '/'
try {
$r = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 20
Say (" responding : yes (HTTP {0})" -f $r.StatusCode) 'Green'
} catch {
Say ' responding : NO' 'Red'
Say (" {0}" -f $_.Exception.Message) 'DarkGray'
}
$db = Get-DbParts
if ($db) {
Say (" database : {0} on {1}:{2} as {3}" -f $db.Name, $db.Host, $db.Port, $db.User)
$mysql = Find-MysqlClient
if ($mysql) {
$q = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$($db.Name)';"
$n = $q | & $mysql "-u$($db.User)" "-p$($db.Pass)" "-h$($db.Host)" "-P$($db.Port)" -N 2>$null
if ($LASTEXITCODE -eq 0) { Say (" tables : {0}" -f $n) 'Green' }
else { Say ' tables : could not connect' 'Red' }
}
} else { Say ' database : no .env found' 'Yellow' }
# Is anyone set up yet?
try {
$na = Invoke-WebRequest -Uri ($deploy.BaseUrl + '/api/setup/needs-admin') -UseBasicParsing -TimeoutSec 15
if ($na.Content -match '"needsadmin"\s*:\s*true') {
Say ' first run : NOT SET UP - open the site to create the first administrator' 'Yellow'
} else { Say ' first run : complete' 'Green' }
} catch { }
Say ''
if ($deploy.Subpath) {
$shown = "http://{0}/{1}/login" -f $env:COMPUTERNAME, $deploy.Alias
if ($deploy.Port -ne 80) { $shown = "http://{0}:{1}/{2}/login" -f $env:COMPUTERNAME, $deploy.Port, $deploy.Alias }
} else {
$shown = "http://{0}:{1}/login" -f $env:COMPUTERNAME, $SitePort
}
Say (" open with : {0}" -f $shown) 'White'
}
function Start-App {
Head 'Starting'
$deploy = Get-Deployment
& $AppCmd start apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" }
if (-not $deploy.Subpath) {
& $AppCmd start site /site.name:$SiteName 2>&1 | ForEach-Object { Say " $_" }
} else {
# Method B shares the parent site - starting or stopping THAT would take
# every other application on this server with it.
Say (" (published under '{0}' - only the application pool is ours to control)" -f $deploy.Parent) 'DarkGray'
}
Say ' started' 'Green'
}
function Stop-App {
Head 'Stopping'
$deploy = Get-Deployment
Say ' ShopDB-Flask will be unavailable until you start it again.' 'Yellow'
if (-not $deploy.Subpath) {
& $AppCmd stop site /site.name:$SiteName 2>&1 | ForEach-Object { Say " $_" }
} else {
Say (" (leaving site '{0}' running - stopping it would take down everything else on it)" -f $deploy.Parent) 'DarkGray'
}
& $AppCmd stop apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" }
Say ' stopped' 'Yellow'
}
function Restart-App {
Head 'Restarting'
# Recycle rather than stop/start: it drains existing requests instead of
# cutting them off, and it is what a config change actually needs.
& $AppCmd recycle apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" }
Start-Sleep -Seconds 2
try {
$r = Invoke-WebRequest -Uri ((Get-Deployment).BaseUrl + '/') -UseBasicParsing -TimeoutSec 30
Say (" back up (HTTP {0})" -f $r.StatusCode) 'Green'
} catch { Say ' did not respond after restart - run: shopdb-admin.ps1 logs' 'Red' }
}
function Show-Logs {
Head 'Recent logs'
$appLog = Join-Path $AppRoot 'logs'
if (Test-Path $appLog) {
$newest = Get-ChildItem "$appLog\*.log" -EA SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($newest) {
Say (" application: {0}" -f $newest.FullName) 'White'
Get-Content $newest.FullName -Tail 20 | ForEach-Object { Say " $_" }
} else { Say ' no application log yet' }
}
$inst = 'C:\ProgramData\ShopDB-Flask\logs'
if (Test-Path $inst) {
$newest = Get-ChildItem "$inst\*.log" -EA SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($newest) {
Say ''
Say (" install: {0}" -f $newest.FullName) 'White'
Get-Content $newest.FullName -Tail 12 | ForEach-Object { Say " $_" }
}
}
}
function Open-Site {
# The installer records the address it actually published at. Prefer it: this
# command runs unelevated, and .env is ACL'd, so working the address out from
# MOUNT_PATH and IIS may not be possible from here.
$recorded = Join-Path $AppRoot '.installed-url'
if (Test-Path $recorded) {
$u = (Get-Content $recorded -TotalCount 1).Trim()
if ($u) { Start-Process $u; Say " opened $u" 'Green'; return }
}
$deploy = Get-Deployment
if ($deploy.Subpath) {
if ($deploy.Port -eq 80) { $u = "http://{0}/{1}/login" -f $env:COMPUTERNAME, $deploy.Alias }
else { $u = "http://{0}:{1}/{2}/login" -f $env:COMPUTERNAME, $deploy.Port, $deploy.Alias }
} else {
$u = "http://{0}:{1}/login" -f $env:COMPUTERNAME, $SitePort
}
Start-Process $u
Say " opened $u" 'Green'
}
function Backup-Db {
param([string] $Dest)
Head 'Database backup'
$db = Get-DbParts
if (-not $db) { Say ' no .env found - cannot determine the database' 'Red'; return }
if (-not $Dest) { $Dest = 'C:\ProgramData\ShopDB-Flask\backups' }
if (-not (Test-Path $Dest)) { New-Item -ItemType Directory -Path $Dest -Force | Out-Null }
$mysql = Find-MysqlClient
if (-not $mysql) { Say ' mysql client not found' 'Red'; return }
$dump = Join-Path (Split-Path $mysql -Parent) 'mysqldump.exe'
if (-not (Test-Path $dump)) { Say ' mysqldump not found' 'Red'; return }
$file = Join-Path $Dest ("shopdb_flask-{0}.sql" -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
Say (" writing {0}" -f $file)
# Redirect rather than --result-file: keeps it working on 5.6 and 8.0 alike.
# Do NOT pipe mysqldump through the PowerShell pipeline into Out-File.
# PowerShell 5.1 writes a UTF-8 BOM and re-encodes the stream through the
# console code page, producing a dump MySQL refuses to load and mangling any
# non-ASCII data - discovered only when the backup is finally needed.
# Redirect the process's stdout straight to the file instead.
$args = @("-u$($db.User)", "-p$($db.Pass)", "-h$($db.Host)", "-P$($db.Port)",
'--single-transaction', '--routines', '--triggers', $db.Name)
$quoted = $args | ForEach-Object {
if ($_ -match '\s' -and $_ -notmatch '^".*"$') { '"' + $_ + '"' } else { $_ }
}
$err = [System.IO.Path]::GetTempFileName()
try {
$proc = Start-Process -FilePath $dump -ArgumentList $quoted -Wait -PassThru -NoNewWindow `
-RedirectStandardOutput $file -RedirectStandardError $err
if ($proc.ExitCode -ne 0) {
Say (" mysqldump failed (exit {0})" -f $proc.ExitCode) 'Red'
Get-Content $err -Tail 3 -EA SilentlyContinue | ForEach-Object { Say (" " + $_) 'DarkGray' }
return
}
} finally { Remove-Item $err -Force -ErrorAction SilentlyContinue }
# Verify it is complete rather than merely present.
$tail = @(Get-Content $file -Tail 5 -ErrorAction SilentlyContinue)
if ((Test-Path $file) -and ((Get-Item $file).Length -gt 1024) -and ($tail -match 'Dump completed')) {
Say (" done - {0:N1} MB, verified complete" -f ((Get-Item $file).Length / 1MB)) 'Green'
# The dump is NOT everything. Uploaded branding, map blueprints and
# generated files live in instance\ on disk, not in the database, so a
# restore from the .sql alone comes back with no floor map. Saying "all of
# your asset data" was true and misleading at the same time.
$instance = Join-Path $AppRoot 'instance'
if (Test-Path $instance) {
$zip = [System.IO.Path]::ChangeExtension($file, $null) + 'instance.zip'
try {
Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue
if (Test-Path $zip) { Remove-Item $zip -Force }
[System.IO.Compression.ZipFile]::CreateFromDirectory($instance, $zip)
Say (" uploaded files: {0:N1} MB -> {1}" -f ((Get-Item $zip).Length / 1MB), (Split-Path $zip -Leaf)) 'Green'
} catch {
Say (" could not archive instance\: {0}" -f $_.Exception.Message) 'Yellow'
Say ' copy it by hand - it holds branding and floor-map images' 'Yellow'
}
}
Say ''
Say ' Store BOTH files off this server. The .sql holds the records; the' 'Yellow'
Say ' .zip holds uploaded branding and floor-map images, which the' 'Yellow'
Say ' database does not. A restore needs both.' 'Yellow'
Say ''
Say ' Contains user password hashes - treat it as sensitive.' 'Yellow'
} else {
Say ' backup is empty or truncated - do NOT rely on it' 'Red'
Remove-Item $file -Force -ErrorAction SilentlyContinue
}
}
function Get-CheckState {
<#
Everything an outside reader needs to reason about this server, gathered
without changing anything. Secrets are NEVER included: the database password
lives in DATABASE_URL and this reports the host, port, name and user only.
#>
$deploy = Get-Deployment
$db = Get-DbParts
$state = [ordered]@{
collected = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
computername = $env:COMPUTERNAME
approot = $AppRoot
installed = (Test-Path (Join-Path $AppRoot 'shopdb\__init__.py'))
version = ''
publishedas = if ($deploy.Subpath) { "subpath /$($deploy.Alias) under '$($deploy.Parent)'" } else { "own site '$SiteName' on port $SitePort" }
baseurl = $deploy.BaseUrl
apppool = 'unknown'
poolstate = 'unknown'
sitestate = 'unknown'
responding = $false
httpstatus = 0
database = $null
pythonversion = ''
plugins = @()
sbomcomponents = 0
errors = @()
}
foreach ($pair in @(@('version', '.installed-version'))) {
$f = Join-Path $AppRoot $pair[1]
if (Test-Path $f) { $state[$pair[0]] = (Get-Content $f -TotalCount 1).Trim() }
}
$state.apppool = $AppPool
try {
Import-Module WebAdministration -ErrorAction Stop
if (Test-Path "IIS:\AppPools\$AppPool") { $state.poolstate = (Get-Item "IIS:\AppPools\$AppPool").State.ToString() }
if ($deploy.Subpath) {
$app = Get-WebApplication -Site $deploy.Parent -Name $deploy.Alias -ErrorAction SilentlyContinue
$state.sitestate = if ($app) { 'application present' } else { 'application MISSING' }
} else {
$s = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
$state.sitestate = if ($s) { $s.State.ToString() } else { 'site MISSING' }
}
} catch { $state.errors += "IIS: $($_.Exception.Message)" }
try {
$r = Invoke-WebRequest -Uri ($deploy.BaseUrl + '/') -UseBasicParsing -TimeoutSec 20
$state.responding = $true
$state.httpstatus = [int] $r.StatusCode
} catch { $state.errors += "HTTP: $($_.Exception.Message)" }
if ($db) {
$state.database = [ordered]@{ host = $db.Host; port = $db.Port; name = $db.Name; user = $db.User; reachable = $false }
$mysql = Find-MysqlClient
if ($mysql) {
$q = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$($db.Name)';"
$n = $q | & $mysql "-u$($db.User)" "-p$($db.Pass)" "-h$($db.Host)" "-P$($db.Port)" -N 2>$null
if ($LASTEXITCODE -eq 0) { $state.database.reachable = $true; $state.database.tables = [int] $n }
} else { $state.errors += 'no mysql client found, database not probed' }
} else { $state.errors += 'no .env found' }
$py = Join-Path $AppRoot 'venv\Scripts\python.exe'
if (Test-Path $py) { $state.pythonversion = (& $py -c "import sys; print('%d.%d.%d' % sys.version_info[:3])" 2>$null | Select-Object -First 1) }
$dir = Join-Path $AppRoot 'plugins'
if (Test-Path $dir) {
$state.plugins = @(Get-ChildItem $dir -Directory -EA SilentlyContinue |
Where-Object { Test-Path (Join-Path $_.FullName 'manifest.json') } |
Select-Object -ExpandProperty Name)
}
$sbom = Join-Path $AppRoot 'sbom.cdx.json'
if (Test-Path $sbom) {
try { $state.sbomcomponents = (Get-Content $sbom -Raw | ConvertFrom-Json).components.Count } catch { }
}
return $state
}
function Invoke-Check {
if ($Json) {
# ONLY the JSON goes to stdout, so it can be redirected to a file or piped
# without a banner in the middle of the document.
Get-CheckState | ConvertTo-Json -Depth 6
return
}
Head 'Health check'
$flask = Join-Path $AppRoot 'venv\Scripts\flask.exe'
if (-not (Test-Path $flask)) { Say ' application not installed' 'Red'; return }
Push-Location $AppRoot
$env:FLASK_APP = 'shopdb'
try { & $flask db-utils preflight 2>&1 | ForEach-Object { Say " $_" } }
finally { Pop-Location }
Say ''
Say ' For help from an AI assistant, paste the output of:' 'DarkGray'
Say ' shopdb-admin.ps1 check -Json' 'White'
}
function Show-Sessions {
Head 'Worker processes'
$w = Get-CimInstance Win32_Process -Filter "Name='w3wp.exe'" -EA SilentlyContinue
if (-not $w) { Say ' no IIS worker running (the site starts one on first request)' }
else {
foreach ($p in $w) {
Say (" pid {0} {1:N0} MB started {2}" -f $p.ProcessId,
($p.WorkingSetSize/1MB), $p.CreationDate)
}
}
$py = Get-CimInstance Win32_Process -Filter "Name='python.exe'" -EA SilentlyContinue |
Where-Object { $_.CommandLine -like "*$AppRoot*" }
if ($py) { foreach ($p in $py) { Say (" python pid {0} {1:N0} MB" -f $p.ProcessId, ($p.WorkingSetSize/1MB)) } }
}
# Set by every Invoke-Flask call. Callers MUST test this rather than
# $LASTEXITCODE: when flask.exe is missing no native command runs at all, so
# $LASTEXITCODE keeps whatever value it had from something earlier - which reads
# as success and made a command that did nothing report that it had worked.
$script:LastFlaskExit = 0
function Invoke-Flask {
param([string[]] $Arguments)
$flask = Join-Path $AppRoot 'venv\Scripts\flask.exe'
if (-not (Test-Path $flask)) {
Say ' application not installed' 'Red'
$script:LastFlaskExit = 127
return $null
}
Push-Location $AppRoot
$env:FLASK_APP = 'shopdb'
# The app logs plugin startup to STDERR even on success; with EAP=Stop that
# becomes a terminating error and a healthy command looks like a failure.
$prev = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
& $flask @Arguments 2>&1
$script:LastFlaskExit = $LASTEXITCODE
}
finally { $ErrorActionPreference = $prev; Pop-Location }
}
function Show-Plugins {
Head 'Plugins'
Invoke-Flask @('plugin','list') | ForEach-Object { Say " $_" }
# What is on disk but NOT installed can still be added here. What is absent
# from disk cannot - see the note below.
$dir = Join-Path $AppRoot 'plugins'
if (Test-Path $dir) {
$onDisk = (Get-ChildItem $dir -Directory -EA SilentlyContinue |
Where-Object { Test-Path (Join-Path $_.FullName 'manifest.json') } |
Select-Object -ExpandProperty Name) -join ', '
Say ''
Say (" shipped in this build : {0}" -f $onDisk) 'White'
}
Say ''
Say ' Add one that is shipped : shopdb-admin.ps1 add-plugin -Path <name>' 'White'
Say ''
Say ' A plugin NOT listed above is not on this server at all. This build was' 'DarkGray'
Say ' made for your site''s chosen plugin set, so its code was never shipped.' 'DarkGray'
Say ' Adding one means a new installer built from an updated site profile.' 'DarkGray'
}
function Add-Plugin {
param([string] $Name)
Head 'Add a plugin'
if (-not $Name) { Say ' usage: shopdb-admin.ps1 add-plugin -Path <plugin-name>' 'Yellow'; return }
$dir = Join-Path $AppRoot ('plugins\' + $Name)
if (-not (Test-Path (Join-Path $dir 'manifest.json'))) {
Say (" '{0}' is not present on this server." -f $Name) 'Red'
Say ''
Say ' This build ships only the plugins your site chose. Adding a new one' 'Yellow'
Say ' requires a new installer built from an updated site profile - the' 'Yellow'
Say ' code is not here to install.' 'Yellow'
Say ''
Say ' Run "shopdb-admin.ps1 plugins" to see what IS available.' 'White'
return
}
# apply-profile, not plugin install. Five plugins ship default_enabled=false,
# so `install` alone left them installed-but-disabled: the command printed a
# green success line and the feature did not appear anywhere in the UI.
# apply-profile installs AND enables, and pulls in the dependency closure.
Say (" adding {0}..." -f $Name)
$profilePath = Join-Path $AppRoot 'site-profile.json'
$applied = $false
if (Test-Path $profilePath) {
try {
$profile = Get-Content $profilePath -Raw | ConvertFrom-Json
$wanted = @($profile.plugins)
if ($wanted -notcontains $Name) { $wanted += $Name }
$profile.plugins = $wanted
$profile | ConvertTo-Json -Depth 6 | Set-Content -Path $profilePath -Encoding UTF8
Invoke-Flask @('plugin','apply-profile',$profilePath) | ForEach-Object { Say " $_" }
$applied = ($script:LastFlaskExit -eq 0)
} catch { Say (" could not update site-profile.json: {0}" -f $_.Exception.Message) 'Yellow' }
}
if (-not $applied) {
# No profile on disk, or apply-profile failed: fall back, but enable
# explicitly so the outcome is the same either way.
Invoke-Flask @('plugin','install',$Name) | ForEach-Object { Say " $_" }
$installed = ($script:LastFlaskExit -eq 0)
Invoke-Flask @('plugin','enable',$Name) | ForEach-Object { Say " $_" }
$applied = $installed -and ($script:LastFlaskExit -eq 0)
}
if (-not $applied) {
# Do NOT print the green line on a failure. It used to be unconditional,
# so a failed add reported success and the operator went looking for a
# feature that was never enabled.
Say (" {0} was NOT added - see the output above" -f $Name) 'Red'
return
}
Say ' applying its database migrations...'
Invoke-Flask @('plugin','upgrade-all') | ForEach-Object { Say " $_" }
if ($script:LastFlaskExit -ne 0) {
Say ' migrations FAILED - the feature is installed but its tables are not' 'Red'
Say ' do not use it until this is resolved; restore a backup if needed' 'Red'
return
}
Say ' restarting so its routes register...'
Restart-App
Say (" {0} added" -f $Name) 'Green'
}
function Invoke-Verify {
<#
Prove the installed dependencies are still the ones that shipped.
Two independent records, both written at install time:
requirements.txt a sha256 for every wheel, checked by pip on install
bundle-lock.json the exact third-party payload of the bundle
This re-checks what can still be checked on a live server. It reports; it
changes nothing.
#>
Head 'Verify'
$lock = Join-Path $AppRoot 'bundle-lock.json'
if (Test-Path $lock) {
try {
$l = Get-Content $lock -Raw | ConvertFrom-Json
Say (" installed from : {0} bundle ({1}, {2})" -f $l.generated, $l.pythontag, $l.platform)
} catch { Say ' bundle-lock.json is present but unreadable' 'Yellow' }
} else {
Say ' no bundle-lock.json recorded (installed before payload locking, or by hand)' 'Yellow'
}
# The SBOM travels with the application, because a server on a vaulted
# network cannot be scanned from anywhere else. When a CVE lands, this is
# what answers "is that component here, and at what version" without needing
# the build box, the internet, or anyone's memory.
$sbom = Join-Path $AppRoot 'sbom.cdx.json'
if (Test-Path $sbom) {
try {
$b = Get-Content $sbom -Raw | ConvertFrom-Json
$shipped = @($b.components | Where-Object { $_.scope -eq 'required' }).Count
Say (" components : {0} ({1} shipped), CycloneDX {2}" -f `
$b.components.Count, $shipped, $b.specVersion)
Say (" bill of materials: {0}" -f $sbom) 'DarkGray'
Say ' search it with : shopdb-admin.ps1 verify -Path <name>' 'DarkGray'
} catch { Say ' sbom.cdx.json is present but unreadable' 'Yellow' }
# A named component turns this into the actual question being asked.
# Guarded on $b: an unreadable SBOM leaves it unset, and querying it then
# would report 'none', which reads as "you are not affected".
if ($Path -and $b) {
Say ''
Say (" matches for '{0}':" -f $Path) 'Cyan'
$hits = @($b.components | Where-Object { $_.name -like ('*' + $Path + '*') })
if (-not $hits) { Say ' none - this server does not carry it' 'Green' }
foreach ($h in $hits) {
$tag = if ($h.scope -eq 'required') { 'SHIPPED' } else { 'build only' }
Say (" {0,-40} {1,-14} {2}" -f $h.name, $h.version, $tag) `
$(if ($h.scope -eq 'required') { 'Yellow' } else { 'DarkGray' })
}
}
} else {
Say ' no SBOM recorded (installed before SBOMs shipped, or by hand)' 'Yellow'
}
# pip's own audit. It re-reads the metadata of what is actually installed and
# reports anything missing or version-inconsistent, which is the part that
# can still drift after install - a hand-run `pip install` on the server.
$py = Join-Path $AppRoot 'venv\Scripts\python.exe'
if (-not (Test-Path $py)) { Say ' no venv - application not installed' 'Red'; return }
Say ''
Say ' checking installed packages against requirements.txt...'
$req = Join-Path $AppRoot 'requirements.txt'
if (-not (Test-Path $req)) { Say ' requirements.txt is missing from the install' 'Red'; return }
$wanted = @{}
foreach ($line in (Get-Content $req)) {
if ($line -match '^([A-Za-z0-9._-]+)==([^ \\]+)') { $wanted[$Matches[1].ToLower().Replace('_','-')] = $Matches[2] }
}
$frozen = @{}
foreach ($line in (& $py -m pip freeze 2>$null)) {
if ($line -match '^([A-Za-z0-9._-]+)==(.+)$') { $frozen[$Matches[1].ToLower().Replace('_','-')] = $Matches[2] }
}
$bad = 0
foreach ($name in ($wanted.Keys | Sort-Object)) {
if (-not $frozen.ContainsKey($name)) {
Say (" MISSING {0} {1}" -f $name, $wanted[$name]) 'Red'; $bad++
} elseif ($frozen[$name] -ne $wanted[$name]) {
Say (" DIFFERS {0} {1} installed, {2} expected" -f $name, $frozen[$name], $wanted[$name]) 'Red'; $bad++
}
}
if ($bad -eq 0) {
Say (" all {0} packages match" -f $wanted.Count) 'Green'
} else {
Say ''
Say (" {0} package(s) do not match what shipped." -f $bad) 'Red'
Say ' Something was installed or upgraded on this server by hand. Re-run the' 'Yellow'
Say ' installer to put the shipped set back.' 'Yellow'
}
& $py -m pip check 2>&1 | ForEach-Object { Say " $_" 'DarkGray' }
}
function Show-Uninstall {
Head 'Uninstall'
Say ' Use Settings > Apps > ShopDB-Flask, or Add/Remove Programs.'
Say ''
Say ' That removes the web site, application pool, firewall rule and files.'
Say ' It does NOT drop the database and does NOT uninstall MySQL.' 'Yellow'
Say ' Take a backup first: shopdb-admin.ps1 backup' 'Yellow'
}
function Show-Menu {
$first = $true
while ($true) {
if ($first) { Show-Banner; $first = $false }
Show-Status
Write-Host ''
Write-Host ' 1 Restart the application 6 Back up the database' -ForegroundColor White
Write-Host ' 2 Stop the application 7 Worker processes' -ForegroundColor White
Write-Host ' 3 Start the application 8 Open in browser' -ForegroundColor White
Write-Host ' 4 View recent logs 9 Plugins' -ForegroundColor White
Write-Host ' 5 Health check V Verify this install' -ForegroundColor White
Write-Host ' 0 Exit' -ForegroundColor White
Write-Host ''
$c = Read-Host ' Choose'
switch ($c) {
'1' { Restart-App } '2' { Stop-App } '3' { Start-App }
'4' { Show-Logs } '5' { Invoke-Check } '6' { Backup-Db $Path }
'7' { Show-Sessions } '8' { Open-Site }
'v' { Invoke-Verify } 'V' { Invoke-Verify }
'9' { Show-Plugins
$add = Read-Host ' Name of a shipped plugin to add (Enter to skip)'
if ($add) { Add-Plugin $add } }
'0' { return }
default { Say ' not a choice' 'Yellow' }
}
Write-Host ''
Read-Host ' Press Enter to continue' | Out-Null
Clear-Host
Show-Banner
}
}
switch ($Command) {
'status' { Show-Banner; Show-Status }
'start' { Start-App }
'stop' { Stop-App }
'restart' { Restart-App }
'logs' { Show-Logs }
'open' { Open-Site }
'backup' { Backup-Db $Path }
'check' { Invoke-Check }
'sessions' { Show-Sessions }
'plugins' { Show-Plugins }
'add-plugin'{ Add-Plugin $Path }
'verify' { Invoke-Verify }
'uninstall' { Show-Uninstall }
default { Show-Menu }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,547 @@
<#
.SYNOPSIS
ShopDB-Flask installer - Stage 1: read-only preflight.
.DESCRIPTION
Discovers everything the installer needs to know about this box and reports
it. Makes NO changes: no installs, no config edits, no service restarts.
Safe to run on a production server.
Written for stock Windows PowerShell 5.1 (Windows Server ships it). No
pwsh-only syntax, no external modules, no network access.
.PARAMETER SitePort
The port the ShopDB site will listen on. Default 8090 (the runbook's example;
the classic ASP site keeps 8080).
.PARAMETER AppRoot
Intended install directory. Default C:\shopdb-flask.
.PARAMETER Json
Emit machine-readable JSON instead of the human report. Later installer
stages consume this.
.EXAMPLE
powershell -ExecutionPolicy Bypass -File .\shopdb-preflight.ps1
powershell -ExecutionPolicy Bypass -File .\shopdb-preflight.ps1 -Json > preflight.json
.NOTES
Exit 0 = no blocking problems. Exit 1 = at least one FAIL.
#>
[CmdletBinding()]
param(
[int] $SitePort = 8090,
[string] $AppRoot = 'C:\shopdb-flask',
# Needed so the port check can tell OUR site apart from a stranger's.
[string] $SiteName = 'shopdb-flask',
[switch] $Json,
# Machine-readable output for a GUI caller: one record per line,
# STATUS|AREA|CHECK|DETAIL|FIX
# The console rendering below aligns columns with padding spaces, which only
# works in a fixed-width font at console width. A GUI must do its own layout,
# so give it DATA and let it decide - do not make it parse a formatted table.
# (-Json exists too, but Inno's Pascal Script has no JSON parser.)
[switch] $Delimited
)
Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'
# --- result collection -------------------------------------------------------
# Every check appends one record. Status is PASS / WARN / FAIL / INFO.
$script:Results = New-Object System.Collections.ArrayList
$script:IisPresent = $false
function Add-Result {
param(
[string] $Area,
[string] $Check,
[ValidateSet('PASS','WARN','FAIL','INFO','SKIP')] [string] $Status,
[string] $Detail,
[string] $Fix = ''
)
$null = $script:Results.Add([PSCustomObject]@{
Area = $Area
Check = $Check
Status = $Status
Detail = $Detail
Fix = $Fix
})
}
# Wrap a check so one failure cannot abort the whole run. On an unfamiliar box
# an unexpected exception is itself a finding, not a crash.
function Invoke-Check {
param([string] $Area, [string] $Check, [scriptblock] $Body)
try { & $Body }
catch {
Add-Result $Area $Check 'WARN' "check could not run: $($_.Exception.Message)" `
'Report this output; the installer needs to handle this box shape.'
}
}
# =============================================================================
# 1. Operator context
# =============================================================================
Invoke-Check 'System' 'Elevation' {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
$adm = (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
if ($adm) { Add-Result 'System' 'Elevation' 'PASS' 'running as Administrator' }
else {
Add-Result 'System' 'Elevation' 'FAIL' 'not elevated' `
'Re-run PowerShell as Administrator. IIS and service changes require it.'
}
}
Invoke-Check 'System' 'Windows version' {
$os = Get-CimInstance Win32_OperatingSystem
$name = $os.Caption
$ver = $os.Version
# ProductType: 1 = workstation, 2 = domain controller, 3 = server
$isServer = ($os.ProductType -ne 1)
$detail = "$name (build $ver), $(if ($isServer) {'Server'} else {'Client'})"
$supported = $false
if ($isServer -and [version]$ver -ge [version]'10.0.17763') { $supported = $true } # 2019+
if (-not $isServer -and [version]$ver -ge [version]'10.0.19045') { $supported = $true } # Win10 22H2+
if ($supported) { Add-Result 'System' 'Windows version' 'PASS' $detail }
else {
Add-Result 'System' 'Windows version' 'FAIL' $detail `
'Supported: Windows Server 2019/2022+, or Windows 10 22H2 / 11 Pro+.'
}
# Client SKUs must be Pro/Enterprise/Education for IIS.
if (-not $isServer -and $name -match 'Home') {
Add-Result 'System' 'Windows edition' 'FAIL' 'Windows Home edition' `
'IIS is not available on Home editions. Pro or higher is required.'
}
}
Invoke-Check 'System' 'Architecture' {
if ([Environment]::Is64BitOperatingSystem) {
Add-Result 'System' 'Architecture' 'PASS' '64-bit'
} else {
Add-Result 'System' 'Architecture' 'FAIL' '32-bit' `
'The bundled Python and wheels are 64-bit (win_amd64) only.'
}
}
Invoke-Check 'System' 'PowerShell version' {
$v = $PSVersionTable.PSVersion
Add-Result 'System' 'PowerShell version' 'INFO' "$v"
if ($v.Major -lt 5) {
Add-Result 'System' 'PowerShell version' 'FAIL' "$v" `
'PowerShell 5.1 or later is required.'
}
}
# =============================================================================
# 2. Disk and ports
# =============================================================================
Invoke-Check 'Disk' 'Free space' {
$drive = (Split-Path -Qualifier $AppRoot)
$d = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='$drive'"
if ($null -eq $d) {
Add-Result 'Disk' 'Free space' 'FAIL' "drive $drive not found" `
"Choose an -AppRoot on an existing volume."
return
}
$freeGB = [math]::Round($d.FreeSpace / 1GB, 1)
$detail = "$freeGB GB free on $drive"
if ($freeGB -ge 5) { Add-Result 'Disk' 'Free space' 'PASS' $detail }
else { Add-Result 'Disk' 'Free space' 'FAIL' $detail 'At least 5 GB is required.' }
}
Invoke-Check 'Disk' 'AppRoot' {
if (Test-Path $AppRoot) {
$existing = @(Get-ChildItem $AppRoot -Force -ErrorAction SilentlyContinue)
if ($existing.Count -gt 0) {
$hasEnv = Test-Path (Join-Path $AppRoot '.env')
if ($hasEnv) {
# An existing install is the NORMAL state for an upgrade. Reporting
# it as a warning makes a routine update look like a problem.
$ver = ''
$vf = Join-Path $AppRoot '.installed-version'
if (Test-Path $vf) { $ver = ' version ' + (Get-Content $vf -TotalCount 1).Trim() }
Add-Result 'Disk' 'AppRoot' 'INFO' `
"existing ShopDB-Flask install found$ver - it will be upgraded in place, and your settings and database are kept"
} else {
Add-Result 'Disk' 'AppRoot' 'WARN' "$AppRoot exists and is not empty" `
'Confirm this directory is safe to install into.'
}
} else { Add-Result 'Disk' 'AppRoot' 'PASS' "$AppRoot exists and is empty" }
} else { Add-Result 'Disk' 'AppRoot' 'PASS' "$AppRoot does not exist yet" }
}
function Test-PortFree {
param([int] $Port)
# Get-NetTCPConnection is the reliable listener check on Server 2012R2+.
try {
$listening = @(Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue)
return ($listening.Count -eq 0)
} catch {
# Fall back to a bind attempt if the cmdlet is unavailable.
try {
$l = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Any, $Port)
$l.Start(); $l.Stop(); return $true
} catch { return $false }
}
}
Invoke-Check 'Network' 'Site port' {
if (Test-PortFree $SitePort) {
Add-Result 'Network' 'Site port' 'PASS' "TCP $SitePort is free"
} else {
$owner = ''
try {
$c = Get-NetTCPConnection -State Listen -LocalPort $SitePort -ErrorAction SilentlyContinue | Select-Object -First 1
if ($c) { $owner = " (pid $($c.OwningProcess): $((Get-Process -Id $c.OwningProcess -ErrorAction SilentlyContinue).ProcessName))" }
} catch { }
# Is the listener OUR OWN site? On a reinstall or upgrade the port is held
# by the very application being upgraded, and blocking on that makes the
# installer refuse to update anything it previously installed.
$ours = $false
try {
Import-Module WebAdministration -ErrorAction SilentlyContinue
$site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
if ($site) {
foreach ($b in $site.bindings.Collection) {
# ${} is required: "$SitePort:" parses as a DRIVE-qualified variable.
if ($b.bindingInformation -match ":${SitePort}:") { $ours = $true }
}
}
} catch { }
if ($ours) {
Add-Result 'Network' 'Site port' 'INFO' `
"TCP $SitePort is used by the existing $SiteName site - this will be upgraded in place"
} else {
Add-Result 'Network' 'Site port' 'FAIL' "TCP $SitePort is in use$owner" `
"Choose a different port with -SitePort, or stop the listener."
}
}
}
# =============================================================================
# 3. IIS
# =============================================================================
Invoke-Check 'IIS' 'Installed' {
$svc = Get-Service -Name W3SVC -ErrorAction SilentlyContinue
$script:IisPresent = ($null -ne $svc)
if ($null -eq $svc) {
Add-Result 'IIS' 'Installed' 'FAIL' 'W3SVC service not found' `
'Install IIS. Server: Install-WindowsFeature Web-Server -IncludeManagementTools. Client: enable Internet Information Services in Windows Features.'
return
}
Add-Result 'IIS' 'Installed' 'PASS' "W3SVC present, status $($svc.Status)"
if ($svc.Status -ne 'Running') {
Add-Result 'IIS' 'Running' 'WARN' "W3SVC is $($svc.Status)" 'Start-Service W3SVC'
}
}
Invoke-Check 'IIS' 'WebAdministration module' {
$m = Get-Module -ListAvailable -Name WebAdministration
if ($m) { Add-Result 'IIS' 'WebAdministration module' 'PASS' 'available' }
else {
Add-Result 'IIS' 'WebAdministration module' 'FAIL' 'not available' `
'Install the IIS management tools (Web-Mgmt-Console / IIS Management Scripts and Tools).'
}
}
Invoke-Check 'IIS' 'HttpPlatformHandler' {
# The handler registers itself as a global module. Check the module list.
$appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe'
if (-not (Test-Path $appcmd)) {
Add-Result 'IIS' 'HttpPlatformHandler' 'SKIP' 'appcmd.exe not present (IIS not installed)' `
'Re-run this preflight after installing IIS.'
return
}
$modules = & $appcmd list module 2>$null
if ($modules -match 'httpPlatformHandler') {
Add-Result 'IIS' 'HttpPlatformHandler' 'PASS' 'installed'
} else {
Add-Result 'IIS' 'HttpPlatformHandler' 'FAIL' 'not installed' `
'Install httpPlatformHandler_amd64.msi from the bundle. IIS cannot launch waitress without it.'
}
}
Invoke-Check 'IIS' 'Locked config sections' {
# The authoritative source is applicationHost.config. `appcmd list config
# /section:X` prints the section CONTENTS, not its lock state, so grepping
# that output silently reports every section as unlocked.
$cfg = Join-Path $env:windir 'system32\inetsrv\config\applicationHost.config'
if (-not (Test-Path $cfg)) {
Add-Result 'IIS' 'Locked config sections' 'SKIP' 'applicationHost.config not found'
return
}
foreach ($name in @('handlers','httpPlatform')) {
$line = Select-String -Path $cfg -Pattern ('<section name="' + $name + '"') |
Select-Object -First 1
if ($null -eq $line) {
# httpPlatform is registered by the HttpPlatformHandler MSI. Before
# that, `appcmd unlock config /section:system.webServer/httpPlatform`
# fails with "Unknown config section".
Add-Result 'IIS' "Section $name" 'SKIP' 'section not registered yet' `
'Install HttpPlatformHandler FIRST; only then can this section be unlocked.'
} elseif ($line.Line -match 'overrideModeDefault="Deny"') {
Add-Result 'IIS' "Section $name" 'WARN' 'locked (overrideModeDefault="Deny")' `
"Installer must run: appcmd unlock config /section:system.webServer/$name (else IIS 500.19)"
} else {
Add-Result 'IIS' "Section $name" 'PASS' 'not locked'
}
}
}
Invoke-Check 'IIS' 'URL Rewrite module' {
$appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe'
if (-not (Test-Path $appcmd)) {
Add-Result 'IIS' 'URL Rewrite module' 'SKIP' 'IIS not installed; cannot check'
return
}
$modules = & $appcmd list module 2>$null
if ($modules -match 'RewriteModule') {
Add-Result 'IIS' 'URL Rewrite module' 'PASS' 'installed (the X-Forwarded-For rule can be enabled)'
} else {
# Not a FAIL: it is only needed for -ClientIpSource direct, and the
# installer carries the MSI and installs it itself. Worth reporting
# because without the rule IIS sends no X-Forwarded-For at all, so every
# client reads as 127.0.0.1 and the GE-Enforce IP allowlist, the
# visitor-location lookup and per-host login rate limiting go quiet.
Add-Result 'IIS' 'URL Rewrite module' 'INFO' 'not installed' `
'The installer installs it from the bundle when -ClientIpSource is direct. Behind a reverse proxy that already sets X-Forwarded-For, use -ClientIpSource proxy and leave it out.'
}
}
Invoke-Check 'IIS' 'Existing sites' {
if (-not $script:IisPresent) {
Add-Result 'IIS' 'Existing sites' 'SKIP' 'IIS not installed'
return
}
try {
Import-Module WebAdministration -ErrorAction Stop
$sites = @(Get-Website)
if ($sites.Count -eq 0) { Add-Result 'IIS' 'Existing sites' 'INFO' 'none' ; return }
$desc = ($sites | ForEach-Object {
$b = ($_.bindings.Collection | ForEach-Object { $_.bindingInformation }) -join ','
"$($_.Name) [$($_.State)] $b"
}) -join '; '
Add-Result 'IIS' 'Existing sites' 'INFO' $desc
# Adoption sites typically run the classic ASP shopdb here already.
if ($desc -match '8080') {
Add-Result 'IIS' 'Classic ASP site' 'INFO' 'a site is bound on 8080 (likely the classic ASP shopdb)' `
'Install ShopDB as a separate site on its own port; do not disturb this one.'
}
} catch {
Add-Result 'IIS' 'Existing sites' 'WARN' "could not enumerate: $($_.Exception.Message)" ''
}
}
# =============================================================================
# 4. MySQL (detect BEFORE offering bundled vs existing)
# =============================================================================
Invoke-Check 'MySQL' 'Service' {
$svcs = @(Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '^MySQL' -or $_.DisplayName -match 'MySQL' })
if ($svcs.Count -eq 0) {
Add-Result 'MySQL' 'Service' 'INFO' 'no MySQL service found' `
'Bundled MySQL 8.0 is the appropriate choice on this box.'
return
}
foreach ($s in $svcs) {
Add-Result 'MySQL' 'Service' 'WARN' "$($s.Name) ($($s.DisplayName)) is $($s.Status)" `
'MySQL already present. Default to the EXISTING-server option; installing bundled MySQL will collide on port 3306.'
}
}
Invoke-Check 'MySQL' 'Port 3306' {
if (Test-PortFree 3306) {
Add-Result 'MySQL' 'Port 3306' 'INFO' 'nothing listening on 3306'
} else {
Add-Result 'MySQL' 'Port 3306' 'WARN' 'something is listening on 3306' `
'Bundled MySQL cannot use the default port. Use the existing server, or pick another port.'
}
}
Invoke-Check 'MySQL' 'Backup client' {
# mysqldump is what takes the mandatory pre-upgrade backup. Without it every
# upgrade skips the backup - and skips it AFTER the application pool has been
# stopped and the tree replaced, so the site is down and there is nothing to
# restore from. A site whose database is on another server typically has no
# MySQL client installed here at all, which is exactly the case that needs it.
$names = @('mysqldump.exe')
$found = ''
foreach ($root in @((Join-Path $PSScriptRoot 'mysqlclient'),
(Join-Path $AppRoot 'mysqlclient'),
'C:\Program Files\MySQL', 'C:\mysql56\bin',
'C:\Program Files (x86)\MySQL')) {
if (-not (Test-Path $root)) { continue }
$hit = Get-ChildItem $root -Filter $names[0] -Recurse -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($hit) { $found = $hit.FullName; break }
}
if ($found) {
Add-Result 'MySQL' 'Backup client' 'PASS' "mysqldump found ($found)"
} else {
Add-Result 'MySQL' 'Backup client' 'WARN' 'mysqldump not found on this server' `
'Needed for the automatic pre-upgrade backup and for "shopdb-admin.ps1 backup". A first install works without it; upgrades will not be protected. Add mysqlclient\ to the installer bundle, or install the MySQL client on this server.'
}
}
Invoke-Check 'MySQL' 'Version and config' {
# Find mysqld.exe via the service binary path; read the version and locate my.ini.
$svc = Get-CimInstance Win32_Service -ErrorAction SilentlyContinue |
Where-Object { $_.PathName -match 'mysqld' } | Select-Object -First 1
if ($null -eq $svc) { return }
$path = $svc.PathName
$exe = ''
if ($path -match '"([^"]+mysqld[^"]*)"') { $exe = $matches[1] }
elseif ($path -match '(\S+mysqld\S*)') { $exe = $matches[1] }
$ver = ''
if ($exe -and (Test-Path $exe)) {
try { $ver = (& $exe --version 2>$null | Out-String).Trim() } catch { }
}
if ($ver) { Add-Result 'MySQL' 'Version' 'INFO' $ver }
# my.ini path is passed as --defaults-file in the service command line.
$ini = ''
if ($path -match '--defaults-file="?([^"]+\.ini)"?') { $ini = $matches[1] }
if ($ini -and (Test-Path $ini)) {
Add-Result 'MySQL' 'Config file' 'INFO' $ini
# MySQL 5.6 needs three flags or `flask db upgrade` dies with error 1071.
$is56 = ($ver -match '\b5\.6\.')
if ($is56) {
$content = Get-Content $ini -Raw
$need = @('innodb_file_per_table','innodb_file_format','innodb_large_prefix')
$missing = @()
foreach ($k in $need) { if ($content -notmatch $k) { $missing += $k } }
if ($missing.Count -eq 0) {
# Present in the FILE is not the same as ACTIVE. MySQL must be
# restarted for them to take effect, and the app's own
# `flask db-utils preflight` queries the live server - trust that.
Add-Result 'MySQL' '5.6 index flags' 'WARN' 'all three present in my.ini' `
'Present in the file only. They take effect after a MySQL RESTART, which interrupts the classic ASP app. Confirm with SHOW VARIABLES or flask db-utils preflight.'
} else {
Add-Result 'MySQL' '5.6 index flags' 'FAIL' ("missing: " + ($missing -join ', ')) `
"Add to [mysqld] in $ini and restart MySQL, or 'flask db upgrade' fails with error 1071. NOTE: restarting interrupts the classic ASP app."
}
}
}
}
# =============================================================================
# 5. Python (detect, but the installer uses its OWN bundled interpreter)
# =============================================================================
Invoke-Check 'Python' 'On PATH' {
$cmd = Get-Command python -ErrorAction SilentlyContinue
if ($null -eq $cmd) {
Add-Result 'Python' 'On PATH' 'INFO' 'no python on PATH' `
'Expected. The installer supplies its own interpreter.'
return
}
$v = ''
try { $v = (& $cmd.Source --version 2>&1 | Out-String).Trim() } catch { }
$detail = "$v at $($cmd.Source)"
# A per-user install under %LOCALAPPDATA% is unreadable by the IIS app-pool
# identity. That produces a 500 with an empty HttpPlatform log.
if ($cmd.Source -like "$env:LOCALAPPDATA*") {
Add-Result 'Python' 'On PATH' 'WARN' "$detail (PER-USER install)" `
'The IIS app-pool identity cannot read %LOCALAPPDATA%. The installer must install Python for ALL USERS and use absolute paths.'
} elseif ($cmd.Source -like '*WindowsApps*') {
Add-Result 'Python' 'On PATH' 'WARN' "$detail (Microsoft Store)" `
'Store Python misbehaves under service identities. The installer will not use it.'
} else {
Add-Result 'Python' 'On PATH' 'INFO' $detail `
'Not used by the installer, but a manual `flask` command later would resolve to this interpreter.'
}
}
Invoke-Check 'Python' 'Registered installs' {
$found = @()
foreach ($hive in @('HKLM:\SOFTWARE\Python\PythonCore','HKCU:\SOFTWARE\Python\PythonCore')) {
if (Test-Path $hive) {
foreach ($k in Get-ChildItem $hive -ErrorAction SilentlyContinue) {
$ip = Join-Path $k.PSPath 'InstallPath'
if (Test-Path $ip) {
$loc = (Get-ItemProperty $ip -ErrorAction SilentlyContinue).'(default)'
$scope = if ($hive -like 'HKLM*') { 'all-users' } else { 'per-user' }
$found += "$($k.PSChildName) ($scope) $loc"
}
}
}
}
if ($found.Count -eq 0) { Add-Result 'Python' 'Registered installs' 'INFO' 'none' }
else { Add-Result 'Python' 'Registered installs' 'INFO' ($found -join '; ') }
}
# =============================================================================
# Report
# =============================================================================
$fails = @($script:Results | Where-Object { $_.Status -eq 'FAIL' })
$warns = @($script:Results | Where-Object { $_.Status -eq 'WARN' })
$skips = @($script:Results | Where-Object { $_.Status -eq 'SKIP' })
if ($Delimited) {
# Data only. No padding, no colour, no alignment - the caller lays it out.
# Pipes are stripped from field values so the record can be split naively.
foreach ($r in $script:Results) {
$fix = ''
if ($r.Fix) { $fix = $r.Fix }
$fields = @($r.Status, $r.Area, $r.Check, $r.Detail, $fix) | ForEach-Object {
([string]$_) -replace '\|', '/' -replace '\s*\r?\n\s*', ' '
}
Write-Output ($fields -join '|')
}
} elseif ($Json) {
[PSCustomObject]@{
Timestamp = (Get-Date).ToString('s')
Computer = $env:COMPUTERNAME
SitePort = $SitePort
AppRoot = $AppRoot
Failures = $fails.Count
Warnings = $warns.Count
Skipped = $skips.Count
Results = $script:Results
} | ConvertTo-Json -Depth 5
} else {
Write-Host ''
Write-Host 'ShopDB-Flask preflight' -ForegroundColor Cyan
Write-Host (" host {0} site port {1} approot {2}" -f $env:COMPUTERNAME, $SitePort, $AppRoot)
Write-Host ''
$area = ''
foreach ($r in $script:Results) {
if ($r.Area -ne $area) { $area = $r.Area; Write-Host "[$area]" -ForegroundColor White }
$colour = 'Gray'
if ($r.Status -eq 'PASS') { $colour = 'Green' }
if ($r.Status -eq 'WARN') { $colour = 'Yellow' }
if ($r.Status -eq 'FAIL') { $colour = 'Red' }
if ($r.Status -eq 'SKIP') { $colour = 'DarkGray' }
Write-Host (" {0,-5} {1,-28} {2}" -f $r.Status, $r.Check, $r.Detail) -ForegroundColor $colour
if ($r.Fix -and $r.Status -ne 'PASS' -and $r.Status -ne 'INFO' -and $r.Status -ne 'SKIP') {
Write-Host (" -> {0}" -f $r.Fix) -ForegroundColor DarkGray
}
}
Write-Host ''
if ($fails.Count -eq 0) {
Write-Host "No blocking problems. $($warns.Count) warning(s), $($skips.Count) skipped." -ForegroundColor Green
} else {
Write-Host "$($fails.Count) blocking problem(s), $($warns.Count) warning(s), $($skips.Count) skipped." -ForegroundColor Red
}
if ($skips.Count -gt 0) {
Write-Host " Skipped checks were NOT verified. Re-run once their prerequisite is installed." -ForegroundColor DarkGray
}
Write-Host ''
}
if ($fails.Count -gt 0) { exit 1 } else { exit 0 }

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 B

View File

@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Check a staged installer bundle against bundle-lock.json.
Prints one line per problem and exits non-zero if there are any. Exits 0 only
when the bundle's third-party payload is EXACTLY what the lock describes: no
missing file, no unexpected extra file, no changed content.
Why this exists alongside bundle-lock.ps1, which does the same job:
- bundle-lock.ps1 is canonical. It runs at INSTALL time on the target server,
where PowerShell is the only thing guaranteed to be present - Python is not
installed until stage 2, and verifying the payload after running part of it
would defeat the purpose.
- This file lets the Linux builder (build-installer.sh) do the same check
without adding pwsh as a build dependency.
The two are kept honest by tests/test_bundle_lock.py, which runs BOTH against
the same fixtures and fails if they disagree.
Usage: verify_bundle_lock.py <bundle-root> <bundle-lock.json>
"""
import hashlib
import json
import os
import re
import sys
# Must match $script:BundlePayloads in bundle-lock.ps1.
PAYLOADS = [
('wheels', True, 'Python wheels for the offline install'),
('python', True, 'the Python installer'),
('httpplatformhandler', True, 'the IIS module that launches waitress'),
('urlrewrite', False, 'IIS URL Rewrite, for the client-IP rule'),
('mysqlclient', False, 'mysql/mysqldump, for backups against a remote database'),
('mysql', False, 'MySQL, for the bundled-database option'),
]
def digest(path):
sha = hashlib.sha256()
with open(path, 'rb') as fh:
for chunk in iter(lambda: fh.read(1024 * 1024), b''):
sha.update(chunk)
return sha.hexdigest()
def payload_files(directory):
"""Every file under the directory, keyed by forward-slashed relative path."""
found = {}
if not os.path.isdir(directory):
return found
for root, _dirs, files in os.walk(directory):
for name in files:
full = os.path.join(root, name)
rel = os.path.relpath(full, directory).replace(os.sep, '/')
found[rel] = {'sha256': digest(full), 'size': os.path.getsize(full)}
return found
def normalize(name):
"""PEP 427 wheel filename form: runs of non-alphanumerics become one _."""
return re.sub(r'[^A-Za-z0-9.]+', '_', name).lower()
def requirement_pins(requirements_path):
"""Every 'name==version' pinned in a lockfile, including marked-out ones.
Markers are deliberately IGNORED. A requirement guarded by
sys_platform == 'win32' is exactly the case that must be present, because the
target is Windows and the wheelhouse is usually assembled somewhere else.
"""
pins = {}
with open(requirements_path) as fh:
for line in fh:
line = line.strip()
if not line or line.startswith('#'):
continue
match = re.match(r'^([A-Za-z0-9._-]+)==([^\s;\\]+)', line)
if match:
pins[normalize(match.group(1))] = match.group(2)
return pins
def check_wheelhouse_covers_requirements(bundle_root):
"""The lock records what IS in the wheelhouse, not what the app NEEDS.
Without this, an incomplete wheelhouse gets locked and blessed, and the
install fails on an air-gapped server. That is not hypothetical: assembling
the wheelhouse on Linux silently omits colorama, a win32-only dependency of
click, because pip evaluates environment markers against the machine doing
the downloading rather than the machine being targeted.
"""
wheels = os.path.join(bundle_root, 'wheels')
requirements = os.path.join(bundle_root, 'app', 'requirements.txt')
if not os.path.isdir(wheels) or not os.path.exists(requirements):
return []
have = os.listdir(wheels)
problems = []
for name, version in sorted(requirement_pins(requirements).items()):
prefix = '%s-%s-' % (name, version)
if not any(f.lower().startswith(prefix) for f in have):
problems.append(
'wheels/ has no wheel for %s==%s, which requirements.txt pins '
'(a marked-out dependency still installs on Windows)' % (name, version))
return problems
def verify(bundle_root, lock):
problems = []
locked = lock.get('payloads')
if not locked:
return ['bundle-lock.json has no "payloads" section']
for name, required, what in PAYLOADS:
directory = os.path.join(bundle_root, name)
present = os.path.isdir(directory)
if name not in locked:
if present:
problems.append(
'%s/ is present but is not in bundle-lock.json - regenerate the lock' % name)
elif required:
problems.append(
'%s/ is required but is in neither the bundle nor the lock' % name)
continue
if not present:
if required or locked[name].get('required'):
problems.append('%s/ is in the lock but missing from the bundle (%s)' % (name, what))
continue
expected = locked[name].get('files', {})
actual = payload_files(directory)
for rel, want in sorted(expected.items()):
got = actual.get(rel)
if got is None:
problems.append('%s/%s is in the lock but missing from the bundle' % (name, rel))
elif got['sha256'] != want['sha256']:
problems.append(
'%s/%s does NOT match the lock (expected sha256 %s..., got %s...)'
% (name, rel, want['sha256'][:12], got['sha256'][:12]))
elif int(got['size']) != int(want['size']):
# Impossible for a matching sha256, so the lock was hand-edited.
problems.append(
'%s/%s size disagrees with the lock - the lock has been edited by hand'
% (name, rel))
for rel in sorted(actual):
if rel not in expected:
problems.append(
'%s/%s is in the bundle but NOT in the lock (unexpected extra file)'
% (name, rel))
problems.extend(check_wheelhouse_covers_requirements(bundle_root))
return problems
def main():
if len(sys.argv) != 3:
sys.exit('usage: verify_bundle_lock.py <bundle-root> <bundle-lock.json>')
bundle_root, lock_path = sys.argv[1], sys.argv[2]
if not os.path.exists(lock_path):
print('no bundle-lock.json at %s' % lock_path)
return 1
with open(lock_path) as fh:
lock = json.load(fh)
problems = verify(bundle_root, lock)
for problem in problems:
print(problem)
return 1 if problems else 0
if __name__ == '__main__':
sys.exit(main())

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -11,7 +11,7 @@
- HttpPlatformHandler IIS module installed
(https://www.iis.net/downloads/microsoft/httpplatformhandler)
- URL Rewrite module installed (only for the optional X-Forwarded-For rule)
- Python 3.12 + a venv at APP_ROOT\venv with requirements.txt + waitress
- Python 3.14 + a venv at APP_ROOT\venv with requirements.txt + waitress
- Secrets live in APP_ROOT\.env (wsgi.py load_dotenv() reads it). Keep them
OUT of this file. Lock .env ACLs to the app pool identity + admins.
@@ -28,7 +28,7 @@
<httpPlatform
processPath="C:\shopdb-flask\venv\Scripts\waitress-serve.exe"
arguments="--port=%HTTP_PLATFORM_PORT% --host=127.0.0.1 --threads=8 --trusted-proxy=127.0.0.1 --trusted-proxy-headers=x-forwarded-for wsgi:app"
arguments="--port=%HTTP_PLATFORM_PORT% --host=127.0.0.1 --threads=8 --trusted-proxy=127.0.0.1 --trusted-proxy-headers=x-forwarded-for --trusted-proxy-count=1 wsgi:app"
stdoutLogEnabled="true"
stdoutLogFile="C:\shopdb-flask\logs\httpplatform"
startupTimeLimit="120"
@@ -49,16 +49,29 @@
</httpPlatform>
<!--
OPTIONAL: forward the real client IP so audit logs and the kiosk
visitor-location feature (IP -> business unit) see the caller, not the
loopback that HttpPlatformHandler connects from.
Forward the real client IP, so the audit log, the kiosk visitor-location
feature (IP -> business unit), the GE-Enforce IP allowlist and per-host
login rate limiting all see the caller rather than the loopback address
HttpPlatformHandler connects from.
This block is COMMENTED OUT by default because it needs the URL Rewrite
module; with it uncommented but URL Rewrite not installed, IIS returns
HTTP 500.19 ("configuration section not well-formed / cannot be read").
Install URL Rewrite (https://www.iis.net/downloads/microsoft/url-rewrite)
and then uncomment the <rewrite> block below to enable it.
IIS does not set X-Forwarded-For on its own. Without the rule below there
is no such header at all, and every client looks like 127.0.0.1 - so the
allowlist and the visitor-location lookup silently stop working.
ONLY CORRECT WHEN IIS IS DIRECTLY EXPOSED. It overwrites the header with
REMOTE_ADDR, which is what stops a client spoofing its own X-Forwarded-For.
Behind a reverse proxy (ARR, a load balancer) REMOTE_ADDR is the PROXY, so
this rule would destroy the real client IP - there, leave it disabled and
let the proxy set the header.
It ships DISABLED because it needs the URL Rewrite module; enabled without
it, IIS returns HTTP 500.19 ("configuration section not well-formed").
The installer handles both: -ClientIpSource direct installs URL Rewrite
from the bundle and enables this; -ClientIpSource proxy leaves it alone.
By hand: install URL Rewrite, then delete the two marker lines below.
-->
<!-- SHOPDB-CLIENTIP-BEGIN
<rewrite>
<allowedServerVariables>
<add name="HTTP_X_FORWARDED_FOR" />
@@ -73,7 +86,7 @@
</rule>
</rules>
</rewrite>
-->
SHOPDB-CLIENTIP-END -->
</system.webServer>

View File

@@ -2,7 +2,7 @@
// admin into the /setup wizard. Defaults to "complete" so the wizard never
// flashes before the real value loads.
import { ref } from 'vue'
import { settingsApi } from '../api'
import { settingsApi, setupApi } from '../api'
export const setupComplete = ref(true)
// Session-only: a fresh admin who clicks "Skip for now" is not nagged again
@@ -26,3 +26,31 @@ export async function refreshSetupState() {
loaded.value = true
return setupComplete.value
}
// First run, before any user exists. Defaults to false so the app never flashes
// the create-admin screen at an established site while this loads.
export const needsAdmin = ref(false)
const adminLoaded = ref(false)
export function isNeedsAdminLoaded() {
return adminLoaded.value
}
export async function refreshNeedsAdmin() {
try {
const response = await setupApi.needsAdmin()
needsAdmin.value = response.data?.data?.needsadmin === true
} catch (err) {
// Fail open: if we cannot tell, assume an admin exists rather than trap
// everyone behind a create-admin screen they cannot complete.
needsAdmin.value = false
}
adminLoaded.value = true
return needsAdmin.value
}
// Called after the first admin is created so the gate stops firing.
export function clearNeedsAdmin() {
needsAdmin.value = false
adminLoaded.value = true
}

View File

@@ -2,7 +2,8 @@ import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import AppLayout from '../views/AppLayout.vue'
import SettingsLayout from '../views/settings/SettingsLayout.vue'
import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState } from '../composables/setupState'
import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState,
needsAdmin, isNeedsAdminLoaded, refreshNeedsAdmin } from '../composables/setupState'
import { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins'
import { useToast } from '../composables/toast'
import { getFacilityName } from '../utils/siteSettings'
@@ -196,6 +197,24 @@ router.afterEach(() => {
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
// FIRST RUN: until an admin exists, nothing else in the app is useful. Without
// this, a fresh instance serves an anonymous dashboard and the operator has no
// indication that setup is unfinished (they must guess their way to /login).
// Unauthenticated only, so it costs an established site nothing.
if (!authStore.isAuthenticated && to.path !== '/login') {
if (!isNeedsAdminLoaded()) {
// Bounded like the plugin gate: a hung request must not hold navigation
// open. refreshNeedsAdmin fails open on error.
await Promise.race([
refreshNeedsAdmin(),
new Promise(resolve => setTimeout(resolve, 4000)),
])
}
if (needsAdmin.value) {
return next({ path: '/login', query: { firstrun: '1' } })
}
}
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
// Remember where they were headed so login can send them back there.
return next({ path: '/login', query: { redirect: to.fullPath } })

View File

@@ -51,6 +51,7 @@ import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { setupApi } from '../api'
import { clearNeedsAdmin } from '../composables/setupState'
import { getSiteLogo } from '../utils/siteSettings'
import { withBase, stripBase } from '../utils/basePath'
@@ -102,6 +103,7 @@ async function handleCreateAdmin() {
loading.value = true
try {
await setupApi.createAdmin({ username: username.value, email: email.value, password: password.value })
clearNeedsAdmin() // an admin now exists; stop the first-run gate firing
// Log straight in with the new credentials, then on to the setup wizard.
const result = await authStore.login(username.value, password.value)
if (result.success) router.push('/setup')

View File

@@ -117,8 +117,9 @@
<!-- Starter data -->
<div v-else-if="current.key === 'data'">
<h2>Starter data</h2>
<p class="hint">Seed the data a new site needs. Both are idempotent - safe to run more than once.</p>
<h2>Reference data</h2>
<p class="hint">Seed the lookup data a new site needs: statuses, types, permissions, and a short list of common vendors. Both are idempotent - safe to run more than once.</p>
<p class="hint">This does not create any assets. The site starts empty and assets are loaded afterwards via the import API, so an empty dashboard here is expected.</p>
<div class="starter-actions">
<div>
<button class="btn btn-secondary" :disabled="seeding" @click="seedReference">
@@ -181,7 +182,7 @@ const steps = [
{ key: 'site', label: 'Site' },
{ key: 'plugins', label: 'Features' },
{ key: 'map', label: 'Floor Map' },
{ key: 'data', label: 'Starter Data' },
{ key: 'data', label: 'Reference Data' },
{ key: 'finish', label: 'Finish' },
]
const step = ref(0)

View File

@@ -13,10 +13,10 @@ This guide documents the process for migrating data from the legacy VBScript Sho
### New System (Flask)
- Core `assets` table (unified asset registry)
- Plugin-specific extension tables:
- `equipment` (equipmenttypeid → equipmenttypes)
- `computers` (computertypeid → computertypes)
- `printers` (printertypeid → printertypes)
- `network_device` (networkdevicetypeid → networkdevicetypes)
- `equipment` (equipmenttypeid -> equipmenttypes)
- `computers` (computertypeid -> computertypes)
- `printers` (printertypeid -> printertypes)
- `network_device` (networkdevicetypeid -> networkdevicetypes)
## Key Mappings

View File

@@ -4,27 +4,12 @@ from logging.config import fileConfig
from flask import current_app
from alembic import context
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.schema import CreateTable
# Force every table the migrations create on MySQL to utf8mb4 + DYNAMIC row
# format. Without this a fresh `flask db upgrade` inherits the server default
# charset, so a box whose default is latin1 (common on older MySQL) silently
# builds a latin1 schema that drifts from the utf8mb4 production target. The
# DYNAMIC row format also keeps utf8mb4 indexes under the 767-byte prefix limit
# on pre-5.7 InnoDB. Scoped to the mysql dialect so the SQLite test DB is
# untouched.
@compiles(CreateTable, "mysql")
def _mysql_create_table_utf8mb4(element, compiler, **kw):
sql = compiler.visit_create_table(element, **kw)
if "CHARSET" not in sql.upper():
sql = sql.rstrip().rstrip(";")
sql += (
" ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
" COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC"
)
return sql
# Registers the compiler hook that forces utf8mb4 + DYNAMIC on MySQL. Imported
# for the side effect, and shared with the PLUGIN chains via
# shopdb/plugins/alembic_template.py - it used to live here, so it covered core
# only and plugin baselines inherited the server default charset.
import shopdb.utils.mysql_charset # noqa: F401
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.

View File

@@ -153,7 +153,7 @@ def resolve_employee_display_name(sso):
# location not carried in the directory). Their name lives on the User
# record; GE usernames are the SSO. Photo still falls back to the GE mark.
try:
from shopdb.core.models import User
from shopdb.api import User
user = User.query.filter_by(username=str(sso)).first()
if user:
name = f'{(user.firstname or "").strip()} {(user.lastname or "").strip()}'.strip()

View File

@@ -52,12 +52,23 @@ def _trusted_client_ip():
Uses request.remote_addr, NOT the raw X-Forwarded-For header. Proxies APPEND
to X-Forwarded-For, so its first hop is attacker-controlled: parsing it (as
_client_ip does for rate-limiting) would let any caller send
'X-Forwarded-For: <allowlisted-ip>' and bypass the token. remote_addr cannot
be forged here - behind IIS the URL-Rewrite rule overwrites X-Forwarded-For
with the real TCP peer and waitress (--trusted-proxy=127.0.0.1
--trusted-proxy-headers=x-forwarded-for) derives remote_addr from it; a
client hitting waitress directly is not a trusted proxy, so its remote_addr
is its own real peer address. Either way remote_addr is the true client.
'X-Forwarded-For: <allowlisted-ip>' and bypass the token.
DEPLOYMENT DEPENDENCY, not a property of this function. remote_addr is only
trustworthy because of what sits in front:
- Behind IIS, the URL-Rewrite rule OVERWRITES X-Forwarded-For with the real
TCP peer, and waitress (--trusted-proxy=127.0.0.1
--trusted-proxy-headers=x-forwarded-for) derives remote_addr from it.
- Remove that rule and IIS still forwards whatever X-Forwarded-For the
CALLER sent. waitress trusts it because it comes from 127.0.0.1, so
remote_addr becomes attacker-controlled and this allowlist is bypassable
from anywhere on the network. It does NOT fail closed.
- A client reaching waitress directly is not a trusted proxy, so there
remote_addr is its own peer address.
The rule is the control. See the geenforce-api-cutover wiki page; the Windows
installer enables it and verifies it is live at the end of an install.
"""
return request.remote_addr or ''

12
requirements-dev.in Normal file
View File

@@ -0,0 +1,12 @@
# Development and CI dependencies.
# Production installs use requirements.txt only; these never ship to a site.
#
# Compile with:
# uv pip compile requirements-dev.in -o requirements-dev.txt
-r requirements.in
# Testing
pytest>=7.0
pytest-flask>=1.2
pytest-cov>=4.0

886
requirements-dev.txt Normal file
View File

@@ -0,0 +1,886 @@
# This file was autogenerated by uv via the following command:
# uv pip compile requirements-dev.in -c requirements.txt --universal --generate-hashes -o requirements-dev.txt
#
# The -c constraint pins every shared dependency to the SAME version production
# runs. Without it the two files were compiled at different times and drifted:
# CI tested against alembic 1.18.5 while sites installed 1.18.4.
alembic==1.18.4 \
--hash=sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a \
--hash=sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc
# via
# -c requirements.txt
# flask-migrate
blinker==1.9.0 \
--hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
--hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
# via
# -c requirements.txt
# flask
cachelib==0.13.0 \
--hash=sha256:209d8996e3c57595bee274ff97116d1d73c4980b2fd9a34c7846cd07fd2e1a48 \
--hash=sha256:8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516
# via
# -c requirements.txt
# flask-caching
certifi==2026.4.22 \
--hash=sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a \
--hash=sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580
# via
# -c requirements.txt
# requests
cffi==2.1.0 ; platform_python_implementation != 'PyPy' \
--hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \
--hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \
--hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \
--hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \
--hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \
--hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \
--hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \
--hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \
--hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \
--hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \
--hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \
--hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \
--hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \
--hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \
--hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \
--hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \
--hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \
--hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \
--hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \
--hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \
--hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \
--hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \
--hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \
--hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \
--hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \
--hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \
--hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \
--hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \
--hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \
--hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \
--hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \
--hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \
--hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \
--hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \
--hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \
--hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \
--hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \
--hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \
--hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \
--hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \
--hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \
--hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \
--hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \
--hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \
--hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \
--hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \
--hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \
--hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \
--hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \
--hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \
--hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \
--hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \
--hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \
--hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \
--hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \
--hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \
--hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \
--hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \
--hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \
--hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \
--hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \
--hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \
--hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \
--hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \
--hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \
--hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \
--hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \
--hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \
--hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \
--hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \
--hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \
--hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \
--hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \
--hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \
--hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \
--hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \
--hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \
--hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \
--hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \
--hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \
--hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \
--hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \
--hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \
--hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \
--hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \
--hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \
--hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \
--hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \
--hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \
--hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \
--hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \
--hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \
--hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \
--hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \
--hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \
--hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \
--hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \
--hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \
--hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \
--hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f
# via
# -c requirements.txt
# cryptography
charset-normalizer==3.4.7 \
--hash=sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc \
--hash=sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c \
--hash=sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67 \
--hash=sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4 \
--hash=sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0 \
--hash=sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c \
--hash=sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5 \
--hash=sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444 \
--hash=sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153 \
--hash=sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9 \
--hash=sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01 \
--hash=sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217 \
--hash=sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b \
--hash=sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c \
--hash=sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a \
--hash=sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83 \
--hash=sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5 \
--hash=sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7 \
--hash=sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb \
--hash=sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c \
--hash=sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1 \
--hash=sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42 \
--hash=sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab \
--hash=sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df \
--hash=sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e \
--hash=sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207 \
--hash=sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18 \
--hash=sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734 \
--hash=sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38 \
--hash=sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110 \
--hash=sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18 \
--hash=sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44 \
--hash=sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d \
--hash=sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48 \
--hash=sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e \
--hash=sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5 \
--hash=sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d \
--hash=sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53 \
--hash=sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790 \
--hash=sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c \
--hash=sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b \
--hash=sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116 \
--hash=sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d \
--hash=sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10 \
--hash=sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6 \
--hash=sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2 \
--hash=sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776 \
--hash=sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a \
--hash=sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265 \
--hash=sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008 \
--hash=sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943 \
--hash=sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374 \
--hash=sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246 \
--hash=sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e \
--hash=sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5 \
--hash=sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616 \
--hash=sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15 \
--hash=sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41 \
--hash=sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960 \
--hash=sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752 \
--hash=sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e \
--hash=sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72 \
--hash=sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7 \
--hash=sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8 \
--hash=sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b \
--hash=sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4 \
--hash=sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545 \
--hash=sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706 \
--hash=sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366 \
--hash=sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb \
--hash=sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a \
--hash=sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e \
--hash=sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00 \
--hash=sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f \
--hash=sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a \
--hash=sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1 \
--hash=sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66 \
--hash=sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356 \
--hash=sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319 \
--hash=sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4 \
--hash=sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad \
--hash=sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d \
--hash=sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5 \
--hash=sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7 \
--hash=sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0 \
--hash=sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686 \
--hash=sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34 \
--hash=sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49 \
--hash=sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c \
--hash=sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1 \
--hash=sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e \
--hash=sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60 \
--hash=sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0 \
--hash=sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274 \
--hash=sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d \
--hash=sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0 \
--hash=sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae \
--hash=sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f \
--hash=sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d \
--hash=sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe \
--hash=sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3 \
--hash=sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393 \
--hash=sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1 \
--hash=sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af \
--hash=sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44 \
--hash=sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00 \
--hash=sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c \
--hash=sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3 \
--hash=sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7 \
--hash=sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd \
--hash=sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e \
--hash=sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b \
--hash=sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8 \
--hash=sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259 \
--hash=sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859 \
--hash=sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46 \
--hash=sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30 \
--hash=sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b \
--hash=sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46 \
--hash=sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24 \
--hash=sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a \
--hash=sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24 \
--hash=sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc \
--hash=sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215 \
--hash=sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063 \
--hash=sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832 \
--hash=sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6 \
--hash=sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79 \
--hash=sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464
# via
# -c requirements.txt
# requests
click==8.3.3 \
--hash=sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2 \
--hash=sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613
# via
# -c requirements.txt
# -r requirements.in
# flask
colorama==0.4.6 ; sys_platform == 'win32' \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
# via
# -c requirements.txt
# click
# pytest
coverage==7.15.3 \
--hash=sha256:00cbdc5e322927dc30c5e42b863819b1bb867cc66f26ab5372c585850876ab93 \
--hash=sha256:0b273f4ff657446a06c2d85bf80e134fa869a92852ba5f87854a70e1fb44da77 \
--hash=sha256:0d2e1f2cbbf36b842f3e2aff8d118c60d677adb498bc6c7fa9c6838738f82767 \
--hash=sha256:1182eed05674c63d40951fae27c43e822749f04d25f75df64c2e4fa3168678de \
--hash=sha256:12d555badc462b0f6037ce8bec8b4af8d71f90eb55b57d0a358731f7ee7883e2 \
--hash=sha256:179fbf847e6c3d90ea71bfd570fe57f1ddb1c51474754894871c1e11099efaa0 \
--hash=sha256:1e3bb08ad574bd9fb6a991f645728f70d333c1c1958dd5fcde65e24cb862813d \
--hash=sha256:21081739f6264cc594cad2d42b62befbd17633824022866c68720eb0c4b8d6b4 \
--hash=sha256:22119e2e3b2ac5ac024d50131fdd4b22ab4c6cf8aa2fc792cce73c0d94c5812d \
--hash=sha256:225e359bd5dedaff6d68e36091af20555866c557d968167308b677379bf575c3 \
--hash=sha256:228e172a76c428bb17d1ab78a2ff188990b0597e5dbd291f52a4edf7412de049 \
--hash=sha256:2395869280554a1941da904423c12660c39f721315e1c02d076a7fe0971382f0 \
--hash=sha256:2400591f4b2e33746c70846388f8bb4c7e33b820e31cb8c6cb2f25305310438b \
--hash=sha256:24f3b21840c3eb76cef3cc70b2bf6649010c64471a84a446538a39306e1ba04d \
--hash=sha256:25c77560309f157e7b7ee8fe0bf78d047ba900b7ae42f0e50e559305b366fea2 \
--hash=sha256:28743dad31622e8c474b17446118037361f5b1f4f2ecdf72d4f6fde246d64446 \
--hash=sha256:2b0807f1f051dd82a234ad6acdb6f1425baede60be1e84e862496c8cc9262ab9 \
--hash=sha256:2e557178799282269412a672e5753f2179edfe1b3f0f19b0c98f8e72d482326a \
--hash=sha256:3088cce65e54c2eefc08e7e1ca0b0acec1e95e8cf084ac848599103ed0367f74 \
--hash=sha256:3a82b2ceee91ba353e59fe2436d8a9eae799ff9825e5385423ea205d693e2949 \
--hash=sha256:3db264ea689f9e8f9fa4fb9005fee4048c3bff4a547f4cfa27f5086cb0804ec0 \
--hash=sha256:3fe9be1c527497d047f770d88a0110189714c36383bb88384508f750c302bffa \
--hash=sha256:447f5421ccf5475956cf516d4ca1d575f487947b6f4e11f9d80c6aefe24b3dc8 \
--hash=sha256:44c41eff9e413fed8740eca75d5438ebeb9d3e45e7cd37c67329213e7a72c764 \
--hash=sha256:494880c9e60782610683f4eb9b65cce4f886673596b8f3cb2dfa079fc551c743 \
--hash=sha256:4d608dc36a364dce33acbf4fc3a50f9d2054c945f233bb0a2cdb4b90bfa17646 \
--hash=sha256:4df21bef8b800eebda9018f53d49c9ace3aeb0090c850139b27923aafcb83e91 \
--hash=sha256:4e869d4799674d67778e76ddbe2e26cf1673369262e231a8ec259421b1015fea \
--hash=sha256:50951e37033c40548d777b8a8454a2cd622dba1136780065678dccaec307c47f \
--hash=sha256:5318dd51b8600b947e058cf5a4fe54d183d9d13c49b97b64ca7be05a34df9bef \
--hash=sha256:54146bafb61f3ba9895b43af0dd17eba01561d586d44ce84ea221b0cbbee5a9e \
--hash=sha256:5459ba486b2a5d58a6c05254779ecdf525e7f20174d0210ceda75ba40fdb8f2c \
--hash=sha256:5c9fce9f4998b0d50a753da765b9215a14decc7863822c89d72da7a89ca625b3 \
--hash=sha256:5cba0c9c13e35c86df7998f1afaf6b1da224a3a39e4da59bdabf60c148046dcb \
--hash=sha256:5f3f854ab4599d98f7799ac9b91e34e8ec9ebc9a6372ee8c1f3413a68cc8b5e9 \
--hash=sha256:60874e5bd67f0b1bdbe42ab42c7bafa66a6fb8de88721af6df3f7a02713960cd \
--hash=sha256:61a01f8c3804760fcc5a3d31c4f3cab792d660d44e17bf7adeaf0ea51e07821e \
--hash=sha256:63a4ff67364afb2cac826b8bbd78a5c50ce656a7b7137436b44d7b96a9271088 \
--hash=sha256:6433fafb8da0e1d02eb53411e0ecdadb6b88f0224fdc23317e703c0e88937d42 \
--hash=sha256:64d0845f9c3ed47302bed265c15ab4dbb64aa4ec1490839b8e328f4e7fa914d2 \
--hash=sha256:66591c46bdd2971d3ae2bc503a5f0459c2edcaf6b7e045b292000cc95bc6cb95 \
--hash=sha256:68ea6c947375982ae907e19e9d2ef156bd6e68e11f3566dd568d7f4ec974e715 \
--hash=sha256:696fc7a28bbf717aba8d2c6963d26702945c7832cb313ba3b323aa5b1afb3156 \
--hash=sha256:69bc14684f8fbbee9f9dbaa4fe79719b0da9725fc37956785c06ec365acf6926 \
--hash=sha256:6e95e42856509675fe26560310313a6117640e96f9a1e19bb3d220116a27c94c \
--hash=sha256:718d366251b060c10731c7dd359de6caea72250036eb94576aa56dacbf830a11 \
--hash=sha256:722dbf8e7828fbcfe0dc8586167dc0a5ce85ad6ea171dbb21ed3f8d6581d3cb8 \
--hash=sha256:75268348fee1f199653b8a846262aec5581c6bb008c4f58824959fb708cc688f \
--hash=sha256:767feb87c5886d781d0a69fafd450a20826ddab7b79bce1665deb64d21441b60 \
--hash=sha256:7835176988cbcf1f014db683bc33aa15e0558e412bf08deaa99757335b88df15 \
--hash=sha256:79a3e32e83227d83d9684459ed579769b56c369ac2d7313099b2d9e031d2e10f \
--hash=sha256:7a0c77ef8cd483a4987a5d12d1d9d5f7ee598dfdc6c0844417d847e5768dc779 \
--hash=sha256:7a47e2a0a0ace9241e70ee00e44520f88b843094603dd54303f1bafecd929c30 \
--hash=sha256:81760de3155d7f52c21860c4046628dc6bed182f72e3c028e2b4fd46f65aa040 \
--hash=sha256:835528518a1d823cf336740324b2f335f7c01e609e74abcb5d5163b3e66661e3 \
--hash=sha256:93ff57c530f3fa7aa69f92fb9b8892b8aa82712aa970842f4abf28657f42fb57 \
--hash=sha256:9490d43e5d041fdf376770a886a29722adb05f6b9c21a65c48c81fc8f1c33fd7 \
--hash=sha256:95bad94f83807ae60ed76f3ac012f69b2605ac9ea81bee959a5a483f7fa09c10 \
--hash=sha256:95bf3e7f26f792e25eb185f85a5a659d48479265176dcfe22b6f334fd0081b5c \
--hash=sha256:974471c506c9f5758808b47c1ebf7949ecd0848f5c1020e78675fefe5ff46866 \
--hash=sha256:9e5860eaff02a0b7f1b73304bdf846596ee62ab3a78d25c68044ebf684cb1fef \
--hash=sha256:a1b82490577f3889950b5a04f18712aef0207243e0749d60fe28c3c73ebfd5fd \
--hash=sha256:a65e09efb0b5ab21fc54a8a65c5b2e533c0a4c0d064af0259a005dc656dc1b13 \
--hash=sha256:abad631cba27094b4631993f4c72e89ac0ca1b3a0236c7abaf8ca79aea619851 \
--hash=sha256:ae7ea5a4614acf399ef0483c4cb34f8f8f01df848d8fcbe7d3ce0865733f1c4d \
--hash=sha256:af000dd1bb859ff8066fda4c79512ff938c798116540307226b373099c7b151f \
--hash=sha256:b47ea0a1d3a3d089826c6cbfad8429d7d8872e28e86baa95ddef330f6875da21 \
--hash=sha256:b51f279a2477b0e1f288b98f141fd227acfdd1d3f0370400e473788879b47871 \
--hash=sha256:c0c4b0d7c4cd56e470d0c9d8441f42e8a96cdfd95050fec027f1d4dd9f11006c \
--hash=sha256:c4398918c4fda32718191239e451fd86ac5ad1e8979b592f1921ee2d1f038965 \
--hash=sha256:c4e2cf9cf774939b3dc581c6e31dfe7e8d7608b24f0f17524d6161f8235c3d2c \
--hash=sha256:c4fc90a60154c3e4b8a2dc206d6dbe852f1c235c249e0dc0cef909d032c9591a \
--hash=sha256:c59209f80a08dbfcdd5109a80dc623cd3b9d22895c85757d34f57a6e6e95570f \
--hash=sha256:c995bfa383c54704839b6c4c2627a1c00895597ada0e5e8190c81d8bd620555c \
--hash=sha256:caa64458b81b18bfc67cdf1f6dc02b23e3edc672f2f8e11771fad75865415a43 \
--hash=sha256:cea1b3e19d710f67e2ba9ce0b0b51032c2a9b4808a65ced48ddf336ef7e58058 \
--hash=sha256:cea9fb33887c99349996266f1fd60abe5af3577a90633392001d27ef46b4b66e \
--hash=sha256:d2e1acb7aee29dfa8f3e48c23f36670898baca1209d9bdd3985a50c7f982165e \
--hash=sha256:d8d6df7aeb5bc464040bbc9ae173d875785d3677ebc4307817997d622d74225e \
--hash=sha256:da78fa6fc7dafe4212839173133ee85afcf42c5cd5f3e47fa7c1c210453b445e \
--hash=sha256:daea8c4fafa22488600405be2c2be525a9406fba3fc0a83acc726db3e14e2005 \
--hash=sha256:db567b02685f26034adcbd85055f80d12cdf02111b8ed00886093d98b2874ce2 \
--hash=sha256:f24896dc8863167f6732f4142f5d37e6195eccc8fe5fe528d35d49597d29fdb3 \
--hash=sha256:f25bb884814a892948b4c20394db3f2364dd452d9492736479e7a493e63b0eb6 \
--hash=sha256:f863856c1779d4a5bb6a94698a2f9073e09c6706501f76f3e7780e72df97d21c \
--hash=sha256:f9147be876e9d83765e0b82176674dc248a6b9283e25e01e7462611b97e9b731 \
--hash=sha256:f92df943c24b96cb215ca26b4f6a2283e63c5db80f1635aceea7fff11311917b \
--hash=sha256:fa1bbaa502a6e877f3ee67cbac3eba2bb637f623e454e6c37b81b38896dbd48f \
--hash=sha256:fa7b17902c3c1dd8a7adb52679b7f6340bba08443d710c8838e04db8cf62be2a \
--hash=sha256:fcbe83fb7258eacd293bf5322d88807acb35ed12a5cfa99dd8215c083e3b0235 \
--hash=sha256:fe578952b1b29fe8c777f43f241d49efac4b56724a3434f5d22ebe3c208df429
# via pytest-cov
cryptography==50.0.0 \
--hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \
--hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \
--hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \
--hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \
--hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \
--hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \
--hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \
--hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \
--hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \
--hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \
--hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \
--hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \
--hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \
--hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \
--hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \
--hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \
--hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \
--hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \
--hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \
--hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \
--hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \
--hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \
--hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \
--hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \
--hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \
--hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \
--hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \
--hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \
--hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \
--hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \
--hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \
--hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \
--hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \
--hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \
--hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \
--hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \
--hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \
--hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \
--hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \
--hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \
--hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \
--hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \
--hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \
--hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \
--hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \
--hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645
# via
# -c requirements.txt
# -r requirements.in
dnspython==2.8.0 \
--hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af \
--hash=sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f
# via
# -c requirements.txt
# email-validator
email-validator==2.3.0 \
--hash=sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 \
--hash=sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426
# via
# -c requirements.txt
# -r requirements.in
flask==3.1.3 \
--hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \
--hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c
# via
# -c requirements.txt
# -r requirements.in
# flask-caching
# flask-cors
# flask-jwt-extended
# flask-marshmallow
# flask-migrate
# flask-sqlalchemy
# pytest-flask
flask-caching==2.4.0 \
--hash=sha256:a7f14e43617cd0612a57bec2f80516d6d2ec888bfc50a807b1e4e41ca345a3b5 \
--hash=sha256:d15b8135f055c4f28f6f7dbcf8d36a3de4af1224def975ae6e0b43cbfa684486
# via
# -c requirements.txt
# -r requirements.in
flask-cors==6.0.2 \
--hash=sha256:6e118f3698249ae33e429760db98ce032a8bf9913638d085ca0f4c5534ad2423 \
--hash=sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a
# via
# -c requirements.txt
# -r requirements.in
flask-jwt-extended==4.7.3 \
--hash=sha256:905ac807b52b5409bc9244dbcca434968c13ca6f9d91bffe7d4cb71e0e6231cb \
--hash=sha256:a8318a3d983d1f360724b901889f1947ffee418e2a3950b48e99c70923b6615e
# via
# -c requirements.txt
# -r requirements.in
flask-marshmallow==1.5.0 \
--hash=sha256:7c06b56e41647eccdb3cd57c25b109f19191b4c62509362bd64920cdf601a066 \
--hash=sha256:99951c77e5654111ed733811c6dc9310bfb4c3688c78a9e76f80b5ae0b2279a6
# via
# -c requirements.txt
# -r requirements.in
flask-migrate==4.1.0 \
--hash=sha256:1a336b06eb2c3ace005f5f2ded8641d534c18798d64061f6ff11f79e1434126d \
--hash=sha256:24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d
# via
# -c requirements.txt
# -r requirements.in
flask-sqlalchemy==3.1.1 \
--hash=sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0 \
--hash=sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312
# via
# -c requirements.txt
# -r requirements.in
# flask-migrate
greenlet==3.5.0 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' \
--hash=sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846 \
--hash=sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4 \
--hash=sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662 \
--hash=sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce \
--hash=sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2 \
--hash=sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588 \
--hash=sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13 \
--hash=sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e \
--hash=sha256:29ea813b2e1f45fa9649a17853b2b5465c4072fbcb072e5af6cd3a288216574a \
--hash=sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3 \
--hash=sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b \
--hash=sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033 \
--hash=sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628 \
--hash=sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136 \
--hash=sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b \
--hash=sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d \
--hash=sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2 \
--hash=sha256:4b28037cb07768933c54d81bfe47a85f9f402f57d7d69743b991a713b63954eb \
--hash=sha256:4d0eadc7e4d9ffb2af4247b606cae307be8e448911e5a0d0b16d72fc3d224cfd \
--hash=sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b \
--hash=sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1 \
--hash=sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16 \
--hash=sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d \
--hash=sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106 \
--hash=sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba \
--hash=sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c \
--hash=sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc \
--hash=sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7 \
--hash=sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339 \
--hash=sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b \
--hash=sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae \
--hash=sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8 \
--hash=sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2 \
--hash=sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5 \
--hash=sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf \
--hash=sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f \
--hash=sha256:804a70b328e706b785c6ef16187051c394a63dd1a906d89be24b6ad77759f13f \
--hash=sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2 \
--hash=sha256:884f649de075b84739713d41dd4dfd41e2b910bfb769c4a3ea02ec1da52cd9bb \
--hash=sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082 \
--hash=sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7 \
--hash=sha256:9c615f869163e14bb1ced20322d8038fb680b08236521ac3f30cd4c1288785a0 \
--hash=sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c \
--hash=sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853 \
--hash=sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988 \
--hash=sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3 \
--hash=sha256:ba8f0bdc2fae6ce915dfd0c16d2d00bca7e4247c1eae4416e06430e522137858 \
--hash=sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37 \
--hash=sha256:cda05425526240807408156b6960a17a79a0c760b813573b67027823be760977 \
--hash=sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4 \
--hash=sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8 \
--hash=sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86 \
--hash=sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f \
--hash=sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112 \
--hash=sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e \
--hash=sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2 \
--hash=sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8 \
--hash=sha256:f8c30c2225f40dd76c50790f0eb3b5c7c18431efb299e2782083e1981feed243 \
--hash=sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564
# via
# -c requirements.txt
# sqlalchemy
idna==3.13 \
--hash=sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242 \
--hash=sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3
# via
# -c requirements.txt
# email-validator
# requests
iniconfig==2.3.0 \
--hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
--hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
# via pytest
itsdangerous==2.2.0 \
--hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \
--hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173
# via
# -c requirements.txt
# flask
jinja2==3.1.6 \
--hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
--hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
# via
# -c requirements.txt
# flask
mako==1.3.12 \
--hash=sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9 \
--hash=sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a
# via
# -c requirements.txt
# alembic
markupsafe==3.0.3 \
--hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \
--hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \
--hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \
--hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \
--hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
--hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \
--hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
--hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \
--hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \
--hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \
--hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
--hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \
--hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \
--hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \
--hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \
--hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
--hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \
--hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \
--hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \
--hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \
--hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
--hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \
--hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \
--hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
--hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \
--hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \
--hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \
--hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \
--hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \
--hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \
--hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
--hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \
--hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \
--hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
--hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \
--hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
--hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \
--hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
--hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \
--hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \
--hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
--hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
--hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \
--hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \
--hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \
--hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \
--hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \
--hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \
--hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \
--hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \
--hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \
--hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
--hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \
--hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \
--hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \
--hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \
--hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \
--hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \
--hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \
--hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \
--hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \
--hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
--hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \
--hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \
--hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \
--hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
--hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \
--hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \
--hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \
--hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \
--hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \
--hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \
--hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \
--hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \
--hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \
--hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \
--hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \
--hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
--hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
--hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \
--hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
--hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
--hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
--hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
--hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
--hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \
--hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \
--hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \
--hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
# via
# -c requirements.txt
# flask
# jinja2
# mako
# werkzeug
marshmallow==4.3.0 \
--hash=sha256:46c4fe6984707e3cbd485dfebbf0a59874f58d695aad05c1668d15e8c6e13b46 \
--hash=sha256:fb43c53b3fe240b8f6af37223d6ef1636f927ad9bea8ab323afad95dff090880
# via
# -c requirements.txt
# flask-marshmallow
# marshmallow-sqlalchemy
marshmallow-sqlalchemy==1.5.0 \
--hash=sha256:3865232672f3dd38c4d5e4e85fdedce76904200742c3594948a2d11d0af93258 \
--hash=sha256:e51192c204770645a2fab0d72f44f8789272eef75951f84b1608d6b4b0bfe0e6
# via
# -c requirements.txt
# -r requirements.in
mysql-connector-python==9.7.0 \
--hash=sha256:079ce68d617250ef5cefffd5243056dc9f87c6034c804235fd6f45a0daf5a6ae \
--hash=sha256:15106ed73d74487f86de6b1859ff7f362efca7c7f9c494497ccb7439d3139fe6 \
--hash=sha256:229ffce2333b88b59f8f034881e12dc85b309615338be9f843ed63923db99d52 \
--hash=sha256:236a4b8abfac8217517ce9e9edc735decf55282ada3890b3cd33770009f33d68 \
--hash=sha256:272c4e8263f6a514e4ae65e9553ed214fc9d4cf1f0f6bedca30def20c2ae1d52 \
--hash=sha256:2eae45230b5cbd783d68bdfe8b05ad9b4ebd06799f8d302a6169d7f025572baf \
--hash=sha256:39bf31cdbe920eae08801eb829584dc27f70dadbdb3a5c3b78402f54cea87ab0 \
--hash=sha256:45491d4ce56722cb335e6d0bde2d4f4a98b7073421bd02a04ad5e6220d69e499 \
--hash=sha256:5492d57a6a0e5127a928290737fbb91b66b46d31dac8de3e7604e550bf3b3a6e \
--hash=sha256:5a5abbc152bc28cb2e64a04605ecd9941eff6b0dc5f9528cb84adb873e9a1e49 \
--hash=sha256:6546e0b60c275409a5add9e3308c3897fcf478d1338cd845b1664c1a8946f72f \
--hash=sha256:933887e71c871b6e9d8908459fe8303ebcf8feb5cc1e1c49caa6490e525cf78e \
--hash=sha256:9549a2974353407427b574f005c92f03972e303182b4c3b4a272bf4ca10855ed \
--hash=sha256:9bdfc2d4c4444cd1cc79cc6487c047b28fe2b26d0327b27eb9f5737bb553cb5c \
--hash=sha256:9da73e212bb08e4df286506ffbda943063bbce448375a7db29748bb367f4e0af \
--hash=sha256:9e17ceb79c1c4c137a5994f8ca8c6a1ce8ebf83eda3ccc21dd67f2c1398680f0 \
--hash=sha256:a2f371ab69d65c61136c51ad7026017400166cef3c959cab7a9fb668c7acbfba \
--hash=sha256:af80b1e7179d5c2d983cf62470ad9b134a7e9ef05cf31108ae587f15873530cc \
--hash=sha256:b5cb8a3ba42b539f79cd13e4c8376d28506f3180f7079c9b04ea7bfd0424fb03 \
--hash=sha256:b8d3d6f32b95ae1d0a2f29d1a2b4e628849bcf7e84a70dc56c85b4929361d86b \
--hash=sha256:c51be697bfdfdf63bb71c5ecc51f7c6faf4aaa3d14a0136fa16e97cc37df1185 \
--hash=sha256:d5924a76b530159c02f2fe8da4d3c6377ce1f5e195827e8ecbd36124673651d3 \
--hash=sha256:daf70f7da64a2e7c17e6dfd1fc98d2deb653fd955d0bd0b1fef246356e682b0f \
--hash=sha256:e3842ecd62391a28c1dda5eb817f40418715e68482a8c146b3c478ac8bb7a23f \
--hash=sha256:ea4e05ab864ab8b0b5066d885d89ca51e5ba1320dba520c20a6f9388a8eca022 \
--hash=sha256:ee90c5f44f706f012be17f03f6ad158ff96e7f2dcc077896fe4537d3d28b3cf4
# via
# -c requirements.txt
# -r requirements.in
packaging==26.2 \
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
# via pytest
pluggy==1.6.0 \
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
# via
# pytest
# pytest-cov
pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
# via
# -c requirements.txt
# cffi
pygments==2.20.0 \
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
# via pytest
pyjwt==2.12.1 \
--hash=sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c \
--hash=sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b
# via
# -c requirements.txt
# flask-jwt-extended
pymysql==1.1.3 \
--hash=sha256:8164ba62c552f6105f3b11753352d0f16b90d1703ba67d81923d5a8a5d1c5289 \
--hash=sha256:e70ebf2047a4edf6138cf79c68ad418ef620af65900aa585c5e8bfc95044d43a
# via
# -c requirements.txt
# -r requirements.in
pytest==9.1.1 \
--hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \
--hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c
# via
# -r requirements-dev.in
# pytest-cov
# pytest-flask
pytest-cov==7.1.0 \
--hash=sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2 \
--hash=sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678
# via -r requirements-dev.in
pytest-flask==1.3.0 \
--hash=sha256:58be1c97b21ba3c4d47e0a7691eb41007748506c36bf51004f78df10691fa95e \
--hash=sha256:c0e36e6b0fddc3b91c4362661db83fa694d1feb91fa505475be6732b5bc8c253
# via -r requirements-dev.in
python-dotenv==1.2.2 \
--hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \
--hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3
# via
# -c requirements.txt
# -r requirements.in
requests==2.33.1 \
--hash=sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517 \
--hash=sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a
# via
# -c requirements.txt
# -r requirements.in
sqlalchemy==2.0.49 \
--hash=sha256:01146546d84185f12721a1d2ce0c6673451a7894d1460b592d378ca4871a0c72 \
--hash=sha256:059d7151fff513c53a4638da8778be7fce81a0c4854c7348ebd0c4078ddf28fe \
--hash=sha256:0c98c59075b890df8abfcc6ad632879540f5791c68baebacb4f833713b510e75 \
--hash=sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5 \
--hash=sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148 \
--hash=sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7 \
--hash=sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e \
--hash=sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518 \
--hash=sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7 \
--hash=sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700 \
--hash=sha256:334edbcff10514ad1d66e3a70b339c0a29886394892490119dbb669627b17717 \
--hash=sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672 \
--hash=sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88 \
--hash=sha256:42e8804962f9e6f4be2cbaedc0c3718f08f60a16910fa3d86da5a1e3b1bfe60f \
--hash=sha256:43d044780732d9e0381ac8d5316f95d7f02ef04d6e4ef6dc82379f09795d993f \
--hash=sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08 \
--hash=sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a \
--hash=sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3 \
--hash=sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b \
--hash=sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536 \
--hash=sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0 \
--hash=sha256:566df36fd0e901625523a5a1835032f1ebdd7f7886c54584143fa6c668b4df3b \
--hash=sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a \
--hash=sha256:5e61abbec255be7b122aa461021daa7c3f310f3e743411a67079f9b3cc91ece3 \
--hash=sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4 \
--hash=sha256:62557958002b69699bdb7f5137c6714ca1133f045f97b3903964f47db97ea339 \
--hash=sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158 \
--hash=sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066 \
--hash=sha256:69469ce8ce7a8df4d37620e3163b71238719e1e2e5048d114a1b6ce0fbf8c662 \
--hash=sha256:6eb188b84269f357669b62cb576b5b918de10fb7c728a005fa0ebb0b758adce1 \
--hash=sha256:74ab4ee7794d7ed1b0c37e7333640e0f0a626fc7b398c07a7aef52f484fddde3 \
--hash=sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5 \
--hash=sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01 \
--hash=sha256:7d6be30b2a75362325176c036d7fb8d19e8846c77e87683ffaa8177b35135613 \
--hash=sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a \
--hash=sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0 \
--hash=sha256:88690f4e1f0fbf5339bedbb127e240fec1fd3070e9934c0b7bef83432f779d2f \
--hash=sha256:8a97ac839c2c6672c4865e48f3cbad7152cee85f4233fb4ca6291d775b9b954a \
--hash=sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e \
--hash=sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2 \
--hash=sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af \
--hash=sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014 \
--hash=sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33 \
--hash=sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61 \
--hash=sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d \
--hash=sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187 \
--hash=sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401 \
--hash=sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b \
--hash=sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d \
--hash=sha256:b95b2f470c1b2683febd2e7eab1d3f0e078c91dbdd0b00e9c645d07a413bb99f \
--hash=sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba \
--hash=sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977 \
--hash=sha256:c338ec6ec01c0bc8e735c58b9f5d51e75bacb6ff23296658826d7cfdfdb8678a \
--hash=sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe \
--hash=sha256:cc992c6ed024c8c3c592c5fc9846a03dd68a425674900c70122c77ea16c5fb0b \
--hash=sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f \
--hash=sha256:d898cc2c76c135ef65517f4ddd7a3512fb41f23087b0650efb3418b8389a3cd1 \
--hash=sha256:d99945830a6f3e9638d89a28ed130b1eb24c91255e4f24366fbe699b983f29e4 \
--hash=sha256:da9b91bca419dc9b9267ffadde24eae9b1a6bffcd09d0a207e5e3af99a03ce0d \
--hash=sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120 \
--hash=sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750 \
--hash=sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0 \
--hash=sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982
# via
# -c requirements.txt
# alembic
# flask-sqlalchemy
# marshmallow-sqlalchemy
tabulate==0.10.0 \
--hash=sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d \
--hash=sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3
# via
# -c requirements.txt
# -r requirements.in
typing-extensions==4.15.0 \
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
# via
# -c requirements.txt
# alembic
# sqlalchemy
tzdata==2026.3 \
--hash=sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415 \
--hash=sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931
# via
# -c requirements.txt
# -r requirements.in
urllib3==2.7.0 \
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
# via
# -c requirements.txt
# requests
waitress==3.0.2 \
--hash=sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f \
--hash=sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e
# via
# -c requirements.txt
# -r requirements.in
werkzeug==3.1.8 \
--hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \
--hash=sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44
# via
# -c requirements.txt
# -r requirements.in
# flask
# flask-cors
# flask-jwt-extended
# pytest-flask

View File

@@ -24,10 +24,14 @@ requests>=2.31
# Security
werkzeug>=3.0
# Windows/IIS runtime
# waitress is the WSGI server IIS launches via HttpPlatformHandler.
# tzdata ships the IANA timezone database, which Windows does not provide;
# without it the notifications plugin fails on zoneinfo lookups.
# Both were previously hand-added to requirements.txt only, so a recompile
# would have silently dropped them from every Windows install.
waitress>=3.0
tzdata
# Validation
email-validator>=2.0
# Testing
pytest>=7.0
pytest-flask>=1.2
pytest-cov>=4.0

View File

@@ -1,26 +1,352 @@
# This file was autogenerated by uv via the following command:
# uv pip compile requirements.in -o requirements.txt
alembic==1.18.4
# via flask-migrate
blinker==1.9.0
# via flask
cachelib==0.13.0
# via flask-caching
certifi==2026.4.22
# via requests
charset-normalizer==3.4.7
# via requests
click==8.3.3
# uv pip compile requirements.in --universal --generate-hashes -o requirements.txt
#
# UNIVERSAL + HASHED on purpose:
# --universal resolves for every platform, so ONE lockfile serves Linux (dev,
# Docker, CI) and the Windows offline wheelhouse. A Linux-only
# resolve silently omitted colorama, a win32-only dependency of
# click, which then had no entry to match in hash-checking mode.
# --generate-hashes puts pip into hash-checking mode automatically. A wheel
# whose sha256 is not listed here is REFUSED at install time.
# This pins the VERSION and proves the artifact is the one
# upstream published; it does NOT pin which artifact of that
# version is used, and it says nothing about extra wheels loose
# in the wheelhouse - bundle-lock.json covers both of those.
#
# Regenerate with the command above. Do NOT hand-edit: waitress and tzdata were
# hand-added here once and vanished on the next compile.
alembic==1.18.4 \
--hash=sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a \
--hash=sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc
# via
# -r requirements.in
# flask-migrate
blinker==1.9.0 \
--hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
--hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
# via
# -r requirements.in
# flask
coverage==7.13.5
# via pytest-cov
dnspython==2.8.0
# via email-validator
email-validator==2.3.0
cachelib==0.13.0 \
--hash=sha256:209d8996e3c57595bee274ff97116d1d73c4980b2fd9a34c7846cd07fd2e1a48 \
--hash=sha256:8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516
# via
# -r requirements.in
# flask-caching
certifi==2026.4.22 \
--hash=sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a \
--hash=sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580
# via
# -r requirements.in
# requests
cffi==2.1.0 \
--hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \
--hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \
--hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \
--hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \
--hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \
--hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \
--hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \
--hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \
--hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \
--hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \
--hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \
--hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \
--hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \
--hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \
--hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \
--hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \
--hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \
--hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \
--hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \
--hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \
--hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \
--hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \
--hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \
--hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \
--hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \
--hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \
--hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \
--hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \
--hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \
--hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \
--hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \
--hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \
--hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \
--hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \
--hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \
--hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \
--hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \
--hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \
--hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \
--hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \
--hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \
--hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \
--hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \
--hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \
--hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \
--hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \
--hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \
--hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \
--hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \
--hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \
--hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \
--hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \
--hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \
--hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \
--hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \
--hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \
--hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \
--hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \
--hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \
--hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \
--hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \
--hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \
--hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \
--hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \
--hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \
--hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \
--hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \
--hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \
--hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \
--hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \
--hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \
--hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \
--hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \
--hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \
--hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \
--hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \
--hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \
--hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \
--hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \
--hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \
--hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \
--hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \
--hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \
--hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \
--hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \
--hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \
--hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \
--hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \
--hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \
--hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \
--hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \
--hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \
--hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \
--hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \
--hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \
--hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \
--hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \
--hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \
--hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \
--hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f
# via
# -r requirements.in
# cryptography
charset-normalizer==3.4.7 \
--hash=sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc \
--hash=sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c \
--hash=sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67 \
--hash=sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4 \
--hash=sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0 \
--hash=sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c \
--hash=sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5 \
--hash=sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444 \
--hash=sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153 \
--hash=sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9 \
--hash=sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01 \
--hash=sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217 \
--hash=sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b \
--hash=sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c \
--hash=sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a \
--hash=sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83 \
--hash=sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5 \
--hash=sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7 \
--hash=sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb \
--hash=sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c \
--hash=sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1 \
--hash=sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42 \
--hash=sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab \
--hash=sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df \
--hash=sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e \
--hash=sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207 \
--hash=sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18 \
--hash=sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734 \
--hash=sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38 \
--hash=sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110 \
--hash=sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18 \
--hash=sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44 \
--hash=sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d \
--hash=sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48 \
--hash=sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e \
--hash=sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5 \
--hash=sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d \
--hash=sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53 \
--hash=sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790 \
--hash=sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c \
--hash=sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b \
--hash=sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116 \
--hash=sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d \
--hash=sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10 \
--hash=sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6 \
--hash=sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2 \
--hash=sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776 \
--hash=sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a \
--hash=sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265 \
--hash=sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008 \
--hash=sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943 \
--hash=sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374 \
--hash=sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246 \
--hash=sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e \
--hash=sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5 \
--hash=sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616 \
--hash=sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15 \
--hash=sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41 \
--hash=sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960 \
--hash=sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752 \
--hash=sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e \
--hash=sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72 \
--hash=sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7 \
--hash=sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8 \
--hash=sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b \
--hash=sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4 \
--hash=sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545 \
--hash=sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706 \
--hash=sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366 \
--hash=sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb \
--hash=sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a \
--hash=sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e \
--hash=sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00 \
--hash=sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f \
--hash=sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a \
--hash=sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1 \
--hash=sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66 \
--hash=sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356 \
--hash=sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319 \
--hash=sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4 \
--hash=sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad \
--hash=sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d \
--hash=sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5 \
--hash=sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7 \
--hash=sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0 \
--hash=sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686 \
--hash=sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34 \
--hash=sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49 \
--hash=sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c \
--hash=sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1 \
--hash=sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e \
--hash=sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60 \
--hash=sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0 \
--hash=sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274 \
--hash=sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d \
--hash=sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0 \
--hash=sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae \
--hash=sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f \
--hash=sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d \
--hash=sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe \
--hash=sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3 \
--hash=sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393 \
--hash=sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1 \
--hash=sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af \
--hash=sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44 \
--hash=sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00 \
--hash=sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c \
--hash=sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3 \
--hash=sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7 \
--hash=sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd \
--hash=sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e \
--hash=sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b \
--hash=sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8 \
--hash=sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259 \
--hash=sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859 \
--hash=sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46 \
--hash=sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30 \
--hash=sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b \
--hash=sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46 \
--hash=sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24 \
--hash=sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a \
--hash=sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24 \
--hash=sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc \
--hash=sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215 \
--hash=sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063 \
--hash=sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832 \
--hash=sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6 \
--hash=sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79 \
--hash=sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464
# via
# -r requirements.in
# requests
click==8.3.3 \
--hash=sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2 \
--hash=sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613
# via
# -r requirements.in
# flask
colorama==0.4.6 ; sys_platform == 'win32' \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
# via click
cryptography==50.0.0 \
--hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \
--hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \
--hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \
--hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \
--hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \
--hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \
--hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \
--hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \
--hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \
--hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \
--hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \
--hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \
--hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \
--hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \
--hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \
--hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \
--hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \
--hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \
--hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \
--hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \
--hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \
--hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \
--hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \
--hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \
--hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \
--hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \
--hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \
--hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \
--hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \
--hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \
--hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \
--hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \
--hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \
--hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \
--hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \
--hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \
--hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \
--hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \
--hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \
--hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \
--hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \
--hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \
--hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \
--hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \
--hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \
--hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645
# via -r requirements.in
flask==3.1.3
dnspython==2.8.0 \
--hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af \
--hash=sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f
# via
# -r requirements.in
# email-validator
email-validator==2.3.0 \
--hash=sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 \
--hash=sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426
# via -r requirements.in
flask==3.1.3 \
--hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \
--hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c
# via
# -r requirements.in
# flask-caching
@@ -29,94 +355,378 @@ flask==3.1.3
# flask-marshmallow
# flask-migrate
# flask-sqlalchemy
# pytest-flask
flask-caching==2.4.0
flask-caching==2.4.0 \
--hash=sha256:a7f14e43617cd0612a57bec2f80516d6d2ec888bfc50a807b1e4e41ca345a3b5 \
--hash=sha256:d15b8135f055c4f28f6f7dbcf8d36a3de4af1224def975ae6e0b43cbfa684486
# via -r requirements.in
flask-cors==6.0.2
flask-cors==6.0.2 \
--hash=sha256:6e118f3698249ae33e429760db98ce032a8bf9913638d085ca0f4c5534ad2423 \
--hash=sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a
# via -r requirements.in
flask-jwt-extended==4.7.3
flask-jwt-extended==4.7.3 \
--hash=sha256:905ac807b52b5409bc9244dbcca434968c13ca6f9d91bffe7d4cb71e0e6231cb \
--hash=sha256:a8318a3d983d1f360724b901889f1947ffee418e2a3950b48e99c70923b6615e
# via -r requirements.in
flask-marshmallow==1.5.0
flask-marshmallow==1.5.0 \
--hash=sha256:7c06b56e41647eccdb3cd57c25b109f19191b4c62509362bd64920cdf601a066 \
--hash=sha256:99951c77e5654111ed733811c6dc9310bfb4c3688c78a9e76f80b5ae0b2279a6
# via -r requirements.in
flask-migrate==4.1.0
flask-migrate==4.1.0 \
--hash=sha256:1a336b06eb2c3ace005f5f2ded8641d534c18798d64061f6ff11f79e1434126d \
--hash=sha256:24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d
# via -r requirements.in
flask-sqlalchemy==3.1.1
flask-sqlalchemy==3.1.1 \
--hash=sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0 \
--hash=sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312
# via
# -r requirements.in
# flask-migrate
greenlet==3.5.0
# via sqlalchemy
idna==3.13
greenlet==3.5.0 \
--hash=sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846 \
--hash=sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4 \
--hash=sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662 \
--hash=sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce \
--hash=sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2 \
--hash=sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588 \
--hash=sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13 \
--hash=sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e \
--hash=sha256:29ea813b2e1f45fa9649a17853b2b5465c4072fbcb072e5af6cd3a288216574a \
--hash=sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3 \
--hash=sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b \
--hash=sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033 \
--hash=sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628 \
--hash=sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136 \
--hash=sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b \
--hash=sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d \
--hash=sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2 \
--hash=sha256:4b28037cb07768933c54d81bfe47a85f9f402f57d7d69743b991a713b63954eb \
--hash=sha256:4d0eadc7e4d9ffb2af4247b606cae307be8e448911e5a0d0b16d72fc3d224cfd \
--hash=sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b \
--hash=sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1 \
--hash=sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16 \
--hash=sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d \
--hash=sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106 \
--hash=sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba \
--hash=sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c \
--hash=sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc \
--hash=sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7 \
--hash=sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339 \
--hash=sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b \
--hash=sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae \
--hash=sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8 \
--hash=sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2 \
--hash=sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5 \
--hash=sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf \
--hash=sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f \
--hash=sha256:804a70b328e706b785c6ef16187051c394a63dd1a906d89be24b6ad77759f13f \
--hash=sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2 \
--hash=sha256:884f649de075b84739713d41dd4dfd41e2b910bfb769c4a3ea02ec1da52cd9bb \
--hash=sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082 \
--hash=sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7 \
--hash=sha256:9c615f869163e14bb1ced20322d8038fb680b08236521ac3f30cd4c1288785a0 \
--hash=sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c \
--hash=sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853 \
--hash=sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988 \
--hash=sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3 \
--hash=sha256:ba8f0bdc2fae6ce915dfd0c16d2d00bca7e4247c1eae4416e06430e522137858 \
--hash=sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37 \
--hash=sha256:cda05425526240807408156b6960a17a79a0c760b813573b67027823be760977 \
--hash=sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4 \
--hash=sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8 \
--hash=sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86 \
--hash=sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f \
--hash=sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112 \
--hash=sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e \
--hash=sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2 \
--hash=sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8 \
--hash=sha256:f8c30c2225f40dd76c50790f0eb3b5c7c18431efb299e2782083e1981feed243 \
--hash=sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564
# via
# -r requirements.in
# sqlalchemy
idna==3.13 \
--hash=sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242 \
--hash=sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3
# via
# -r requirements.in
# email-validator
# requests
iniconfig==2.3.0
# via pytest
itsdangerous==2.2.0
# via flask
jinja2==3.1.6
# via flask
mako==1.3.12
# via alembic
markupsafe==3.0.3
itsdangerous==2.2.0 \
--hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \
--hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173
# via
# -r requirements.in
# flask
jinja2==3.1.6 \
--hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
--hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
# via
# -r requirements.in
# flask
mako==1.3.12 \
--hash=sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9 \
--hash=sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a
# via
# -r requirements.in
# alembic
markupsafe==3.0.3 \
--hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \
--hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \
--hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \
--hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \
--hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
--hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \
--hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
--hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \
--hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \
--hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \
--hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
--hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \
--hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \
--hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \
--hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \
--hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
--hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \
--hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \
--hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \
--hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \
--hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
--hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \
--hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \
--hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
--hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \
--hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \
--hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \
--hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \
--hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \
--hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \
--hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
--hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \
--hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \
--hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
--hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \
--hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
--hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \
--hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
--hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \
--hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \
--hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
--hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
--hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \
--hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \
--hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \
--hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \
--hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \
--hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \
--hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \
--hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \
--hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \
--hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
--hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \
--hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \
--hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \
--hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \
--hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \
--hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \
--hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \
--hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \
--hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \
--hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
--hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \
--hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \
--hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \
--hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
--hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \
--hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \
--hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \
--hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \
--hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \
--hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \
--hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \
--hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \
--hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \
--hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \
--hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \
--hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
--hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
--hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \
--hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
--hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
--hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
--hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
--hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
--hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \
--hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \
--hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \
--hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
# via
# -r requirements.in
# flask
# jinja2
# mako
# werkzeug
marshmallow==4.3.0
# via
# flask-marshmallow
# marshmallow-sqlalchemy
marshmallow-sqlalchemy==1.5.0
# via -r requirements.in
mysql-connector-python==9.7.0
# via -r requirements.in
packaging==26.2
# via pytest
pluggy==1.6.0
# via
# pytest
# pytest-cov
pygments==2.20.0
# via pytest
pyjwt==2.12.1
# via flask-jwt-extended
pymysql==1.1.3
cryptography>=42.0
# via -r requirements.in
pytest==9.0.3
marshmallow==4.3.0 \
--hash=sha256:46c4fe6984707e3cbd485dfebbf0a59874f58d695aad05c1668d15e8c6e13b46 \
--hash=sha256:fb43c53b3fe240b8f6af37223d6ef1636f927ad9bea8ab323afad95dff090880
# via
# -r requirements.in
# pytest-cov
# pytest-flask
pytest-cov==7.1.0
# flask-marshmallow
# marshmallow-sqlalchemy
marshmallow-sqlalchemy==1.5.0 \
--hash=sha256:3865232672f3dd38c4d5e4e85fdedce76904200742c3594948a2d11d0af93258 \
--hash=sha256:e51192c204770645a2fab0d72f44f8789272eef75951f84b1608d6b4b0bfe0e6
# via -r requirements.in
pytest-flask==1.3.0
mysql-connector-python==9.7.0 \
--hash=sha256:079ce68d617250ef5cefffd5243056dc9f87c6034c804235fd6f45a0daf5a6ae \
--hash=sha256:15106ed73d74487f86de6b1859ff7f362efca7c7f9c494497ccb7439d3139fe6 \
--hash=sha256:229ffce2333b88b59f8f034881e12dc85b309615338be9f843ed63923db99d52 \
--hash=sha256:236a4b8abfac8217517ce9e9edc735decf55282ada3890b3cd33770009f33d68 \
--hash=sha256:272c4e8263f6a514e4ae65e9553ed214fc9d4cf1f0f6bedca30def20c2ae1d52 \
--hash=sha256:2eae45230b5cbd783d68bdfe8b05ad9b4ebd06799f8d302a6169d7f025572baf \
--hash=sha256:39bf31cdbe920eae08801eb829584dc27f70dadbdb3a5c3b78402f54cea87ab0 \
--hash=sha256:45491d4ce56722cb335e6d0bde2d4f4a98b7073421bd02a04ad5e6220d69e499 \
--hash=sha256:5492d57a6a0e5127a928290737fbb91b66b46d31dac8de3e7604e550bf3b3a6e \
--hash=sha256:5a5abbc152bc28cb2e64a04605ecd9941eff6b0dc5f9528cb84adb873e9a1e49 \
--hash=sha256:6546e0b60c275409a5add9e3308c3897fcf478d1338cd845b1664c1a8946f72f \
--hash=sha256:933887e71c871b6e9d8908459fe8303ebcf8feb5cc1e1c49caa6490e525cf78e \
--hash=sha256:9549a2974353407427b574f005c92f03972e303182b4c3b4a272bf4ca10855ed \
--hash=sha256:9bdfc2d4c4444cd1cc79cc6487c047b28fe2b26d0327b27eb9f5737bb553cb5c \
--hash=sha256:9da73e212bb08e4df286506ffbda943063bbce448375a7db29748bb367f4e0af \
--hash=sha256:9e17ceb79c1c4c137a5994f8ca8c6a1ce8ebf83eda3ccc21dd67f2c1398680f0 \
--hash=sha256:a2f371ab69d65c61136c51ad7026017400166cef3c959cab7a9fb668c7acbfba \
--hash=sha256:af80b1e7179d5c2d983cf62470ad9b134a7e9ef05cf31108ae587f15873530cc \
--hash=sha256:b5cb8a3ba42b539f79cd13e4c8376d28506f3180f7079c9b04ea7bfd0424fb03 \
--hash=sha256:b8d3d6f32b95ae1d0a2f29d1a2b4e628849bcf7e84a70dc56c85b4929361d86b \
--hash=sha256:c51be697bfdfdf63bb71c5ecc51f7c6faf4aaa3d14a0136fa16e97cc37df1185 \
--hash=sha256:d5924a76b530159c02f2fe8da4d3c6377ce1f5e195827e8ecbd36124673651d3 \
--hash=sha256:daf70f7da64a2e7c17e6dfd1fc98d2deb653fd955d0bd0b1fef246356e682b0f \
--hash=sha256:e3842ecd62391a28c1dda5eb817f40418715e68482a8c146b3c478ac8bb7a23f \
--hash=sha256:ea4e05ab864ab8b0b5066d885d89ca51e5ba1320dba520c20a6f9388a8eca022 \
--hash=sha256:ee90c5f44f706f012be17f03f6ad158ff96e7f2dcc077896fe4537d3d28b3cf4
# via -r requirements.in
python-dotenv==1.2.2
# via -r requirements.in
requests==2.33.1
# via -r requirements.in
sqlalchemy==2.0.49
pycparser==3.0 \
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
# via
# -r requirements.in
# cffi
pyjwt==2.12.1 \
--hash=sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c \
--hash=sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b
# via
# -r requirements.in
# flask-jwt-extended
pymysql==1.1.3 \
--hash=sha256:8164ba62c552f6105f3b11753352d0f16b90d1703ba67d81923d5a8a5d1c5289 \
--hash=sha256:e70ebf2047a4edf6138cf79c68ad418ef620af65900aa585c5e8bfc95044d43a
# via -r requirements.in
python-dotenv==1.2.2 \
--hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \
--hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3
# via -r requirements.in
requests==2.33.1 \
--hash=sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517 \
--hash=sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a
# via -r requirements.in
sqlalchemy==2.0.49 \
--hash=sha256:01146546d84185f12721a1d2ce0c6673451a7894d1460b592d378ca4871a0c72 \
--hash=sha256:059d7151fff513c53a4638da8778be7fce81a0c4854c7348ebd0c4078ddf28fe \
--hash=sha256:0c98c59075b890df8abfcc6ad632879540f5791c68baebacb4f833713b510e75 \
--hash=sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5 \
--hash=sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148 \
--hash=sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7 \
--hash=sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e \
--hash=sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518 \
--hash=sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7 \
--hash=sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700 \
--hash=sha256:334edbcff10514ad1d66e3a70b339c0a29886394892490119dbb669627b17717 \
--hash=sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672 \
--hash=sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88 \
--hash=sha256:42e8804962f9e6f4be2cbaedc0c3718f08f60a16910fa3d86da5a1e3b1bfe60f \
--hash=sha256:43d044780732d9e0381ac8d5316f95d7f02ef04d6e4ef6dc82379f09795d993f \
--hash=sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08 \
--hash=sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a \
--hash=sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3 \
--hash=sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b \
--hash=sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536 \
--hash=sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0 \
--hash=sha256:566df36fd0e901625523a5a1835032f1ebdd7f7886c54584143fa6c668b4df3b \
--hash=sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a \
--hash=sha256:5e61abbec255be7b122aa461021daa7c3f310f3e743411a67079f9b3cc91ece3 \
--hash=sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4 \
--hash=sha256:62557958002b69699bdb7f5137c6714ca1133f045f97b3903964f47db97ea339 \
--hash=sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158 \
--hash=sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066 \
--hash=sha256:69469ce8ce7a8df4d37620e3163b71238719e1e2e5048d114a1b6ce0fbf8c662 \
--hash=sha256:6eb188b84269f357669b62cb576b5b918de10fb7c728a005fa0ebb0b758adce1 \
--hash=sha256:74ab4ee7794d7ed1b0c37e7333640e0f0a626fc7b398c07a7aef52f484fddde3 \
--hash=sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5 \
--hash=sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01 \
--hash=sha256:7d6be30b2a75362325176c036d7fb8d19e8846c77e87683ffaa8177b35135613 \
--hash=sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a \
--hash=sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0 \
--hash=sha256:88690f4e1f0fbf5339bedbb127e240fec1fd3070e9934c0b7bef83432f779d2f \
--hash=sha256:8a97ac839c2c6672c4865e48f3cbad7152cee85f4233fb4ca6291d775b9b954a \
--hash=sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e \
--hash=sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2 \
--hash=sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af \
--hash=sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014 \
--hash=sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33 \
--hash=sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61 \
--hash=sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d \
--hash=sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187 \
--hash=sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401 \
--hash=sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b \
--hash=sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d \
--hash=sha256:b95b2f470c1b2683febd2e7eab1d3f0e078c91dbdd0b00e9c645d07a413bb99f \
--hash=sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba \
--hash=sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977 \
--hash=sha256:c338ec6ec01c0bc8e735c58b9f5d51e75bacb6ff23296658826d7cfdfdb8678a \
--hash=sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe \
--hash=sha256:cc992c6ed024c8c3c592c5fc9846a03dd68a425674900c70122c77ea16c5fb0b \
--hash=sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f \
--hash=sha256:d898cc2c76c135ef65517f4ddd7a3512fb41f23087b0650efb3418b8389a3cd1 \
--hash=sha256:d99945830a6f3e9638d89a28ed130b1eb24c91255e4f24366fbe699b983f29e4 \
--hash=sha256:da9b91bca419dc9b9267ffadde24eae9b1a6bffcd09d0a207e5e3af99a03ce0d \
--hash=sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120 \
--hash=sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750 \
--hash=sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0 \
--hash=sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982
# via
# -r requirements.in
# alembic
# flask-sqlalchemy
# marshmallow-sqlalchemy
tabulate==0.10.0
tabulate==0.10.0 \
--hash=sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d \
--hash=sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3
# via -r requirements.in
typing-extensions==4.15.0
typing-extensions==4.15.0 \
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
# via
# -r requirements.in
# alembic
# sqlalchemy
urllib3==2.7.0
# via requests
werkzeug==3.1.8
tzdata==2026.3 \
--hash=sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415 \
--hash=sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931
# via -r requirements.in
urllib3==2.7.0 \
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
# via
# -r requirements.in
# requests
waitress==3.0.2 \
--hash=sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f \
--hash=sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e
# via -r requirements.in
werkzeug==3.1.8 \
--hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \
--hash=sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44
# via
# -r requirements.in
# flask
# flask-cors
# flask-jwt-extended
# pytest-flask
waitress>=3.0
tzdata

View File

@@ -17,40 +17,31 @@ OUT="${2:-$REPO/build/site}"
[ -f "$PROFILE" ] || { echo "profile not found: $PROFILE"; exit 1; }
# Resolve the chosen plugins + their hard-dependency closure from the manifests.
CLOSURE=$(python3 - "$PROFILE" "$REPO" <<'PY'
import json, sys, os
profile_path, repo = sys.argv[1], sys.argv[2]
chosen = json.load(open(profile_path)).get('plugins', [])
plugins_dir = os.path.join(repo, 'plugins')
def deps(name):
mpath = os.path.join(plugins_dir, name, 'manifest.json')
if not os.path.exists(mpath):
sys.exit(f'profile plugin not found on disk: {name}')
out = []
for dep in json.load(open(mpath)).get('dependencies', []):
# name-only (strip any PEP440 range)
for sep in '><=!~ ':
dep = dep.split(sep)[0]
out.append(dep.strip())
return out
closure, seen = [], set()
def add(name):
if name in seen: return
seen.add(name)
for d in deps(name): add(d)
closure.append(name)
for p in chosen: add(p)
print(','.join(closure))
PY
)
# Shared with the Windows builder (deploy/windows/installer/build-installer.ps1)
# so both stage the same set from the same profile.
CLOSURE=$(python3 "$REPO/scripts/resolve_plugin_closure.py" "$PROFILE" "$REPO")
echo "Site profile: $PROFILE"
echo "Plugin closure: $CLOSURE"
# Frontend: build carrying only the closure's plugins.
echo "==> Building frontend (SITE_PLUGINS=$CLOSURE) ..."
# Frontend: TWO builds, because Vite compiles the mount path into the bundle and
# it therefore cannot be chosen at install time from a single build - the page
# would load and then request its assets from the wrong path.
# dist -> own IIS site (method A, the default)
# dist-subpath -> IIS Application under an existing site (method B)
# SUBPATH_ALIAS is fixed per bundle so the three places that must agree - the IIS
# application alias, MOUNT_PATH and this base - cannot drift apart.
SUBPATH_ALIAS="${SUBPATH_ALIAS:-shopdb}"
SUBPATH_TMP="$(mktemp -d)"
trap 'rm -rf "$SUBPATH_TMP"' EXIT
echo "==> Building frontend for /$SUBPATH_ALIAS/ (SITE_PLUGINS=$CLOSURE) ..."
( cd "$REPO/frontend" && SITE_PLUGINS="$CLOSURE" VITE_BASE_PATH="/$SUBPATH_ALIAS/" npm run build --silent )
cp -r "$REPO/frontend/dist" "$SUBPATH_TMP/dist-subpath"
echo "$SUBPATH_ALIAS" > "$SUBPATH_TMP/dist-subpath/.alias"
# Root build LAST, so frontend/dist is left in the state a developer expects.
echo "==> Building frontend for / (SITE_PLUGINS=$CLOSURE) ..."
( cd "$REPO/frontend" && SITE_PLUGINS="$CLOSURE" npm run build --silent )
# Backend: stage core + only the chosen plugin dirs.
@@ -64,6 +55,60 @@ for name in ${CLOSURE//,/ }; do
cp -a "$REPO/plugins/$name" "$OUT/plugins/"
done
cp -r "$REPO/frontend/dist" "$OUT/frontend-dist"
# Staged after the rm -rf above, or it would be deleted with everything else.
cp -r "$SUBPATH_TMP/dist-subpath" "$OUT/frontend-dist-subpath"
# Runtime files a deployable tree needs beyond the Python packages. Without these
# the staged tree can be imported but not actually run or migrated, so the
# Windows installer (which consumes this output as its app\ payload) had to
# assemble them separately - and could assemble a tree whose plugin set did not
# match the profile it was built from.
cp "$REPO/wsgi.py" "$REPO/requirements.txt" "$OUT/"
cp -a "$REPO/migrations" "$OUT/"
# ONLY web.config, not all of deploy/. Two reasons, and the first is fatal:
# the Windows builder stages its output at deploy/windows/installer/bundle, so
# copying deploy/ wholesale recursed into the destination and cp aborted with
# "cannot copy a directory into itself" - the documented build could not finish.
# Second, the rest of deploy/ is installer SOURCE (scripts, artwork, MSIs); none
# of it belongs in an application tree that gets copied onto a server.
# shopdb-install.ps1 reads it from exactly this path.
if [ -f "$REPO/deploy/windows/web.config" ]; then
mkdir -p "$OUT/deploy/windows"
cp -a "$REPO/deploy/windows/web.config" "$OUT/deploy/windows/"
fi
# A CycloneDX SBOM of everything this tree depends on, Python and npm together.
# Staged INTO the tree so it installs onto the server with the application: an
# air-gapped site cannot be scanned remotely, so the only way to answer "are we
# exposed to this CVE, and where" is for the answer to be sitting on the box.
# Generated from requirements.txt and package-lock.json, both already pinned and
# committed, so it is a translation rather than a scan - and deterministic.
echo "==> Generating SBOM ..."
python3 "$REPO/scripts/generate_sbom.py" "$REPO" -o "$OUT/sbom.cdx.json"
# Docs the RUNNING SITE serves or the operator needs on the box. Without
# openapi.json and llms.txt the self-hosted /api/docs page is broken on every
# installed server - which matters most for the sites least able to debug it, and
# for the operators who will point an assistant at their own instance. The
# Windows runbooks ship because an air-gapped server has no other way to reach
# them; docs/ is not otherwise staged, so shipping the whole tree would be noise.
echo "==> Staging docs ..."
mkdir -p "$OUT/docs"
for doc in openapi.json llms.txt api-inventory.json \
INSTALL-WINDOWS.md OPERATE-WINDOWS.md BACKUP-RESTORE.md UPGRADE.md; do
[ -f "$REPO/docs/$doc" ] && cp -a "$REPO/docs/$doc" "$OUT/docs/"
done
# Never let a missing optional doc fail the build - the loop's last test governs
# the exit status under `set -e`.
true
# Stage the profile INTO the tree. This is what makes the set self-describing:
# `flask plugin apply-profile` at provisioning time reads the same profile the
# tree was staged from, so the installed plugin set and the shipped plugin code
# cannot drift. It is also what `flask plugin prune-schema` effectively keys off
# (via what ends up installed), so a mismatch here would drop the wrong tables.
cp "$PROFILE" "$OUT/site-profile.json"
find "$OUT" -type d -name '__pycache__' -prune -exec rm -rf {} +
find "$OUT" -type f -name '*.pyc' -delete
@@ -71,3 +116,8 @@ echo ""
echo "Lean site staged at: $OUT"
echo " backend plugins: $(ls "$OUT/plugins" | tr '\n' ' ')"
echo " (a plugin not listed is absent from both the backend tree and the bundle)"
echo " profile staged as: $OUT/site-profile.json"
echo ""
echo "At provisioning, after 'flask db upgrade' and 'flask plugin upgrade-all':"
echo " flask plugin apply-profile site-profile.json"
echo " flask plugin prune-schema --yes --force # ADR-014; BOTH flags required"

View File

@@ -9,7 +9,8 @@
# 4. No snake_case API params in frontend that should match DB column names
#
# Exits non-zero if any violation found.
# Skips: venv/, node_modules/, __pycache__/, frontend/dist/, migrations/versions/
# Skips: venv/, node_modules/, __pycache__/, frontend/dist/, migrations/versions/,
# deploy/windows/installer/bundle/ (installer build output)
set -e
@@ -23,6 +24,12 @@ EXCLUDES=(
--exclude-dir=node_modules
--exclude-dir=__pycache__
--exclude-dir=dist
# The installer's build output: a staged copy of the whole application plus
# a second SPA build under dist-subpath, which --exclude-dir=dist does not
# match. Linting it means linting vendored minified JS and failing on
# characters nobody in this repository wrote.
--exclude-dir=bundle
--exclude-dir=dist-subpath
--exclude-dir=.git
--exclude-dir=versions
--exclude-dir=staticdocs
@@ -46,8 +53,12 @@ INCLUDES_ALL=(
--include='*.yml'
)
# INCLUDES_ALL, not INCLUDES_CODE: the check only ever covered .py/.vue/.js/.ts,
# so documentation was free to accumulate em-dashes, arrows and smart quotes -
# and did, including in files this repo's own convention forbids them in.
# Markdown, JSON and YAML are now covered too.
echo "==> Checking for non-ASCII characters..."
NON_ASCII=$(grep -rPn '[^\x00-\x7F]' "${EXCLUDES[@]}" "${INCLUDES_CODE[@]}" . 2>/dev/null || true)
NON_ASCII=$(grep -rPn '[^\x00-\x7F]' "${EXCLUDES[@]}" "${INCLUDES_ALL[@]}" . 2>/dev/null || true)
if [ -n "$NON_ASCII" ]; then
echo "FAIL: non-ASCII characters found (em-dashes, smart quotes, arrows, emojis):"
echo "$NON_ASCII"

348
scripts/generate_sbom.py Normal file
View File

@@ -0,0 +1,348 @@
#!/usr/bin/env python3
"""Emit a CycloneDX 1.6 SBOM for this application: Python and npm together.
Why this is hand-rolled rather than cyclonedx-py plus cyclonedx-npm:
- It has to run on the machine that builds the installer, which may be a work
PC with nothing but Python and Node. Two more toolchains to install, keep
current and match versions across build boxes is a real cost for output this
small.
- Both inputs are already fully pinned and committed - requirements.txt with a
sha256 per package, package-lock.json with an integrity per package - so
there is nothing to resolve. This is a format translation, not a scan.
- It is deterministic by construction, which the packaged tools are not
without extra flags. Same inputs, byte-identical output.
WHAT IT COVERS
Python every pin in requirements.txt, with the sha256 the installer enforces.
Environment markers are ignored on purpose: a requirement guarded by
sys_platform == 'win32' still installs on the target, which is Windows.
npm every package in frontend/package-lock.json. Development-only packages
are included but marked scope 'excluded', because they do not ship
inside the compiled SPA - a reader answering "are we exposed" needs to
see the distinction, not a list with them silently missing.
Dependency edges are real, not flat: uv writes '# via <parent>' comments into
requirements.txt, and package-lock records each package's dependencies.
NOT COVERED: the application's own source, which git records, and the operating
system. See deploy/windows/installer/bundle-lock.json for the third-party
binaries (Python installer, IIS MSIs) that ship alongside this.
Usage:
generate_sbom.py <repo-root> [-o out.json] [--timestamp ISO8601] [--appversion X]
"""
import argparse
import base64
import binascii
import hashlib
import json
import os
import re
import sys
import uuid
from datetime import datetime, timezone
SPEC_VERSION = '1.6'
GENERATOR = 'scripts/generate_sbom.py'
# Fixed namespace so the same component set always yields the same serial number.
# A random UUID per run would make every SBOM differ from the last for no reason.
SERIAL_NAMESPACE = uuid.UUID('6f9d1a1e-2b3c-4d5e-8f90-a1b2c3d4e5f6')
def normalize(name):
return re.sub(r'[^A-Za-z0-9.]+', '_', name).lower()
def read_app_version(repo):
init = os.path.join(repo, 'shopdb', '__init__.py')
try:
with open(init) as fh:
match = re.search(r"^__version__\s*=\s*'([^']+)'", fh.read(), re.M)
if match:
return match.group(1)
except OSError:
pass
return '0.0.0'
def parse_requirements(path):
"""Pins, their sha256 list, and which package pulled each one in.
uv emits, per entry:
flask==3.1.3 \\
--hash=sha256:... \\
--hash=sha256:...
# via
# -r requirements.in
# flask-migrate
'-r <file>' means the site asked for it directly; anything else is a parent.
"""
components, order = {}, []
current = None
in_via = False
with open(path) as fh:
for raw in fh:
line = raw.rstrip('\n')
stripped = line.strip()
match = re.match(r'^([A-Za-z0-9._-]+)==([^\s;\\]+)', stripped)
if match and not stripped.startswith('#'):
current = normalize(match.group(1))
in_via = False
if current not in components:
order.append(current)
components[current] = {
'name': match.group(1), 'version': match.group(2),
'hashes': [], 'parents': [], 'direct': False,
}
continue
if current is None:
continue
hash_match = re.search(r'--hash=sha256:([a-f0-9]{64})', stripped)
if hash_match:
components[current]['hashes'].append(hash_match.group(1))
continue
if stripped.startswith('#'):
body = stripped.lstrip('#').strip()
if body == 'via':
in_via = True
continue
if body.startswith('via '):
in_via = True
body = body[4:].strip()
if in_via and body:
if body.startswith('-r ') or body.startswith('-c '):
components[current]['direct'] = True
else:
components[current]['parents'].append(normalize(body))
continue
in_via = False
return [components[key] for key in order]
def integrity_to_hash(integrity):
"""npm 'sha512-<base64>' becomes a CycloneDX hash entry."""
if not integrity or '-' not in integrity:
return None
algorithm, _, encoded = integrity.partition('-')
algorithms = {'sha512': 'SHA-512', 'sha256': 'SHA-256', 'sha1': 'SHA-1'}
if algorithm not in algorithms:
return None
try:
digest = base64.b64decode(encoded)
except (binascii.Error, ValueError):
return None
return {'alg': algorithms[algorithm], 'content': digest.hex()}
def parse_package_lock(path):
"""Every locked package, plus the names the project depends on DIRECTLY.
The direct set comes from the lockfile's root entry. Without it the SBOM
claimed the application depended directly on all ~110 shipped packages,
which flattens the graph and makes 'what did we choose' unanswerable.
"""
with open(path) as fh:
data = json.load(fh)
packages = data.get('packages', {})
root = packages.get('', {})
direct = sorted((root.get('dependencies') or {}).keys())
# Keyed by (name, version), because npm installs the same package at several
# depths: node_modules/vite AND node_modules/vitest/node_modules/vite are two
# entries for one component. Emitting both produced duplicate bom-refs, which
# CycloneDX forbids and scanners reject.
merged = {}
for key, meta in packages.items():
if not key.startswith('node_modules/'):
continue
# Nested paths (a/node_modules/b) name the package after the LAST segment.
name = key.split('node_modules/')[-1]
version = meta.get('version')
if not version:
continue
entry = merged.get((name, version))
if entry is None:
merged[(name, version)] = {
'name': name,
'version': version,
'dev': bool(meta.get('dev') or meta.get('devOptional')),
'license': meta.get('license'),
'hash': integrity_to_hash(meta.get('integrity')),
'deps': set((meta.get('dependencies') or {}).keys()),
}
continue
# One copy reachable outside the dev tree means the component ships, so
# 'dev' only survives while EVERY instance is dev. Getting this backwards
# would mark a shipped package build-only and hide it from a CVE search.
entry['dev'] = entry['dev'] and bool(meta.get('dev') or meta.get('devOptional'))
entry['license'] = entry['license'] or meta.get('license')
entry['hash'] = entry['hash'] or integrity_to_hash(meta.get('integrity'))
entry['deps'] |= set((meta.get('dependencies') or {}).keys())
out = []
for entry in merged.values():
entry['deps'] = sorted(entry['deps'])
out.append(entry)
out.sort(key=lambda component: (component['name'], component['version']))
return out, direct
def purl(ecosystem, name, version):
from urllib.parse import quote
return 'pkg:%s/%s@%s' % (ecosystem, quote(name, safe='@/'), quote(version, safe=''))
def build(repo, timestamp, app_version):
requirements = os.path.join(repo, 'requirements.txt')
lockfile = os.path.join(repo, 'frontend', 'package-lock.json')
components, dependencies = [], []
root_ref = 'shopdb-flask@%s' % app_version
root_deps = []
pips = parse_requirements(requirements) if os.path.exists(requirements) else []
by_normalized = {}
for pin in pips:
ref = purl('pypi', pin['name'], pin['version'])
by_normalized[normalize(pin['name'])] = ref
entry = {
'type': 'library',
'bom-ref': ref,
'name': pin['name'],
'version': pin['version'],
'purl': ref,
'scope': 'required',
'properties': [{'name': 'shopdb:ecosystem', 'value': 'python'}],
}
if pin['hashes']:
entry['hashes'] = [{'alg': 'SHA-256', 'content': value}
for value in sorted(pin['hashes'])]
components.append(entry)
for pin in pips:
ref = by_normalized[normalize(pin['name'])]
if pin['direct']:
root_deps.append(ref)
for parent in pin['parents']:
parent_ref = by_normalized.get(parent)
if parent_ref:
dependencies.append((parent_ref, ref))
npms, npm_direct = parse_package_lock(lockfile) if os.path.exists(lockfile) else ([], [])
npm_refs = {}
for package in npms:
ref = purl('npm', package['name'], package['version'])
npm_refs.setdefault(package['name'], ref)
entry = {
'type': 'library',
'bom-ref': ref,
'name': package['name'],
'version': package['version'],
'purl': ref,
# Development packages do not ship inside the compiled SPA. Recorded
# rather than dropped so the distinction is visible.
'scope': 'excluded' if package['dev'] else 'required',
'properties': [{'name': 'shopdb:ecosystem', 'value': 'npm'}],
}
if package['hash']:
entry['hashes'] = [package['hash']]
if isinstance(package['license'], str):
entry['licenses'] = [{'license': {'id': package['license']}}]
components.append(entry)
for name in npm_direct:
ref = npm_refs.get(name)
if ref:
root_deps.append(ref)
for package in npms:
ref = npm_refs.get(package['name'])
for name in package['deps']:
child = npm_refs.get(name)
if child and ref:
dependencies.append((ref, child))
graph = {}
for parent, child in dependencies:
graph.setdefault(parent, set()).add(child)
depends_on = [{'ref': root_ref, 'dependsOn': sorted(set(root_deps))}]
for ref in sorted(graph):
depends_on.append({'ref': ref, 'dependsOn': sorted(graph[ref])})
components.sort(key=lambda component: component['bom-ref'])
fingerprint = hashlib.sha256(
json.dumps([component['bom-ref'] for component in components],
sort_keys=True).encode()).hexdigest()
serial = uuid.uuid5(SERIAL_NAMESPACE, fingerprint)
return {
'bomFormat': 'CycloneDX',
'specVersion': SPEC_VERSION,
'serialNumber': 'urn:uuid:%s' % serial,
'version': 1,
'metadata': {
'timestamp': timestamp,
'tools': {'components': [{
'type': 'application', 'name': GENERATOR, 'version': app_version,
}]},
'authors': [{'name': 'GE Aerospace'}],
'supplier': {'name': 'GE Aerospace'},
'component': {
'type': 'application',
'bom-ref': root_ref,
'name': 'shopdb-flask',
'version': app_version,
'description': 'Asset management for the shop floor.',
},
},
'components': components,
'dependencies': depends_on,
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('repo')
parser.add_argument('-o', '--output', default='-')
# Pinned by the caller, or by SOURCE_DATE_EPOCH, so two builds of one commit
# produce byte-identical files.
parser.add_argument('--timestamp', default=None)
parser.add_argument('--appversion', default=None)
args = parser.parse_args()
timestamp = args.timestamp
if not timestamp:
epoch = os.environ.get('SOURCE_DATE_EPOCH')
moment = (datetime.fromtimestamp(int(epoch), timezone.utc) if epoch
else datetime.now(timezone.utc))
timestamp = moment.strftime('%Y-%m-%dT%H:%M:%SZ')
version = args.appversion or read_app_version(args.repo)
document = build(args.repo, timestamp, version)
text = json.dumps(document, indent=2, sort_keys=False) + '\n'
if args.output == '-':
sys.stdout.write(text)
else:
directory = os.path.dirname(args.output)
if directory and not os.path.isdir(directory):
os.makedirs(directory)
with open(args.output, 'w') as fh:
fh.write(text)
counts = {}
for component in document['components']:
key = component['properties'][0]['value']
counts[key] = counts.get(key, 0) + 1
shipped = sum(1 for component in document['components']
if component['scope'] == 'required')
print('%s: %d components (%s), %d shipped' % (
args.output, len(document['components']),
', '.join('%s %d' % (k, counts[k]) for k in sorted(counts)), shipped))
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Resolve a site profile's chosen plugins plus their hard-dependency closure.
Printed as a comma-separated list, dependencies before dependants, so the caller
can stage them in order.
This lives in its own file because TWO builders need the same answer:
scripts/build-site.sh (Linux) and deploy/windows/installer/build-installer.ps1
(Windows). It was inline in build-site.sh; a PowerShell reimplementation would
have been a second copy of the closure rules, free to drift from this one and
produce a bundle whose plugin set did not match the profile it was built from.
Usage: resolve_plugin_closure.py <site-profile.json> <repo-root>
"""
import json
import os
import sys
def load_dependencies(plugins_dir, name):
manifest = os.path.join(plugins_dir, name, 'manifest.json')
if not os.path.exists(manifest):
sys.exit('profile plugin not found on disk: %s' % name)
with open(manifest) as fh:
declared = json.load(fh).get('dependencies', [])
names = []
for dep in declared:
# name-only (strip any PEP440 range)
for sep in '><=!~ ':
dep = dep.split(sep)[0]
names.append(dep.strip())
return names
def resolve(profile_path, repo):
with open(profile_path) as fh:
chosen = json.load(fh).get('plugins', [])
plugins_dir = os.path.join(repo, 'plugins')
closure, seen = [], set()
def add(name):
if name in seen:
return
seen.add(name)
for dep in load_dependencies(plugins_dir, name):
add(dep)
closure.append(name)
for name in chosen:
add(name)
return closure
def main():
if len(sys.argv) != 3:
sys.exit('usage: resolve_plugin_closure.py <site-profile.json> <repo-root>')
print(','.join(resolve(sys.argv[1], sys.argv[2])))
if __name__ == '__main__':
main()

View File

@@ -90,7 +90,7 @@ else
python3 -m venv "$WORKDIR/venv"
PYTHON="$WORKDIR/venv/bin/python"
"$PYTHON" -m pip install --upgrade pip >/dev/null
"$PYTHON" -m pip install -r "$FRAMEWORK/requirements.txt"
"$PYTHON" -m pip install -r "$FRAMEWORK/requirements-dev.txt"
fi
echo "==> Linking plugin '$PLUGIN_NAME' into framework plugins/"

View File

@@ -98,6 +98,31 @@ def preflight():
elif version:
ok('MySQL 5.7+/8.0 - no extra index flags needed')
# The database's own default charset. Tables are forced to utf8mb4 by the
# compiler hook (shopdb/utils/mysql_charset.py), so a latin1 default does not
# break the schema - but anything created OUTSIDE that path inherits it, and
# a database created without an explicit CHARSET is the usual cause. Cheap to
# check here, invisible until characters come back mangled.
if version:
try:
row = db.session.execute(text(
'SELECT DEFAULT_CHARACTER_SET_NAME FROM information_schema.SCHEMATA '
'WHERE SCHEMA_NAME = DATABASE()')).fetchone()
charset = row[0] if row else None
if charset is None:
warn('Could not read the database default charset',
'Not fatal - tables are forced to utf8mb4 regardless.')
elif str(charset).lower() == 'utf8mb4':
ok('Database default charset is utf8mb4')
else:
warn(f'Database default charset is {charset}, not utf8mb4',
'Tables this application creates are forced to utf8mb4, so the '
'schema is correct. Fix the default so anything created outside '
'the migrations matches: ALTER DATABASE <name> CHARACTER SET '
'utf8mb4 COLLATE utf8mb4_unicode_ci;')
except Exception as exc:
warn('Could not read the database default charset', f'({exc})')
click.echo('')
if failures:
click.echo(click.style(f'{len(failures)} blocker(s). Fix them before installing.', fg='red', bold=True))

View File

@@ -34,6 +34,14 @@ from typing import Iterable
from alembic import context
from sqlalchemy import MetaData, pool
# Registers the compiler hook that forces utf8mb4 + DYNAMIC on MySQL. Imported
# for the side effect. It used to live inline in migrations/env.py, so it applied
# to the CORE chain only: a plugin's baseline tables were created at the server's
# default charset while core's were utf8mb4, on the same database. On a server
# defaulting to latin1 that difference is invisible until a join between the two
# stops using an index, or a character comes back mangled.
import shopdb.utils.mysql_charset # noqa: F401,E402
logger = logging.getLogger('alembic.env.plugin')
# Explicit table-ownership map. Adding tables to a plugin requires updating

View File

@@ -0,0 +1,40 @@
"""Force every table created on MySQL to utf8mb4 + DYNAMIC row format.
Importing this module installs a SQLAlchemy compiler hook. It is a side effect on
purpose: the hook has to be registered before any CreateTable is compiled, and
every place that creates tables needs it.
WHY. Without it a CREATE TABLE inherits the SERVER's default charset. A MySQL box
defaulting to latin1 - common on older installs, and the West Jefferson 5.6
server is one - silently builds a latin1 schema that drifts from the utf8mb4
production target. Nothing fails at create time; it surfaces later as mangled
characters, or as a join between a utf8mb4 and a latin1 column that cannot use an
index.
DYNAMIC row format is the other half: it keeps utf8mb4 indexes under the 767-byte
prefix limit on pre-5.7 InnoDB, which is what error 1071 ("key too long") is.
This lived inline in migrations/env.py, so it covered the CORE chain only. Plugin
chains (ADR-008) run through shopdb/plugins/alembic_template.py, which never
imported it - so a plugin's baseline tables were created at the server default
while core's were utf8mb4, on the same database. Both import this now.
Scoped to the mysql dialect, so the SQLite test database is untouched.
"""
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.schema import CreateTable
TABLE_SUFFIX = (
" ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
" COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC"
)
@compiles(CreateTable, "mysql")
def _mysql_create_table_utf8mb4(element, compiler, **kw):
sql = compiler.visit_create_table(element, **kw)
# An explicit CHARSET in the model's __table_args__ wins - this is a default,
# not an override.
if "CHARSET" not in sql.upper():
sql = sql.rstrip().rstrip(";") + TABLE_SUFFIX
return sql

231
tests/test_bundle_lock.py Normal file
View File

@@ -0,0 +1,231 @@
"""The installer bundle lock: fail-closed behaviour, and parity between the two
implementations that read it.
There are two verifiers on purpose - bundle-lock.ps1 runs at install time on a
server where PowerShell is the only thing present, verify_bundle_lock.py runs in
the Linux builder without adding pwsh as a build dependency. Two implementations
of one rule drift. These tests run BOTH against the same fixtures and fail if
they disagree, so the drift shows up here rather than as a bundle that one of
them waves through.
The PowerShell half is skipped where pwsh is absent; the Python half always runs.
"""
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
INSTALLER = Path(__file__).resolve().parents[1] / 'deploy' / 'windows' / 'installer'
VERIFY_PY = INSTALLER / 'verify_bundle_lock.py'
LOCK_PS1 = INSTALLER / 'bundle-lock.ps1'
PWSH = shutil.which('pwsh') or shutil.which('powershell')
pytestmark = pytest.mark.skipif(not VERIFY_PY.exists(), reason='installer not in this tree')
def build_bundle(root):
"""A minimal bundle holding every REQUIRED payload directory."""
for name, files in (
('wheels', {'alembic-1.18.4-py3-none-any.whl': b'wheel-a',
'cffi-2.1.0-cp314-cp314-win_amd64.whl': b'wheel-b'}),
('python', {'python-3.14.6-amd64.exe': b'py'}),
('httpplatformhandler', {'httpPlatformHandler_amd64.msi': b'hph'}),
):
directory = root / name
directory.mkdir(parents=True)
for filename, content in files.items():
(directory / filename).write_bytes(content)
return root
def write_lock(bundle, lock_path):
"""Generate the lock the same way refresh-bundle-lock.ps1 does."""
sys.path.insert(0, str(INSTALLER))
import verify_bundle_lock as verifier
payloads = {}
for name, required, _what in verifier.PAYLOADS:
directory = bundle / name
if not directory.is_dir():
continue
payloads[name] = {'required': required, 'files': verifier.payload_files(str(directory))}
lock_path.write_text(json.dumps({
'schema': 1, 'generated': '2026-08-03T00:00:00Z',
'pythontag': 'cp314', 'platform': 'win_amd64', 'payloads': payloads,
}))
def check_python(bundle, lock_path):
result = subprocess.run(
[sys.executable, str(VERIFY_PY), str(bundle), str(lock_path)],
capture_output=True, text=True)
problems = [line for line in result.stdout.splitlines() if line.strip()]
# Exit status and output must agree, or a caller that checks only one of them
# gets a different answer from a caller that checks the other.
assert (result.returncode != 0) == bool(problems), result.stdout
return problems
def check_powershell(bundle, lock_path):
script = (
". '%s'; $p = Test-BundleLock -BundleRoot '%s' -Lock (Read-BundleLock '%s'); "
"if ($p) { $p -join \"`n\" }" % (LOCK_PS1, bundle, lock_path))
result = subprocess.run([PWSH, '-NoProfile', '-Command', script],
capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return [line for line in result.stdout.splitlines() if line.strip()]
def check_both(bundle, lock_path):
"""Returns the Python verdict, asserting PowerShell reports the same set.
Compared as sets: both sort their output, but PowerShell's Sort-Object is
culture-aware and Python's sorted() is ordinal, so the two can order the same
findings differently. What must never differ is WHICH problems are found.
"""
problems = check_python(bundle, lock_path)
if PWSH and LOCK_PS1.exists():
assert sorted(check_powershell(bundle, lock_path)) == sorted(problems), (
'verify_bundle_lock.py and bundle-lock.ps1 disagree')
return problems
@pytest.fixture
def locked(tmp_path):
bundle = build_bundle(tmp_path / 'bundle')
lock = tmp_path / 'bundle-lock.json'
write_lock(bundle, lock)
return bundle, lock
def test_untouched_bundle_passes(locked):
bundle, lock = locked
assert check_both(bundle, lock) == []
def test_extra_file_fails(locked):
"""The gap pip's own hash checking leaves: a stale wheel nobody asked for."""
bundle, lock = locked
(bundle / 'wheels' / 'stale-0.1-py3-none-any.whl').write_bytes(b'left over')
problems = check_both(bundle, lock)
assert any('stale-0.1' in p and 'NOT in the lock' in p for p in problems)
def test_altered_file_fails(locked):
bundle, lock = locked
(bundle / 'wheels' / 'cffi-2.1.0-cp314-cp314-win_amd64.whl').write_bytes(b'swapped')
problems = check_both(bundle, lock)
assert any('does NOT match the lock' in p for p in problems)
def test_missing_file_fails(locked):
bundle, lock = locked
(bundle / 'wheels' / 'alembic-1.18.4-py3-none-any.whl').unlink()
problems = check_both(bundle, lock)
assert any('missing from the bundle' in p for p in problems)
def test_missing_required_payload_fails(locked):
bundle, lock = locked
shutil.rmtree(bundle / 'python')
problems = check_both(bundle, lock)
assert any(p.startswith('python/') for p in problems)
def test_unlocked_payload_directory_fails(locked):
"""An optional payload dropped in after the lock was made is still unreviewed."""
bundle, lock = locked
(bundle / 'mysql').mkdir()
(bundle / 'mysql' / 'mysql-8.0.46-winx64.msi').write_bytes(b'msi')
problems = check_both(bundle, lock)
assert any('mysql/ is present but is not in bundle-lock.json' in p for p in problems)
def test_absent_optional_payload_is_fine(locked):
bundle, lock = locked
assert not (bundle / 'mysql').exists()
assert check_both(bundle, lock) == []
def test_missing_lock_fails_closed(locked):
"""No lock is a failure, not a pass. The build must not fall through to 'fine'."""
bundle, lock = locked
lock.unlink()
result = subprocess.run([sys.executable, str(VERIFY_PY), str(bundle), str(lock)],
capture_output=True, text=True)
assert result.returncode != 0
assert 'no bundle-lock.json' in result.stdout
def test_malformed_lock_reports_rather_than_crashing(locked):
"""A truncated lock must produce a sentence, not a stack trace.
shopdb-install.ps1 runs under Set-StrictMode 2.0, where reading a property
that is not there throws. Both verifiers have to survive a lock missing the
section they read first.
"""
bundle, lock = locked
lock.write_text(json.dumps({'schema': 1, 'pythontag': 'cp314'}))
problems = check_both(bundle, lock)
assert problems == ['bundle-lock.json has no "payloads" section']
def test_lock_missing_files_section_is_not_a_silent_pass(locked):
"""A payload entry with no file list must not read as 'nothing expected, fine'."""
bundle, lock = locked
data = json.loads(lock.read_text())
del data['payloads']['wheels']['files']
lock.write_text(json.dumps(data))
problems = check_both(bundle, lock)
assert problems, 'an entry with no file list let every wheel through'
assert all('wheels/' in p for p in problems)
def test_wheelhouse_must_cover_requirements(locked):
"""The regression that motivated the check.
A wheelhouse assembled anywhere but Windows omits colorama - pip evaluates
sys_platform markers against the downloading machine. The lock alone cannot
catch it, because the lock records what is there, not what is needed.
"""
bundle, lock = locked
app = bundle / 'app'
app.mkdir()
(app / 'requirements.txt').write_text(
"alembic==1.18.4 \\\n --hash=sha256:abc\n"
"colorama==0.4.6 ; sys_platform == 'win32' \\\n --hash=sha256:def\n")
problems = check_both(bundle, lock)
assert any('colorama==0.4.6' in p for p in problems), problems
assert not any('alembic' in p for p in problems), 'alembic has a wheel and must not be flagged'
def test_wheelhouse_coverage_normalizes_names(locked):
"""mysql-connector-python pins as dashes and ships as mysql_connector_python."""
bundle, lock = locked
app = bundle / 'app'
app.mkdir()
(app / 'requirements.txt').write_text('mysql-connector-python==9.7.0\n')
assert any('mysql_connector_python==9.7.0' in p for p in check_both(bundle, lock))
wheel = bundle / 'wheels' / 'mysql_connector_python-9.7.0-cp314-cp314-win_amd64.whl'
wheel.write_bytes(b'connector')
write_lock(bundle, lock) # re-lock so the new wheel is not an 'extra'
assert check_both(bundle, lock) == []
def test_payload_list_matches_powershell():
"""Both files enumerate the payload directories. Same names, same required
flags, same order - a directory that is required in one and optional in the
other means the builder and the installer disagree about what may be absent.
"""
sys.path.insert(0, str(INSTALLER))
import verify_bundle_lock as verifier
declared = re.findall(r"Name\s*=\s*'([a-z]+)'\s*;\s*Required\s*=\s*\$(true|false)",
LOCK_PS1.read_text())
assert declared, 'could not find $script:BundlePayloads in bundle-lock.ps1'
assert declared == [(name, str(required).lower()) for name, required, _ in verifier.PAYLOADS]

View File

@@ -0,0 +1,55 @@
"""The installer wizard's pre-ticked feature list must match the plugin manifests.
The wizard cannot read manifest.json - Inno's Pascal Script has no JSON parser -
so the default set is a hardcoded list in ShopDBFlask.iss. It drifted: two plugins
that ship "default_enabled": false were pre-ticked, so every site taking the
defaults installed and enabled them against their own manifests.
A hardcoded list is fine; a hardcoded list nobody checks is not.
"""
import json
import re
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[1]
ISS = REPO / 'deploy' / 'windows' / 'installer' / 'ShopDBFlask.iss'
PLUGINS = REPO / 'plugins'
pytestmark = pytest.mark.skipif(not ISS.exists(), reason='installer not in this tree')
def manifests_defaulting_off():
off = set()
for manifest in PLUGINS.glob('*/manifest.json'):
try:
data = json.loads(manifest.read_text())
except (ValueError, OSError):
continue
if data.get('default_enabled') is False:
off.add(manifest.parent.name)
return off
def wizard_excludes():
"""The names PluginDefault returns False for."""
body = re.search(r'function PluginDefault.*?\nend;', ISS.read_text(), re.S)
assert body, 'PluginDefault not found in ShopDBFlask.iss'
return set(re.findall(r"Name <> '([a-z_]+)'", body.group(0)))
def test_wizard_defaults_match_the_manifests():
off = manifests_defaulting_off()
assert off, 'no plugin declares default_enabled false - has the field moved?'
assert wizard_excludes() == off, (
'ShopDBFlask.iss PluginDefault disagrees with the manifests.\n'
' manifests default_enabled=false: %s\n'
' wizard leaves unticked: %s' % (sorted(off), sorted(wizard_excludes())))
def test_every_wizard_exclusion_is_a_real_plugin():
"""A typo in the .iss list silently pre-ticks the plugin it meant to exclude."""
for name in wizard_excludes():
assert (PLUGINS / name / 'manifest.json').exists(), (
'%s is excluded by the wizard but has no manifest' % name)

154
tests/test_sbom.py Normal file
View File

@@ -0,0 +1,154 @@
"""The generated SBOM: shape, completeness, and the properties it is relied on for.
The point of shipping an SBOM to an air-gapped site is answering "are we exposed
to this CVE, and where" without scanning the box. That only works if the document
actually lists everything that ships, at the right versions, with identifiers a
scanner recognises. These tests hold it to that.
"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[1]
GENERATOR = REPO / 'scripts' / 'generate_sbom.py'
FIXED_TIMESTAMP = '2026-01-01T00:00:00Z'
def generate(tmp_path, name='sbom.cdx.json'):
out = tmp_path / name
result = subprocess.run(
[sys.executable, str(GENERATOR), str(REPO), '-o', str(out),
'--timestamp', FIXED_TIMESTAMP],
capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(out.read_text()), out
@pytest.fixture(scope='module')
def sbom(tmp_path_factory):
document, _ = generate(tmp_path_factory.mktemp('sbom'))
return document
def test_is_valid_cyclonedx(sbom):
assert sbom['bomFormat'] == 'CycloneDX'
assert sbom['specVersion'] == '1.6'
assert sbom['serialNumber'].startswith('urn:uuid:')
assert sbom['version'] == 1
def test_carries_the_ntia_minimum_elements(sbom):
"""Supplier, component name, version, unique identifier, dependency
relationship, author, timestamp."""
meta = sbom['metadata']
assert meta['supplier']['name']
assert meta['authors']
assert meta['timestamp'] == FIXED_TIMESTAMP
assert meta['component']['name'] and meta['component']['version']
assert sbom['dependencies']
for component in sbom['components']:
assert component['name']
assert component['version']
assert component['purl'], '%s has no unique identifier' % component['name']
def test_every_python_pin_is_present(sbom):
"""The SBOM must not be quietly narrower than what the installer installs."""
import re
pinned = set()
for line in (REPO / 'requirements.txt').read_text().splitlines():
match = re.match(r'^([A-Za-z0-9._-]+)==([^\s;\\]+)', line.strip())
if match:
pinned.add((match.group(1).lower().replace('_', '-'), match.group(2)))
listed = {(c['name'].lower().replace('_', '-'), c['version'])
for c in sbom['components'] if c['purl'].startswith('pkg:pypi/')}
assert pinned == listed
def test_marked_out_dependencies_are_still_listed(sbom):
"""colorama is win32-only. It installs on the target, so it must appear -
the same blind spot that left it out of the wheelhouse."""
names = {c['name'] for c in sbom['components'] if c['purl'].startswith('pkg:pypi/')}
assert 'colorama' in names
def test_frontend_packages_are_covered(sbom):
"""The npm tree is the reason this exists: nothing else records what version
of leaflet or dompurify ends up inside the compiled SPA."""
npm = {c['name']: c for c in sbom['components'] if c['purl'].startswith('pkg:npm/')}
assert len(npm) > 100
for shipped in ('leaflet', 'dompurify', 'vue'):
assert shipped in npm, '%s is missing from the SBOM' % shipped
assert npm[shipped]['scope'] == 'required'
def test_build_only_packages_are_marked_not_dropped(sbom):
"""Dev packages do not ship. Recorded as 'excluded' rather than omitted, so a
reader can tell 'not here' from 'not looked for'."""
npm = {c['name']: c for c in sbom['components'] if c['purl'].startswith('pkg:npm/')}
assert npm['vite']['scope'] == 'excluded'
assert any(c['scope'] == 'required' for c in npm.values())
def test_bom_refs_are_unique(sbom):
"""CycloneDX forbids duplicate bom-refs, and scanners reject a document that
has them. npm installs the same package at several depths - node_modules/vite
and node_modules/vitest/node_modules/vite - which emitted it twice."""
refs = [c['bom-ref'] for c in sbom['components']]
duplicates = {ref for ref in refs if refs.count(ref) > 1}
assert not duplicates, 'duplicate bom-refs: %s' % sorted(duplicates)[:5]
def test_a_package_present_outside_the_dev_tree_counts_as_shipped(tmp_path):
"""Merging duplicates must not mark a shipped package build-only, which would
hide it from a CVE search on the server."""
sys.path.insert(0, str(REPO / 'scripts'))
import generate_sbom
lock = tmp_path / 'package-lock.json'
lock.write_text(json.dumps({'lockfileVersion': 3, 'packages': {
'': {'name': 'x', 'version': '1.0.0', 'dependencies': {'shared': '^1'}},
'node_modules/shared': {'version': '1.0.0'},
'node_modules/builder': {'version': '2.0.0', 'dev': True},
'node_modules/builder/node_modules/shared': {'version': '1.0.0', 'dev': True},
}}))
packages, direct = generate_sbom.parse_package_lock(str(lock))
shared = [p for p in packages if p['name'] == 'shared']
assert len(shared) == 1, 'the two copies of shared were not merged'
assert shared[0]['dev'] is False
assert direct == ['shared']
def test_components_carry_integrity_hashes(sbom):
missing = [c['name'] for c in sbom['components'] if not c.get('hashes')]
assert not missing, 'components without a hash: %s' % missing[:5]
def test_dependency_graph_is_real_not_flat(sbom):
"""A flat 'root depends on everything' graph cannot answer what was chosen
versus what was dragged in."""
root = sbom['metadata']['component']['bom-ref']
edges = {entry['ref']: entry['dependsOn'] for entry in sbom['dependencies']}
assert root in edges
assert len(edges) > 1, 'no edges below the root'
assert len(edges[root]) < len(sbom['components']), 'root depends on everything'
refs = {c['bom-ref'] for c in sbom['components']}
for ref, children in edges.items():
if ref == root:
continue
assert ref in refs, 'edge from an unknown component: %s' % ref
for child in children:
assert child in refs, 'edge to an unknown component: %s' % child
def test_output_is_byte_identical_across_runs(tmp_path):
"""Regenerating must not churn. A document that differs every build gets
re-committed without being read."""
first, first_path = generate(tmp_path, 'a.json')
_, second_path = generate(tmp_path, 'b.json')
assert first_path.read_bytes() == second_path.read_bytes()
assert first['serialNumber'] == json.loads(second_path.read_text())['serialNumber']