Plugin framework maturation, reports overhaul, theming, and USB frontend repair

Framework:
- Per-plugin Alembic migration chains (ADR-008): every bundled plugin
  carries its own chain with a stamp-only anchor at the ownership cutover;
  new plugin schema lands in plugins/<name>/migrations/, never the core
  chain. Deploys add flask plugin upgrade-all. Fixed a latent bug in the
  shared alembic template (engine URL resolution) and taught the metadata
  filter to include FK-referenced core tables.
- Frontend plugin route gating (ADR-009): plugin routes carry meta.plugin;
  a disabled plugin's pages redirect to the dashboard via a cached,
  fail-open check against the new public GET /api/plugins/enabled.
- get_reports() plugin hook (contract 0.5.0 -> 0.6.0): plugins contribute
  report cards; warranty and toner cards moved off the hardcoded list.

Reports:
- Hub grouped by category with search; inline reports render at the top,
  are URL-backed (?report=id, back-button and deep links work), expose
  their server-side filter params as controls, and export CSV. Warranty
  and Toner pages gained CSV export.
- Deleted the dead legacy Warranty Status report (always-zero buckets
  from a retired column).

Theming and fonts:
- Inter (variable) bundled locally via @fontsource, replacing the Google
  Fonts Roboto import - air-gapped installs now render correctly; tables
  use tabular numerals.
- Optional brand_primary_dark_color, brand_accent_color,
  brand_sidebar_color settings applied to CSS vars at bootstrap.

USB frontend repair (views were reading a dead legacy shape):
- List/detail/form and the employee profile USB panels remapped to the
  real API shape (device_id/device_desc/checkinoutlog); employee panels
  now use /usb/checkouts endpoints; external-mode /usb/checkouts/active
  honors the badge filter; dead client methods pruned.

Also: warranties list page no longer requires login (matches app
convention); collector doc rewritten with a GE-Enforce integration guide
and paste-ready PowerShell reporter; ADR index and CHANGELOG updated.

Verified: 323 tests pass, naming/style green, frontend builds, plugin
migration dry-run green on scratch MySQL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-11 10:01:47 -04:00
parent b8c22244a1
commit 22e623c1f6
85 changed files with 3274 additions and 972 deletions

View File

@@ -10,6 +10,56 @@ ADR-007 and ADR-002.
## [Unreleased]
### Added
- measuringtools plugin (ADR-005): gage-lab instruments as Asset extensions
with type lookup (color-coded), calibration tracking (derived
overdue/due-soon/current status), calibration report, and full frontend.
Built as the framework exemplar; docs/PLUGIN-GUIDE.md walks through its
construction step by step as the plugin-system tutorial.
- CSV export on the Warranty and Toner report pages; per-report filter
controls (business unit, asset type, location, application, limit) on the
inline core reports; report open-state is URL-backed and deep-linkable.
- Per-plugin Alembic migration chains (ADR-008): every bundled plugin now
carries its own chain with a stamp-only anchor; new plugin schema changes
land in `plugins/<name>/migrations/`, never the core chain. Deploys run
`flask plugin upgrade-all` after `flask db upgrade`.
- Frontend plugin route gating (ADR-009): a disabled backend plugin's pages
redirect to the dashboard; new public `GET /api/plugins/enabled`.
- `get_reports()` plugin hook (plugin contract 0.5.0 -> 0.6.0): plugins
contribute their own report cards; warranty and toner cards moved off the
hardcoded frontend list.
- Reports hub grouped by category with a search filter.
- Configurable QR label targets: `qr_target_printer` / `qr_target_usb`
settings (blank = the asset's own page, else a URL template with
placeholders) and a `usb_label_style` barcode/QR toggle for USB
mini-labels. New Settings > Printing & Labels section.
- Site palette theming: optional `brand_primary_dark_color`,
`brand_accent_color`, `brand_sidebar_color` settings applied at bootstrap.
- Collector integration guide rewrite: header-only auth reference and a
paste-ready GE-Enforce PowerShell reporting function.
### Changed
- Inter (variable) replaces Roboto, bundled locally - no Google Fonts
fetch, so air-gapped installs render correctly. Tables use tabular
numerals.
- ServiceNow defaults point at the current geaerospaceqa.service-now.com
global search (search, incident, and change links).
### Fixed
- USB frontend remapped to the actual API shape (`device_id` /
`device_desc`): device list, detail, form, label batch, and the employee
profile's checked-out/history panels were all reading dead legacy fields.
- External-mode `GET /api/usb/checkouts/active` now honors the `badge`
filter.
- Warranties list page no longer demands login (matches every other list
page; reads were already public).
- Removed the dead legacy Warranty Status report (always-zero buckets from
a retired column); the warranty plugin's report is the real one.
- Pruned dead usbApi client methods that had no backend routes.
## [0.5.0] - 2026-07-10
First release cut with a version, tag, changelog, and CI. Focused on

View File

@@ -1,67 +1,156 @@
# Collector integration (PC auto-update)
How the shopfloor PC fleet pushes inventory into shopdb-flask, replacing the
classic ASP `api.asp?action=updateCompleteAsset` path.
classic ASP `api.asp?action=updateCompleteAsset` path. The generic collector
contract is defined in ADR-006; this doc is the operational reference for wiring
a real caller (the GE-Enforce fleet agent) to it.
## Endpoint
- Server code: `shopdb/core/api/collector.py`
- Computers schema + upsert: `plugins/computers/plugin.py`
(`get_collector_schema` / `apply_collector_payload`)
- Contract rationale: `docs/adr/ADR-006-collector-contract.md`
`POST /api/collector/computers`
---
Auth: API key header `X-API-Key: <key>`, resolved as `COLLECTOR_API_KEY_COMPUTERS`
then the shared `COLLECTOR_API_KEY` (ADR-006). Idempotent upsert keyed on
`hostname`.
## 1. How it works now
> **Breaking change:** the API key must be sent in the `X-API-Key` header. The
> old `?api_key=<key>` querystring fallback has been removed, on every collector
> endpoint (`/api/collector/<plugin>`, `/pc`, `/apps`, `/heartbeat`, `/bulk`,
> `/status`). Querystring keys leak into access logs and proxy history. Update
> any caller still passing `api_key` in the URL to use the header instead.
### Auth model (header-only, env keys, fail-closed)
## Payload (project naming convention: lowercase concatenated)
- The API key travels in the `X-API-Key` request header. Nothing else is
accepted. The old `?api_key=<key>` querystring fallback has been removed on
every collector endpoint (see the breaking-change section below).
- Keys come from environment variables (never the database, never a file the app
serves):
- `COLLECTOR_API_KEY_<PLUGINNAME>` - per-plugin override, uppercased plugin
name (e.g. `COLLECTOR_API_KEY_COMPUTERS`).
- `COLLECTOR_API_KEY` - shared fallback used when no per-plugin key is set.
- Resolution order per request: per-plugin key first, then the shared key
(`_plugin_api_key` in `collector.py`).
- Fail-closed: if neither variable is set server-side, the endpoint returns
HTTP 500 `Collector API key not configured` and rejects every request. An
unconfigured server never silently accepts unauthenticated data.
- A caller that sends the wrong key (or no key) gets HTTP 401 `Invalid API key`.
| Field | Meaning | Flask target |
|-------|---------|--------------|
| `hostname` (required) | identity | `Computer.hostname` |
| `machinenumber` | machine number | `Asset.assetnumber` (skips `9999` placeholder, falls back to hostname) |
| `pctype` | imaging pc-type | `Computer.computertypeid` via the configurable mapping |
| `pcsubtype` | finer class | accepted, not yet stored (warning) |
| `serialnumber` | BIOS serial | `Asset.serialnumber` |
| `loggedinuser` | current user | `Computer.loggedinuser` |
| `lastboottime` | ISO datetime | `Computer.lastboottime` |
| `lastcheckin` | ISO datetime | accepted (heartbeat) |
| `ipaddress` | primary IP | primary `Communication` |
| `vendorname` | manufacturer | `Computer.vendorid` (created if missing) |
| `modelnumber` | model | `Computer.modelnumberid` (created if missing) |
| `osname` | OS caption | `Computer.osid` (looked up; warned if unknown) |
| `installedsoftware` | `[{name, version}]` | `ComputerInstalledApp` (known apps only) |
### Generic endpoint contract: `POST /api/collector/<plugin>`
Response: `{status, action: created|updated, assetid, identityvalue, warnings[]}`.
One dynamic route serves every enabled plugin that returns a collector schema.
For the PC fleet that is `POST /api/collector/computers`.
## Source of truth on the PC (current method, may change)
Request flow inside `generic_collect`:
The data already exists at image time and at runtime:
1. Look up the plugin's schema. Unknown / disabled plugin -> HTTP 404
`No collector registered for plugin <plugin>`.
2. Resolve and check the API key (per-plugin then shared, as above).
3. Parse the JSON body. Missing or non-object body -> HTTP 400 `No data provided`.
4. Identity-field resolution: the schema names an `identityfield`
(`hostname` for computers). If that field is missing or blank in the payload,
HTTP 400 `<identityfield> is required`.
5. Idempotent upsert: the plugin's `apply_collector_payload` finds the existing
asset by identity and updates it, or inserts a new one. Same identity on a
later submission updates the same row - re-imaging a PC does not duplicate it,
and existing asset relationships are preserved.
6. Audit logging: every accepted submission writes an `AuditLog` row
(`action` = created/updated, entity `Collector`, `details = collector:<plugin>
action=<action>`), then commits.
- **machine number**: registry `HKLM\SOFTWARE\[WOW6432Node\]GE Aircraft Engines\Dnc\General\MachineNo`
FIRST (authoritative post Update-MachineNumber; ignore the `9999` placeholder),
then `C:\Enrollment\machine-number.txt` as fallback. This is exactly what
GE-Enforce.ps1 already does.
- **pc-type / pc-subtype**: `C:\Enrollment\pc-type.txt` / `pc-subtype.txt`
(the `gea-shopfloor-*` taxonomy).
- **serial / vendor / model / os / user / boot**: live WMI on the PC.
Response body (HTTP 200), wrapped in the standard envelope
`{status, data, message, meta}`, with the collector result under `data`:
GE-Enforce currently writes a status JSON to the SFLD share rather than POSTing.
Whatever transport is used (a relay reading those status files, or a direct POST
later), map its field names to the table above.
```json
{
"status": "success",
"data": {
"status": "ok",
"action": "created",
"assetid": 12345,
"identityvalue": "WJRP2335",
"warnings": ["unknown operating system: Microsoft Windows 11 Enterprise 23H2 (build 22631)"]
},
"message": "computers collector created",
"meta": { "timestamp": "...", "requestid": "..." }
}
```
## pc-type mapping (configurable)
`action` is `created`, `updated`, or `noop`. `warnings` is a list of soft
problems (unmapped pc-type, unknown OS, unknown app, un-stored pcsubtype) that
did NOT fail the request - the row was still written.
`pctype` (e.g. `gea-shopfloor-cmm`) is mapped to a flask Computer Type through
### Error responses
Errors use the same envelope with `status: "error"` and the detail under
`data.error`:
```json
{ "status": "error", "data": { "error": { "code": "UNAUTHORIZED", "message": "Invalid API key" } }, "meta": { } }
```
The plugin can raise `ValueError` for controlled validation failures; its message
is returned verbatim (HTTP 400). Any other exception is caught, logged
server-side, the transaction is rolled back, and the caller gets a generic
HTTP 500 `Internal error processing collector payload` with no internal detail
(by design - check the server log to see what actually failed).
### Schema discovery: `GET /api/collector/_schemas`
Returns the collector schema for every enabled plugin. This route is
JWT-protected (an interactive/admin token), NOT API-key protected - it is for
humans and tooling introspecting what payloads are accepted, not for the
headless collectors themselves.
### Legacy endpoints (computers-only, predate ADR-006)
These still exist for the older PowerShell callers and all require the same
`X-API-Key` header:
| Endpoint | Purpose |
|---|---|
| `POST /api/collector/pc` | Update one PC matched by `hostname` (lastboot, user, serial). |
| `POST /api/collector/apps` | Update installed apps for a PC (known apps only). |
| `POST /api/collector/heartbeat` | Record check-in for one or many hostnames. |
| `POST /api/collector/bulk` | Update many PCs in one call. |
| `GET /api/collector/status` | Liveness + endpoint list. |
New integrations should target `POST /api/collector/computers`, not these. Per
ADR-006 the legacy `/pc` path is deprecated and slated for removal before v1.0.
### Computers plugin field mapping
Payload naming follows the project convention (lowercase concatenated). The
identity field is `hostname`. All other fields are optional; an omitted field
leaves the existing value untouched (patch-style), so a bare report never blanks
a column.
| Payload field | Type | Server behaviour (`apply_collector_payload`) |
|---|---|---|
| `hostname` (required) | string | Identity. Matches `Computer.hostname` (case-insensitive), then falls back to `Asset.assetnumber`. New asset created if no match. |
| `machinenumber` | string | Business tag -> `Asset.assetnumber`. The placeholder `9999` and empty string are skipped; when skipped a new PC falls back to `assetnumber = hostname`. On an existing PC a real value updates `assetnumber`. |
| `pctype` | string | `gea-shopfloor-*` imaging type -> `Computer.computertypeid` via the configurable `pctypemap` settings. Unmapped value -> warning, not error. |
| `pcsubtype` | string | Accepted but not stored yet -> warning. |
| `serialnumber` | string | `Asset.serialnumber`. |
| `loggedinuser` | string | `Computer.loggedinuser`. (`currentuser` is also accepted as a legacy alias.) |
| `lastboottime` | ISO-8601 datetime | `Computer.lastboottime`. Unparseable -> warning. |
| `lastcheckin` | ISO-8601 datetime | Accepted (heartbeat semantics). `lastreporteddate` is set server-side on every call regardless. |
| `ipaddress` | string | Primary `Communication` row (`isprimary=True`, type `IP`). Updated in place or created. |
| `vendorname` | string | `Computer.vendorid`. Vendor row auto-created if missing (free vocab). |
| `modelnumber` | string | `Computer.modelnumberid`, scoped to the vendor when known. Model row auto-created if missing. |
| `osname` | string | `Computer.osid`. Controlled vocab: looked up in `operatingsystems`, NOT auto-created. Unknown value -> warning (row still written, `osid` left unset). |
| `installedsoftware` | array of `{name, version}` | `ComputerInstalledApp` rows for applications shopdb already tracks. Unknown app name -> warning, skipped. |
Schema source of truth: `get_collector_schema` in `plugins/computers/plugin.py`.
If you change the payload, change it there and re-check this table.
### pc-type mapping (configurable per site)
`pctype` (e.g. `gea-shopfloor-cmm`) maps to a shopdb Computer Type through
`pctypemap_<pxetype>` settings (Settings > System > "Collector PC Type Mapping").
Defaults live in `plugins/computers/pctypemap.py` and are seeded on plugin
install; edit per site in the UI. Unmapped pc-types are recorded as a warning,
not an error.
install; edit per site in the UI. Unmapped pc-types produce a warning, not a
failure.
## Classic api.asp field mapping (for migrating the PowerShell scripts)
### Classic api.asp field mapping (for porting the PowerShell reporter)
The fleet's classic-ASP reporter posts form fields to
`api.asp?action=updateCompleteAsset`. Map them to the collector JSON as follows:
| Classic `updateCompleteAsset` form field | Collector field |
|---|---|
@@ -76,5 +165,365 @@ not an error.
| `osVersion` | `osname` |
| `installedApps` | `installedsoftware` |
Not carried over (no current home): warranty fields, DNC config, multi-NIC
detail beyond the primary IP, VNC/WinRM flags.
Not carried over (no current home in the computers schema): warranty fields, DNC
config, multi-NIC detail beyond the single primary IP, VNC/WinRM flags. The
classic reporter posts a full `networkInterfaces` array; the collector accepts
only one `ipaddress`, so pick the corp/routable NIC (see the corp-range gate in
the PowerShell below).
---
## 2. Breaking change: querystring `api_key` removed
The API key must now be sent in the `X-API-Key` header. The `?api_key=<key>`
querystring form has been removed on every collector endpoint
(`/api/collector/<plugin>`, `/pc`, `/apps`, `/heartbeat`, `/bulk`, `/status`).
Querystring keys leak into web-server access logs, proxy history, and browser
history. Header-only keeps the secret out of those logs.
Before (no longer works - the key is ignored and the request is rejected 401):
```
POST /api/collector/computers?api_key=SECRET
Content-Type: application/json
{ "hostname": "WJRP2335", "machinenumber": "2335" }
```
After (correct):
```
POST /api/collector/computers
X-API-Key: SECRET
Content-Type: application/json
{ "hostname": "WJRP2335", "machinenumber": "2335" }
```
PowerShell before/after:
```powershell
# BEFORE (broken)
Invoke-RestMethod -Uri "https://$SiteHost/api/collector/computers?api_key=$key" `
-Method Post -Body $json -ContentType 'application/json'
# AFTER (correct)
Invoke-RestMethod -Uri "https://$SiteHost/api/collector/computers" `
-Method Post -Body $json -ContentType 'application/json' `
-Headers @{ 'X-API-Key' = $key }
```
Any caller still putting `api_key` in the URL must move it to the header.
---
## 3. GE-Enforce implementation guide
GE-Enforce is the SYSTEM-context fleet agent that runs every enforcement cycle on
each shopfloor PC (scheduled task, at-logon + periodic). It lives OUTSIDE this
repo. The facts below are drawn from the real scripts so the payload matches what
the PC already knows:
- `playbook/shopfloor-setup/common/GE-Enforce.ps1` - the enforcement pass. At the
end of each run it writes a status JSON to
`<share>\_outputs\logs\<hostname>\status.json` (interim transport; the fleet
dashboard reads those files). It logs via `Write-EnforceLog` to
`C:\Logs\Shopfloor\enforce-YYYYMMDD.log`. Its machine-number resolution is:
registry `HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\Dnc\General` then
`HKLM:\SOFTWARE\GE Aircraft Engines\Dnc\General`, property `MachineNo`,
skipping the `9999` placeholder, then `C:\Enrollment\machine-number.txt` as
fallback. hostname is `[System.Environment]::MachineName` (live NetBIOS name,
not `$env:COMPUTERNAME`, which goes stale after a post-image rename).
- `playbook/shopfloor-setup/gea-shopfloor-collections/Report-AssetToShopDB.ps1` -
the existing HTTP reporter that POSTs to the classic ASP
`api.asp?action=updateCompleteAsset`. It runs every cycle as a `Type=PS1`
manifest entry (DetectionMethod `Always`) under the SYSTEM task, logs to
`C:\Logs\Shopfloor\report-asset-YYYYMMDD.log`, always exits 0, and reads the
same machine number (reg, then `cmm\cmmid.txt`, then `machine-number.txt`) plus
BIOS serial, OS caption, make/model, logged-in user, and corp-NIC IP from WMI.
The function below is the direct-to-flask analogue of this reporter.
- `playbook/shopfloor-setup/Shopfloor/lib/Update-MachineNumber.ps1` - keeps the
reg `MachineNo` and `C:\Enrollment\machine-number.txt` in sync when a tech
reassigns a bay, so both sources agree.
### Paste-ready reporter
Drop this in as a `Type=PS1` / DetectionMethod `Always` manifest entry (mirroring
`Report-AssetToShopDB.ps1`), or dot-source `Send-ShopdbCollectorReport` from
GE-Enforce.ps1 and call it at the end of the pass. It reads the machine number
exactly the way GE-Enforce already does, builds a payload matching the computers
collector schema, and POSTs with the `X-API-Key` header over TLS 1.2. Every
field name below was checked against `get_collector_schema` in
`plugins/computers/plugin.py`.
```powershell
# Send-ShopdbCollectorReport.ps1
# Reports this PC's identity to shopdb-flask via POST /api/collector/computers.
# Runs as SYSTEM from the GE-Enforce cycle. Always exits without throwing;
# failures are logged, never fatal.
function Get-ShopdbCollectorApiKey {
# NEVER hardcode the key in this script (it lives on the share). Read it
# from a per-site value the SYSTEM/machine account can already reach.
# Preferred: a field in the site-config.json GE-Enforce already parses.
# Fallback: a machine-local file staged at imaging, ACL'd to SYSTEM.
$key = ''
$siteCfg = 'C:\Enrollment\site-config.json'
if (Test-Path -LiteralPath $siteCfg) {
try {
$cfg = Get-Content -LiteralPath $siteCfg -Raw | ConvertFrom-Json
if ($cfg.collectorApiKey) { $key = "$($cfg.collectorApiKey)".Trim() }
} catch {}
}
if (-not $key) {
$keyFile = 'C:\Enrollment\collector-api-key.txt'
if (Test-Path -LiteralPath $keyFile) {
try { $key = (Get-Content -LiteralPath $keyFile -First 1 -ErrorAction Stop).Trim() } catch {}
}
}
return $key
}
function Get-ShopdbMachineNumber {
# Same resolution GE-Enforce.ps1 / Report-AssetToShopDB.ps1 use:
# eDNC registry (skip 9999 placeholder) then C:\Enrollment\machine-number.txt.
$machineNumber = ''
foreach ($rp in @(
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\Dnc\General',
'HKLM:\SOFTWARE\GE Aircraft Engines\Dnc\General'
)) {
if ($machineNumber) { break }
if (Test-Path $rp) {
try {
$v = (Get-ItemProperty -Path $rp -Name MachineNo -ErrorAction Stop).MachineNo
if ($v -and "$v".Trim() -ne '9999') { $machineNumber = "$v".Trim() }
} catch {}
}
}
if (-not $machineNumber -and (Test-Path 'C:\Enrollment\machine-number.txt')) {
try {
$v = (Get-Content 'C:\Enrollment\machine-number.txt' -First 1 -ErrorAction Stop).Trim()
if ($v -and $v -ne '9999') { $machineNumber = $v }
} catch {}
}
return $machineNumber
}
function Get-ShopdbCorpIPv4 {
# Pick the corp/AESFMA NIC IP. Same allowed-range gate as
# Report-AssetToShopDB.ps1 - update the ranges if the site re-VLANs.
$allowedRanges = @(
@{ Network = '10.134.48.0'; PrefixLen = 23 },
@{ Network = '10.48.249.0'; PrefixLen = 26 }
)
function ConvertTo-Uint32([string]$ip) {
$bytes = ([System.Net.IPAddress]::Parse($ip)).GetAddressBytes()
[Array]::Reverse($bytes)
return [BitConverter]::ToUInt32($bytes, 0)
}
try {
$ips = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop |
Where-Object { $_.IPAddress -notmatch '^169\.254' -and $_.IPAddress -ne '127.0.0.1' }
foreach ($ipo in $ips) {
$ipInt = ConvertTo-Uint32 $ipo.IPAddress
foreach ($r in $allowedRanges) {
$netInt = ConvertTo-Uint32 $r.Network
$mask = [uint32]([math]::Pow(2, 32) - [math]::Pow(2, 32 - $r.PrefixLen))
if (($ipInt -band $mask) -eq ($netInt -band $mask)) { return $ipo.IPAddress }
}
}
} catch {}
return ''
}
function Send-ShopdbCollectorReport {
param(
[string]$SiteHost = 'tsgwp00525.wjs.geaerospace.net',
[string]$ApiKey = (Get-ShopdbCollectorApiKey),
[int]$TimeoutSec = 30,
[string]$LogFile = ('C:\Logs\Shopfloor\collector-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
)
$logDir = Split-Path -Parent $LogFile
if (-not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
}
function Write-CollectorLog([string]$msg) {
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
"$ts $msg" | Tee-Object -FilePath $LogFile -Append | Out-Null
}
Write-CollectorLog '=== Report to shopdb-flask collector ==='
if (-not $ApiKey) {
Write-CollectorLog 'ERROR no collector API key (site-config.json / collector-api-key.txt) - skipping.'
return
}
# PowerShell 5.1 does not always negotiate TLS 1.2 by default.
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {}
# --- Gather identity (matches the computers collector schema) ---
$hostname = [System.Environment]::MachineName
if (-not $hostname) { $hostname = $env:COMPUTERNAME }
$machineNumber = Get-ShopdbMachineNumber
$ipAddress = Get-ShopdbCorpIPv4
$serialNumber = ''
try {
$serialNumber = "$((Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber)".Trim()
} catch { Write-CollectorLog "WARN BIOS serial read failed: $($_.Exception.Message)" }
$vendorName = ''; $modelNumber = ''; $loggedInUser = ''
try {
$cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
$vendorName = "$($cs.Manufacturer)".Trim()
$modelNumber = "$($cs.Model)".Trim()
# UserName is COMPUTERNAME\user or DOMAIN\user; keep the bare username.
if ($cs.UserName) { $loggedInUser = ($cs.UserName -split '\\')[-1].Trim() }
} catch { Write-CollectorLog "WARN ComputerSystem read failed: $($_.Exception.Message)" }
$osName = ''; $lastBootTime = ''
try {
$os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
$osName = "$($os.Caption)".Trim()
$dv = ''
try { $dv = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name DisplayVersion -ErrorAction Stop).DisplayVersion } catch {}
if ($dv) { $osName += " $dv" }
if ($os.BuildNumber) { $osName += " (build $($os.BuildNumber))" }
$osName = $osName.Trim()
# ISO-8601 so the server's datetime parse accepts it.
try { $lastBootTime = $os.LastBootUpTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } catch {}
} catch { Write-CollectorLog "WARN OS read failed: $($_.Exception.Message)" }
$pcType = ''
if (Test-Path -LiteralPath 'C:\Enrollment\pc-type.txt') {
try { $pcType = (Get-Content -LiteralPath 'C:\Enrollment\pc-type.txt' -First 1 -ErrorAction Stop).Trim() } catch {}
}
$pcSubType = ''
if (Test-Path -LiteralPath 'C:\Enrollment\pc-subtype.txt') {
try { $pcSubType = (Get-Content -LiteralPath 'C:\Enrollment\pc-subtype.txt' -First 1 -ErrorAction Stop).Trim() } catch {}
}
# --- Build payload. Field names MUST match get_collector_schema exactly. ---
$payload = @{ hostname = $hostname }
if ($machineNumber) { $payload['machinenumber'] = $machineNumber }
if ($pcType) { $payload['pctype'] = $pcType }
if ($pcSubType) { $payload['pcsubtype'] = $pcSubType }
if ($serialNumber) { $payload['serialnumber'] = $serialNumber }
if ($loggedInUser) { $payload['loggedinuser'] = $loggedInUser }
if ($lastBootTime) { $payload['lastboottime'] = $lastBootTime }
if ($ipAddress) { $payload['ipaddress'] = $ipAddress }
if ($vendorName) { $payload['vendorname'] = $vendorName }
if ($modelNumber) { $payload['modelnumber'] = $modelNumber }
if ($osName) { $payload['osname'] = $osName }
$payload['lastcheckin'] = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
# installedsoftware is optional; add an array of @{ name=..; version=.. }
# here if the manifest introspection is wired to real Application names.
$uri = "https://$SiteHost/api/collector/computers"
$json = $payload | ConvertTo-Json -Depth 5
$headers = @{ 'X-API-Key' = $ApiKey }
Write-CollectorLog ("POST {0} host={1} machineNo={2} pcType={3} serial={4} ip={5}" -f `
$uri, $hostname, $machineNumber, $pcType, $serialNumber, $ipAddress)
try {
$resp = Invoke-RestMethod -Uri $uri -Method Post -Body $json `
-ContentType 'application/json' -Headers $headers `
-TimeoutSec $TimeoutSec -ErrorAction Stop
$d = $resp.data
Write-CollectorLog ("OK action={0} assetid={1} identity={2} warnings={3}" -f `
$d.action, $d.assetid, $d.identityvalue, ((@($d.warnings)) -join '; '))
} catch {
$code = $null
try { $code = [int]$_.Exception.Response.StatusCode.value__ } catch {}
Write-CollectorLog "ERROR POST failed (http=$code): $($_.Exception.Message)"
}
}
# Entry point when run as a standalone manifest PS1 entry:
Send-ShopdbCollectorReport
```
### Where the call slots into the GE-Enforce run
Put the POST at the END of the enforcement pass, after the enforcement scopes are
processed and the PC identity is settled. Two placements, pick one:
- Preferred - a sibling manifest entry, mirroring `Report-AssetToShopDB.ps1`:
add `Send-ShopdbCollectorReport.ps1` as a `Type=PS1`, DetectionMethod `Always`
manifest entry in `common\manifest.json`. It runs every cycle under the SYSTEM
task, keeps GE-Enforce.ps1 untouched, and gets its own log file. This matches
the established pattern the classic reporter already uses.
- Alternative - inline: dot-source the function and call
`Send-ShopdbCollectorReport` inside GE-Enforce.ps1's status-write-back `try`
block (right after `status.json` is written, near the end of the pass). Use
this only if you want the HTTP push to share GE-Enforce's own log and lifecycle.
Either way, run it ALONGSIDE the existing `status.json` share write (and
alongside the classic `Report-AssetToShopDB.ps1`) during rollout - do not remove
the share write until the HTTP path is proven.
### Delivering the API key to clients
Do NOT hardcode the key in the script - the script itself lives on the SFLD
share, so a literal key there is effectively published to everyone with share
read. Two viable options:
1. Per-site value the machine account already reads. GE-Enforce already parses
`C:\Enrollment\site-config.json` (its local copy of the per-site config on the
share). Add one field, `collectorApiKey`, populated per site. The script reads
it with the machine/SYSTEM identity it already runs as. Rotation = update the
one config value at the site, no re-image, no script redeploy.
2. Baked at imaging into a machine-local file (`C:\Enrollment\collector-api-key.txt`)
or an HKLM value, ACL'd to SYSTEM/Administrators, written by the enrollment
pipeline. Tighter blast radius (the key never sits on the share at all), but
rotation requires touching every PC or re-imaging.
Recommendation: use option 1 (`collectorApiKey` in the per-site config), matching
how GE-Enforce already sources its share paths and mirroring how SFLD credentials
are provisioned per site. It keeps the secret out of source control and off the
script, sits at the same trust boundary as everything else the SYSTEM agent
already reads, and supports rotation without a fleet touch. `Get-ShopdbCollectorApiKey`
above already prefers this and falls back to the imaging-baked file, so a site
can start on option 1 and tighten to option 2 later with no code change. The
server side pairs this with `COLLECTOR_API_KEY_COMPUTERS` (per-plugin) so the PC
fleet's key is scoped to the computers collector only.
### Staged rollout
1. Keep everything as-is: `status.json` share write + classic
`Report-AssetToShopDB.ps1` continue running. Provision `collectorApiKey` per
site and set `COLLECTOR_API_KEY_COMPUTERS` on the shopdb-flask server.
2. Add `Send-ShopdbCollectorReport` as a new manifest entry on a handful of pilot
bays (one per pc-type: collections, cmm, keyence, waxtrace, ...). It logs to
`C:\Logs\Shopfloor\collector-YYYYMMDD.log` and cannot break enforcement (it
never throws, exits cleanly).
3. Verify on the server: pilot PCs appear/update in shopdb-flask, `action` is
`created`/`updated`, warnings are understood (map any unmapped `pctype`, add
any missing OS strings to the `operatingsystems` vocab). Cross-check the
collector log's `OK` lines against the audit log.
4. Roll the manifest entry out fleet-wide (it deploys from `common\`, so every
pc-type gets it). Continue running both the share write and the HTTP POST.
5. Once shopdb-flask is the system of record and stable across a full imaging
cycle, retire the classic `Report-AssetToShopDB.ps1` and, if desired, the
`status.json` share write.
---
## 4. Troubleshooting
| Symptom | HTTP | Cause / fix |
|---|---|---|
| `Invalid API key` | 401 | The `X-API-Key` header is missing or wrong. Confirm the client key matches `COLLECTOR_API_KEY_COMPUTERS` (or the shared `COLLECTOR_API_KEY`) on the server. Check the key is being read (`Get-ShopdbCollectorApiKey` returned non-empty). Remember the querystring form no longer works. |
| `Collector API key not configured` | 500 | Fail-closed: neither `COLLECTOR_API_KEY_COMPUTERS` nor `COLLECTOR_API_KEY` is set in the server environment. Set one and restart the app. This is a SERVER config gap, not a client problem. |
| `No collector registered for plugin computers` | 404 | The computers plugin is disabled or not loaded on that instance. Enable it. |
| `hostname is required` / `No data provided` | 400 | Empty body, non-JSON body, or missing identity field. Check `-ContentType 'application/json'` and that `hostname` is set. |
| `Internal error processing collector payload` | 500 | Generic by design - the server does not leak the cause to the caller. The real error (DB, unexpected exception) is in the server log (`Collector upsert failed for computers`). Check there. |
| `warnings` present but `action` is created/updated | 200 | Soft issues only; the row WAS written. Common: unmapped `pctype` (fix the pctypemap setting), unknown `osname` (add it to the `operatingsystems` vocab), unknown app name, `pcsubtype` not stored. No action needed unless the warning matters to you. |
Client-side log for the fleet reporter: `C:\Logs\Shopfloor\collector-YYYYMMDD.log`.
Server-side: the Flask app log (the collector logs upsert failures and per-plugin
schema failures there).

View File

@@ -148,7 +148,10 @@ them under `instance/branding/`.
| `qr_logo` | `/ge-monogram.svg` | Logo composited in printer QR labels. Blank = no overlay. |
| `badge_logo` | `/ge-aerospace-logo.svg` | Logo on the equipment badge print page. |
| `site_favicon` | (empty) | Browser tab favicon. Blank = shipped `/favicon.svg`. |
| `brand_primary_color` | (empty) | Primary brand color as a CSS color value. Blank = built-in theme color. |
| `brand_primary_color` | (empty) | Primary brand color as a CSS color value (maps to `--primary`). Blank = built-in theme color. |
| `brand_primary_dark_color` | (empty) | Primary hover/active color (maps to `--primary-dark`). Blank = auto-derived by darkening the primary color ~15%. |
| `brand_accent_color` | (empty) | Accent color for secondary buttons and badges (maps to `--secondary`). Blank = built-in theme color. |
| `brand_sidebar_color` | (empty) | Sidebar background color (maps to `--sidebar-bg`). Blank = built-in theme color. |
### printing

View File

@@ -62,9 +62,16 @@ If `ProductionConfig.validate()` raises, the container exits with the offending
```bash
docker compose exec api flask db upgrade
docker compose exec api flask plugin upgrade-all
```
This applies the baseline migration (creates all tables) and any subsequent migrations. Re-running is idempotent.
`flask db upgrade` applies the core Alembic chain: the baseline migration plus
every later migration, which together create all core AND bundled-plugin tables
through the chain head. `flask plugin upgrade-all` then stamps each bundled
plugin's own migration chain (the `alembic_version_<plugin>` tables) and applies
any plugin-specific migrations added after the ownership cutover. Both commands
are idempotent, so re-running them is safe. See ADR-008 for why plugin schema
splits into per-plugin chains from the cutover forward.
**Charset:** the schema is utf8mb4 (`utf8mb4_unicode_ci`). The docker-compose `db` service sets `--character-set-server=utf8mb4`, so the auto-created `shopdb_flask` database is utf8mb4. If you point at an external MySQL instead of the bundled container, create the database as utf8mb4 first, or it inherits the server default (often latin1) and the schema silently drifts:

View File

@@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
The framework declares its contract version in `shopdb/__init__.py`:
```python
__contract_version__ = '0.4.0'
__contract_version__ = '0.6.0'
```
Each plugin's `manifest.json` declares the range of contract versions it supports:
@@ -192,6 +192,30 @@ class ComputersPlugin(BasePlugin):
> already covers every bundled asset type; no plugin ever implemented the hook.
> Search honors runtime plugin enable/disable.
### `get_reports() -> List[Dict]`
Returns report card definitions for the Reports hub. Added in contract 0.6.0.
Each entry has `id`, `name`, `description`, `category`, plus EXACTLY ONE of
`route` (a frontend path for a dedicated report page) or `endpoint` (an API
endpoint the hub renders inline).
```python
class WarrantyPlugin(BasePlugin):
def get_reports(self):
return [{
'id': 'warranty',
'name': 'Warranty Report',
'description': 'Assets bucketed by coverage: expired, expiring soon, active',
'category': 'warranty',
'route': '/reports/warranty',
}]
```
Consumed by `GET /api/reports`, which merges plugin cards after the static core
reports sorted into category groups by the frontend (disabled plugins are
skipped; a broken plugin is isolated in prod, re-raised in dev/test).
### `get_collector_schema() -> Optional[Dict]`
Declares the JSON Schema for an external collector pushing to `/api/collector/<pluginname>`. See [ADR-006](../docs/adr/ADR-006-collector-contract.md) for the contract.

View File

@@ -121,6 +121,7 @@ Override hooks on the plugin class as needed. See [PLUGIN-HOOKS.md](PLUGIN-HOOKS
|------|------|
| `get_navigation_items` | Plugin shows up in the sidebar nav |
| `get_dashboard_widgets` | Plugin's dashboard widget appears on the home page |
| `get_reports` | Plugin's report cards appear on the Reports hub |
| `get_collector_schema` + `apply_collector_payload` | Plugin accepts external pushes at `/api/collector/<name>` |
Each hook has a default that does nothing. Override only what your plugin needs.
@@ -147,6 +148,7 @@ Copy from an existing plugin's view files (e.g., `frontend/src/views/network/`)
## Next steps
- [PLUGIN-GUIDE.md](PLUGIN-GUIDE.md) - the full narrative walkthrough of building the `measuringtools` plugin end to end (models, per-plugin migration baseline, authz, hooks, frontend integration, tests). Read this after the quickstart when you want the exemplar that exercises every framework feature.
- [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md) for the full hook reference
- [CONTRIBUTING.md](../CONTRIBUTING.md) for naming conventions
- [docs/adr/ADR-001-asset-as-platform-contract.md](../docs/adr/ADR-001-asset-as-platform-contract.md) for what your plugin can rely on

View File

@@ -4,7 +4,7 @@ shopdb-flask is a framework. The plugins listed here are the pieces other GE Aer
## Bundled (ship with the framework)
These six plugins are in `plugins/` in this repo. Enable per site with `flask plugin install <name>`.
These plugins are in `plugins/` in this repo. Enable per site with `flask plugin install <name>`.
| Plugin | Tracks | Notes |
|--------|--------|-------|
@@ -14,16 +14,16 @@ These six plugins are in `plugins/` in this repo. Enable per site with `flask pl
| `network` | Switches, routers, access points, IDFs as locations | Asset-only; cleanest of the bundled set. |
| `usb` | USB devices issued to shop-floor users | Lightweight checkout / check-in. |
| `notifications` | Shop-floor notifications, recognitions, kiosk feed | Used by `ShopfloorDashboard.vue`. |
## Planned (in the roadmap, not yet built)
| Plugin | Tracks | Status |
|--------|--------|--------|
| `measuringtools` | Metrology and inspection: CMMs, Keyence vision, surface profilometers, GenSpec | Per [ADR-005](adr/ADR-005-equipment-vs-measuringtools.md). First plugin to be built using `flask plugin new` as the canary for the scaffold. |
| `measuringtools` | Metrology and inspection instruments: calipers, micrometers, thread/bore/height gages, indicators | Per [ADR-005](adr/ADR-005-equipment-vs-measuringtools.md). Calibration lifecycle with derived status. First plugin built on the matured scaffold; its walkthrough is [PLUGIN-GUIDE.md](PLUGIN-GUIDE.md). Ships `default_enabled: false`. |
## Building your own
See [PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) for the 30-minute walkthrough. The contract is locked in [ADR-001](adr/ADR-001-asset-as-platform-contract.md) and versioned per [ADR-002](adr/ADR-002-plugin-versioning.md).
Two guides:
- [PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) - generate, customize, install, and test a plugin in 30 minutes using `flask plugin new`.
- [PLUGIN-GUIDE.md](PLUGIN-GUIDE.md) - the full narrative walkthrough of building the `measuringtools` plugin, the exemplar that exercises every current framework feature (models, per-plugin migrations, authz, hooks, frontend integration, tests).
The contract is locked in [ADR-001](adr/ADR-001-asset-as-platform-contract.md) and versioned per [ADR-002](adr/ADR-002-plugin-versioning.md).
Quick path:
@@ -33,6 +33,29 @@ flask plugin new cameras --description "Tracks shop-floor surveillance cameras"
flask plugin install cameras
```
## Migrations (per-plugin chains)
Each plugin that owns tables carries its own Alembic chain under
`plugins/<name>/migrations/`, with a per-plugin version table
`alembic_version_<name>` independent of the core `alembic_version`. Ownership is
split at a fixed cutover (see [ADR-008](adr/ADR-008-plugin-migration-ownership.md)):
- The core chain (`flask db upgrade`) created every table that existed through
its head, including the bundled-plugin tables. Each bundled plugin's `0001`
migration is a stamp-only no-op anchor recording that fact.
- From the cutover forward, a change to a plugin's schema lands as
`plugins/<name>/migrations/versions/000N_*.py`, never in the core chain. The
core chain is reserved for core tables.
- A plugin built AFTER the cutover (e.g. `measuringtools`) is different: the
core chain never created its tables, so its `0001` is a REAL baseline that
creates them, not a no-op anchor. See [PLUGIN-GUIDE.md](PLUGIN-GUIDE.md) for
the anchor-vs-baseline distinction.
Deploys and upgrades run `flask db upgrade` then `flask plugin upgrade-all`.
`upgrade-all` stamps every plugin anchor and applies any later plugin
migrations; it is idempotent. The registry (`instance/plugins.json`) records
which revisions each plugin has applied in `migrations_applied`.
## Distribution conventions
For sister-site plugins (per [ADR-003](adr/ADR-003-plugin-distribution.md)):

View File

@@ -51,12 +51,16 @@ cd frontend && npm ci && npm run build && cd ..
```bash
# Docker:
docker compose exec api flask db upgrade
docker compose exec api flask plugin upgrade-all
# venv:
flask db upgrade
flask plugin upgrade-all
```
`flask db upgrade` applies any new migrations in the core Alembic chain. It is
idempotent; running it when already at head is a no-op.
`flask db upgrade` applies any new migrations in the core Alembic chain.
`flask plugin upgrade-all` then applies any new per-plugin migrations (each
bundled plugin owns its schema going forward - see ADR-008). Both are
idempotent; running them when already at head is a no-op.
## Step 4: Re-seed permissions and settings

View File

@@ -0,0 +1,123 @@
# ADR-008: Plugin migration ownership (per-plugin chains from the cutover)
- **Status:** ACCEPTED
- **Date:** 2026-07-10
- **Deciders:** cproudlock
- **Supersedes:** the "Migration strategy (resolved)" section of ADR-004
## Context
ADR-004 resolved a Phase 7B footgun by folding every bundled plugin's tables
into the single core Alembic chain (migration `7c04_fold_plugin_schema`), and
later core migrations (`7d04`..`7d16`) kept adding plugin schema directly to the
core chain. At the time this was the safe choice: bundled-plugin baselines and
the core baseline had both been creating the same tables, so
`flask plugin upgrade-all` would collide with `flask db upgrade`.
That resolution left the per-plugin Alembic engine
(`shopdb/plugins/migrations.py`, `shopdb/plugins/alembic_template.py`, the
`alembic_version_<plugin>` version tables, and `flask plugin upgrade-all`) fully
built but unused for the bundled plugins. The framework is the product (per the
project's charter); a plugin that cannot own its own schema is not really a
plugin. Sister sites that adopt or fork a plugin need its schema history to
travel with the plugin, not be entangled in the host's core chain. Keeping every
future plugin table change in the core chain also means the core chain grows
without bound and a plugin can never be cleanly removed.
The blocker ADR-004 worried about (double table creation) only exists while a
plugin's own migration tries to CREATE tables the core chain already created.
That is avoidable: history is immutable, so the tables already built by the core
chain stay owned by the core chain; only NEW schema needs a new home.
## Decision
Ownership splits at a fixed cutover, the current core-chain head.
1. **Core chain owns history through its head.** The core Alembic chain
(baseline `68b3947ae14f` .. head `7d16_directoryemployees`) remains
authoritative for every table that exists at the cutover, including the
bundled-plugin tables it created. Those migrations are immutable and are not
rewritten. `flask db upgrade` continues to reproduce the full schema.
2. **Plugin chains own plugin schema going forward.** From the cutover forward,
any change to a plugin's schema lands as
`plugins/<name>/migrations/versions/000N_*.py` in that plugin's own chain,
never in the core chain. The core chain is reserved for core tables.
3. **Every table-owning bundled plugin gets a `0001` anchor.** Each such plugin
carries a migration chain whose first revision is a stamp-only no-op:
`upgrade()` does nothing because the core chain already created the tables.
The anchor exists so the plugin chain has a base that
`flask plugin upgrade-all` can stamp into the per-plugin version table
`alembic_version_<plugin>`. Blueprint-only plugins that own no tables get no
chain.
4. **Deploy and upgrade sequence.** A deploy runs `flask db upgrade`
(core chain, creates everything through the head) then
`flask plugin upgrade-all` (stamps every plugin anchor and applies any later
per-plugin migrations). The same two commands upgrade an existing install;
both are idempotent. The registry (`instance/plugins.json`) tracks each
plugin's applied revisions in `migrations_applied`.
5. **Table-ownership registry.** `PLUGIN_TABLE_OWNERS` in
`shopdb/plugins/alembic_template.py` is the explicit map of which tables each
plugin owns; it is kept in sync with the plugins' `__tablename__` declarations
and pinned by `tests/test_plugin_migrations.py`.
External (out-of-tree) plugins per ADR-003 already shipped their own chains;
this ADR brings the bundled plugins onto the same model, so there is one rule
for all plugins.
## Consequences
### Positive
- A plugin's schema history travels with the plugin. Adopting or forking sites
get the plugin's migrations, not a slice of someone else's core chain.
- The core chain stops accreting plugin schema; it stays about core tables.
- A plugin can be evolved (or, with its own downgrade, removed) independently.
- The long-built per-plugin Alembic engine is finally exercised on every deploy,
so it cannot silently rot.
### Negative / cost
- Two migrate commands per deploy instead of one. Documented in `docs/DEPLOY.md`
and `docs/UPGRADE.md`; both are idempotent so the cost is one extra safe call.
- A plugin author must now put new tables in the plugin chain and register them
in `PLUGIN_TABLE_OWNERS`, rather than autogenerating into the core chain. The
`flask plugin new` guidance and this ADR spell that out.
- The cutover is a discontinuity: tables created before it are core-owned,
tables created after it are plugin-owned. The line is the core-chain head at
this ADR's date, recorded here so it is unambiguous.
### Neutral
- No schema changes and no data migration: the anchors are no-ops. A fresh
install and an existing install converge to the same state.
## Alternatives considered
1. **Keep everything in the core chain (status quo per ADR-004).** Simplest
operationally but defeats the plugin-as-product goal: plugin schema cannot
travel, the core chain grows without bound, and plugins can never be cleanly
removed. Rejected.
2. **Rewrite history so plugin tables move out of the core chain into plugin
`0001` CREATE migrations.** Would make each plugin chain self-contained from
empty, but breaks the immutability rule, forces every existing site to
re-run a rewritten chain, and re-introduces the exact double-creation footgun
ADR-004 fixed. Rejected.
3. **Anchor that CREATEs tables with `IF NOT EXISTS` guards.** Lets a from-empty
install build plugin tables from the plugin chain, but then two chains both
claim the same tables and drift can diverge silently. The no-op anchor keeps
a single authoritative creator (the core chain) for cutover-era tables.
Rejected.
## References
- ADR-003 (plugin distribution; external plugins already ship chains)
- ADR-004 (deployment topology; this ADR supersedes its migration-strategy note)
- `shopdb/plugins/alembic_template.py` (`PLUGIN_TABLE_OWNERS`, shared env runner)
- `shopdb/plugins/migrations.py`, `shopdb/plugins/cli.py` (`upgrade-all`)
- `plugins/<name>/migrations/` (per-plugin chains and `0001` anchors)
- `tests/test_plugin_migrations.py` (ownership + chain + idempotency guards)
- `docs/DEPLOY.md`, `docs/UPGRADE.md`, `docs/PLUGINS.md` (deploy sequence)

View File

@@ -0,0 +1,146 @@
# ADR-009: Frontend plugin gating
- **Status:** ACCEPTED
- **Date:** 2026-07-10
- **Deciders:** cproudlock
- **Supersedes:** none
## Context
The backend plugin system (ADR-002, ADR-003) lets an operator disable a
plugin. Disabling it unregisters the plugin's API blueprint, drops its
rows from global search (see `test_search_disabled`), and removes its
entry from `get_navigation_items()`, so the disabled feature's sidebar
link disappears.
The frontend told a different story. Every plugin's Vue routes and views
ship inside the core bundle. `frontend/src/router/index.js` auto-discovers
them with `import.meta.glob('./routes/*.js')`, so a route like `/usb` or
`/printers/3` is registered regardless of whether the owning backend
plugin is enabled. A user who typed the URL, followed a stale bookmark,
or clicked a cross-link reached a page whose API calls all 404, landing
on a broken shell instead of a clean redirect. The navigation was already
data-driven; direct-URL reachability was the gap.
There is no frontend plugin system yet. The product is packaged as one
core Flask app plus backend-only plugins; the Vue app is monolithic and
build-time static. Any gating has to live in core frontend code because
that is the only place the plugin routes exist.
## Decision
### Step 1 (this ADR, implemented): route-level gating
Plugin-owned frontend routes are gated against the backend's enabled-plugin
list. This is the whole of what ships now.
1. **Enabled-list endpoint.** A new `GET /api/plugins/enabled`
(`jwt_required(optional=True)`) returns a flat JSON array of enabled
plugin name strings and nothing else. It is a cheap registry read
(`registry.get_enabled_plugins()`), no database access. Exposing it to
anonymous callers is safe because `GET /api/dashboard/navigation`
already leaks the same enabled/disabled signal, and unauthenticated
kiosk routes (`/tv`) need the answer too. It carries no metadata, so it
reveals strictly less than the admin-gated `GET /api/plugins`.
2. **Route tagging.** Every plugin-owned route carries `meta.plugin =
'<pluginname>'`. This covers the per-plugin route modules
(`routes/computers.js`, `routes/equipment.js`, ...) and the
plugin-owned routes that physically live in core files: the
PC-relationships and toner reports, the slide manager and `/tv`
dashboard (slides), the printer-QR and USB-label print pages, and the
employee-detail page. Genuinely core routes (dashboard, search, map,
applications, reference-data settings) stay untagged and are never
gated.
3. **Cached fetch, fail-open.** A composable
(`composables/enabledPlugins.js`) fetches the list exactly once behind
a cached promise. If the fetch fails or returns a non-array, the code
fails **open**: every plugin is treated as enabled. A transient API
error must never brick navigation. The cost is that a disabled
plugin's page is briefly reachable during an outage, which is
acceptable because its API calls would fail anyway and the next
successful fetch closes the gap.
4. **Router guard.** `router.beforeEach` awaits the cached fetch when the
target route has `meta.plugin`. If that plugin is not enabled it
redirects to `/` and raises an info toast. The endpoint is
jwt-optional, so the guard works for both authenticated pages and the
unauthenticated `/tv` kiosk route.
This is intentionally a thin layer. It does not change the plugin
contract surface, so `__contract_version__` does not move: adding a core
HTTP endpoint and tagging core-shipped routes are not plugin-contract
changes. Plugins still ship no frontend code of their own.
### Non-goals for step 1
- No build-time or runtime loading of plugin-authored Vue code.
- No per-permission or per-role route gating (that stays with the
existing `requiresAuth` / `requiresAdmin` meta flags).
- No removal of disabled routes from the route table; they remain
registered and are intercepted by the guard. Keeping them registered
avoids a rebuild when a plugin is toggled and keeps the redirect path
simple.
## Future direction (PROPOSED, not implemented)
Step 1 gates routes that core already owns. The longer-term goal is a
real frontend-plugin contract where a plugin ships its own frontend and
core discovers it, mirroring the backend model. Sketch:
1. **Plugin-owned frontend tree.** Each plugin gains a
`plugins/<name>/frontend/` directory holding its route module, views,
and any plugin-specific components. Core stops carrying `views/usb`,
`views/printers`, and so on.
2. **Build-time discovery.** The Vite build discovers plugin frontends
with a glob over `plugins/*/frontend/routes.js` (analogous to today's
`import.meta.glob('./routes/*.js')`), so a plugin's presence in the
tree is what puts its routes in the bundle. Combined with the step-1
enabled-list gate, a plugin that is absent from the build ships no
code and a plugin that is present-but-disabled is route-gated at
runtime.
3. **Shared component + registration contract.** Plugins register into
named extension points instead of editing core files: an `iconMap`
registration for nav/asset icons, asset-detail panels, map-marker
renderers, and search-result renderers (the "Frontend hook contract"
already listed as deferred in the project CLAUDE.md). Core exposes a
stable set of shared components (form controls, detail-page shells,
table primitives) as the plugin frontend's only allowed core imports,
the frontend analogue of the `shopdb.api` namespace.
4. **Versioned frontend contract.** The shared-component and
registration surface would be versioned the same way the backend
contract is (ADR-002), so a plugin frontend can declare the core
frontend range it needs.
### Tradeoffs of the future direction
- **Pro:** true plugin self-containment; a site can drop in or remove a
plugin (frontend and backend together) without patching core; smaller
core; clearer ownership.
- **Con:** significant build-system work (per-plugin Vite entry
discovery, code-splitting, dev-server HMR across the plugin tree); a
new versioned frontend contract to maintain and document; a migration
that moves ten plugins' worth of views out of core; risk of a leaky
shared-component surface becoming an accidental contract. The payoff
only matters once external (out-of-tree) plugins with their own
frontends are a real requirement. Until then, step 1's route gating
delivers the user-visible correctness (no reachable dead pages) at a
fraction of the cost.
## Consequences
- Disabling a backend plugin now makes its frontend routes redirect to
the dashboard instead of loading a broken shell. Behavior matches the
already-dynamic navigation.
- One extra lightweight request at app boot (`GET /api/plugins/enabled`),
cached for the session.
- Fail-open means gating is a UX guardrail, not a security control. It is
not a substitute for backend authorization: the API still enforces auth
and the disabled plugin's endpoints are simply unregistered. Never rely
on route gating to protect data.
- The future frontend-plugin contract remains open work; this ADR records
the direction and its cost so a later decision can pick it up.

View File

@@ -20,6 +20,8 @@ Each ADR captures a single architectural decision: the context, the decision its
| [005](ADR-005-equipment-vs-measuringtools.md) | Equipment vs measuringtools plugin scope | ACCEPTED |
| [006](ADR-006-collector-contract.md) | Plugin collector contract pattern | ACCEPTED |
| [007](ADR-007-product-versioning-and-releases.md) | Product versioning and releases | ACCEPTED |
| [008](ADR-008-plugin-migration-ownership.md) | Plugin migration ownership (per-plugin chains) | ACCEPTED |
| [009](ADR-009-frontend-plugin-gating.md) | Frontend plugin route gating | ACCEPTED |
## Authoring

View File

@@ -1,13 +1,14 @@
{
"name": "shopdb-frontend",
"version": "1.0.0",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "shopdb-frontend",
"version": "1.0.0",
"version": "0.5.0",
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/vue3": "^6.1.20",
@@ -522,6 +523,14 @@
"node": ">=18"
}
},
"node_modules/@fontsource-variable/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
"integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fullcalendar/core": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",

View File

@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/vue3": "^6.1.20",

View File

@@ -646,9 +646,6 @@ export const usbApi = {
update(id, data) {
return api.put(`/usb/${id}`, data)
},
delete(id) {
return api.delete(`/usb/${id}`)
},
checkout(id, data) {
return api.post(`/usb/${id}/checkout`, data)
},
@@ -658,25 +655,10 @@ export const usbApi = {
getHistory(id, params = {}) {
return api.get(`/usb/${id}/history`, { params })
},
getAvailable() {
return api.get('/usb/available')
},
getCheckedOut() {
return api.get('/usb/checkedout')
},
getUserCheckouts(userId) {
return api.get(`/usb/user/${userId}`)
},
dashboardSummary() {
return api.get('/usb/dashboard/summary')
},
types: {
list() {
return api.get('/usb/types')
},
create(data) {
return api.post('/usb/types', data)
}
// check-out log rows for one badge; activeonly = only currently-held devices
getUserCheckouts(badge, activeonly = true) {
const path = activeonly ? '/usb/checkouts/active' : '/usb/checkouts'
return api.get(path, { params: { badge } })
}
}
@@ -694,9 +676,6 @@ export const reportsApi = {
kbPopularity(params = {}) {
return api.get('/reports/kb-popularity', { params })
},
warrantyStatus(params = {}) {
return api.get('/reports/warranty-status', { params })
},
softwareCompliance(params = {}) {
return api.get('/reports/software-compliance', { params })
},
@@ -767,6 +746,10 @@ export const pluginsApi = {
list() {
return api.get('/plugins')
},
// Flat array of enabled plugin names. jwt-optional, safe for kiosk routes.
enabled() {
return api.get('/plugins/enabled')
},
setEnabled(name, enabled) {
return api.put(`/plugins/${name}`, { enabled })
}
@@ -1026,3 +1009,46 @@ export const warrantyApi = {
return api.get('/warranty/report')
}
}
// Measuring tools API (plugin)
export const measuringtoolsApi = {
list(params = {}) {
return api.get('/measuringtools', { params })
},
get(id) {
return api.get(`/measuringtools/${id}`)
},
getByAsset(assetid) {
return api.get(`/measuringtools/by-asset/${assetid}`)
},
create(data) {
return api.post('/measuringtools', data)
},
update(id, data) {
return api.put(`/measuringtools/${id}`, data)
},
remove(id) {
return api.delete(`/measuringtools/${id}`)
},
calibrationReport() {
return api.get('/measuringtools/report/calibration')
},
// Measuring-tool types
types: {
list(params = {}) {
return api.get('/measuringtools/types', { params })
},
get(id) {
return api.get(`/measuringtools/types/${id}`)
},
create(data) {
return api.post('/measuringtools/types', data)
},
update(id, data) {
return api.put(`/measuringtools/types/${id}`, data)
},
remove(id) {
return api.delete(`/measuringtools/types/${id}`)
}
}
}

View File

@@ -1,5 +1,5 @@
/* Reset and base styles */
@import url('https://fonts.googleapis.com/css?family=Roboto:400,500,700&display=swap');
/* Inter is bundled locally via @fontsource-variable/inter (imported in main.js) */
* {
margin: 0;
@@ -95,7 +95,7 @@
}
body {
font-family: 'Roboto', sans-serif;
font-family: 'Inter Variable', system-ui, -apple-system, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
@@ -370,6 +370,8 @@ th, td {
vertical-align: middle;
white-space: nowrap;
border-top: 1px solid var(--border);
/* line up digits in columns (Inter supports tabular figures) */
font-variant-numeric: tabular-nums;
}
/* Cap a long free-text column (e.g. Description) so it truncates with an

View File

@@ -0,0 +1,42 @@
// Enabled-plugin gating for the router. Fetches the enabled plugin-name list
// ONCE (cached promise) so a disabled backend plugin's frontend routes become
// unreachable by direct URL. Fail-open: any fetch error treats every plugin as
// enabled, so an API blip can never brick navigation.
import { pluginsApi } from '../api'
let enabledPromise = null
let enabledSet = null // resolved Set<string>, or null while pending / on failure
// Kick off (or reuse) the single fetch. The endpoint is jwt-optional so this
// works on unauthenticated kiosk routes (e.g. /tv) too.
export function loadEnabledPlugins() {
if (!enabledPromise) {
enabledPromise = pluginsApi.enabled()
.then(response => {
const names = response?.data?.data
// Only trust a real array; anything else -> fail open (null).
enabledSet = Array.isArray(names) ? new Set(names) : null
return enabledSet
})
.catch(() => {
// Fail open: leave enabledSet null so isPluginEnabled returns true.
enabledSet = null
return null
})
}
return enabledPromise
}
// True if the plugin is enabled OR the list is unknown (fail-open). A null
// enabledSet means we never got a trustworthy answer, so allow everything.
export function isPluginEnabled(name) {
if (!name) return true
if (enabledSet === null) return true
return enabledSet.has(name)
}
// Test/hot-reload hook: forget the cached result so the next call refetches.
export function resetEnabledPlugins() {
enabledPromise = null
enabledSet = null
}

View File

@@ -3,6 +3,9 @@ import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
// Locally-bundled Inter (air-gap safe, no Google Fonts fetch)
import '@fontsource-variable/inter'
// Initialize theme on app load
import './stores/theme'
import { applyBranding } from './utils/siteSettings'

View File

@@ -3,6 +3,8 @@ 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 { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins'
import { useToast } from '../composables/toast'
// Auto-discover all route modules from routes/ directory
const routeModules = import.meta.glob('./routes/*.js', { eager: true })
@@ -59,7 +61,8 @@ const routes = [
{
path: '/tv',
name: 'tv',
component: () => import('../views/TVDashboard.vue')
component: () => import('../views/TVDashboard.vue'),
meta: { plugin: 'slides' }
},
// Print pages (standalone, no sidebar/header)
{
@@ -70,17 +73,20 @@ const routes = [
{
path: '/print/printer-qr',
name: 'print-printer-qr-batch',
component: () => import('../views/print/PrinterQRBatch.vue')
component: () => import('../views/print/PrinterQRBatch.vue'),
meta: { plugin: 'printers' }
},
{
path: '/print/printer-qr/:id',
name: 'print-printer-qr-single',
component: () => import('../views/print/PrinterQRSingle.vue')
component: () => import('../views/print/PrinterQRSingle.vue'),
meta: { plugin: 'printers' }
},
{
path: '/print/usb-labels',
name: 'print-usb-labels',
component: () => import('../views/print/USBLabelBatch.vue')
component: () => import('../views/print/USBLabelBatch.vue'),
meta: { plugin: 'usb' }
},
{
path: '/',
@@ -108,6 +114,17 @@ router.beforeEach(async (to, from, next) => {
return next('/')
}
// Plugin gating: a disabled backend plugin's frontend routes are dead ends.
// The enabled list is fetched once and cached; fail-open on error so a blip
// cannot brick navigation. Works unauthenticated (endpoint is jwt-optional).
if (to.meta.plugin) {
await loadEnabledPlugins()
if (!isPluginEnabled(to.meta.plugin)) {
useToast().info(`The ${to.meta.plugin} feature is not enabled.`)
return next('/')
}
}
// First-run: steer a fresh admin into the setup wizard (once, until finished).
if (authStore.isAuthenticated && authStore.isAdmin && to.path !== '/setup') {
if (!isSetupLoaded()) {

View File

@@ -1,46 +1,48 @@
/**
* Computers plugin routes
*/
export default [
{
path: 'pcs',
name: 'pcs',
component: () => import('../../views/pcs/PCsList.vue')
},
{
path: 'pcs/new',
name: 'pc-new',
component: () => import('../../views/pcs/PCForm.vue'),
meta: { requiresAuth: true }
},
{
path: 'pcs/:id',
name: 'pc-detail',
component: () => import('../../views/pcs/PCDetail.vue')
},
{
path: 'pcs/:id/edit',
name: 'pc-edit',
component: () => import('../../views/pcs/PCForm.vue'),
meta: { requiresAuth: true }
},
// Computer-specific settings
{
path: 'settings/pctypes',
name: 'pctypes',
component: () => import('../../views/settings/PCTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/operatingsystems',
name: 'operatingsystems',
component: () => import('../../views/settings/OperatingSystemsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/accessprotocols',
name: 'access-protocols',
component: () => import('../../views/settings/AccessProtocolsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
}
]
/**
* Computers plugin routes
*/
export default [
{
path: 'pcs',
name: 'pcs',
component: () => import('../../views/pcs/PCsList.vue'),
meta: { plugin: 'computers' }
},
{
path: 'pcs/new',
name: 'pc-new',
component: () => import('../../views/pcs/PCForm.vue'),
meta: { requiresAuth: true, plugin: 'computers' }
},
{
path: 'pcs/:id',
name: 'pc-detail',
component: () => import('../../views/pcs/PCDetail.vue'),
meta: { plugin: 'computers' }
},
{
path: 'pcs/:id/edit',
name: 'pc-edit',
component: () => import('../../views/pcs/PCForm.vue'),
meta: { requiresAuth: true, plugin: 'computers' }
},
// Computer-specific settings
{
path: 'settings/pctypes',
name: 'pctypes',
component: () => import('../../views/settings/PCTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
},
{
path: 'settings/operatingsystems',
name: 'operatingsystems',
component: () => import('../../views/settings/OperatingSystemsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
},
{
path: 'settings/accessprotocols',
name: 'access-protocols',
component: () => import('../../views/settings/AccessProtocolsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
}
]

View File

@@ -31,17 +31,20 @@ export default [
{
path: 'reports/pc-relationships',
name: 'report-pc-relationships',
component: () => import('../../views/reports/PCRelationshipsReport.vue')
component: () => import('../../views/reports/PCRelationshipsReport.vue'),
meta: { plugin: 'computers' }
},
{
path: 'reports/toner',
name: 'toner-report',
component: () => import('../../views/reports/TonerReport.vue')
component: () => import('../../views/reports/TonerReport.vue'),
meta: { plugin: 'printers' }
},
{
path: 'employees/:sso',
name: 'employee-detail',
component: () => import('../../views/employees/EmployeeDetail.vue')
component: () => import('../../views/employees/EmployeeDetail.vue'),
meta: { plugin: 'employees' }
},
// Settings
{
@@ -102,7 +105,7 @@ export default [
path: 'settings/slides',
name: 'slide-manager',
component: () => import('../../views/settings/SlideManager.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'slides' }
},
{
path: 'settings/equipmenttypes',

View File

@@ -1,27 +1,29 @@
/**
* Equipment plugin routes
*/
export default [
{
path: 'machines',
name: 'machines',
component: () => import('../../views/machines/MachinesList.vue')
},
{
path: 'machines/new',
name: 'machine-new',
component: () => import('../../views/machines/MachineForm.vue'),
meta: { requiresAuth: true }
},
{
path: 'machines/:id',
name: 'machine-detail',
component: () => import('../../views/machines/MachineDetail.vue')
},
{
path: 'machines/:id/edit',
name: 'machine-edit',
component: () => import('../../views/machines/MachineForm.vue'),
meta: { requiresAuth: true }
}
]
/**
* Equipment plugin routes
*/
export default [
{
path: 'machines',
name: 'machines',
component: () => import('../../views/machines/MachinesList.vue'),
meta: { plugin: 'equipment' }
},
{
path: 'machines/new',
name: 'machine-new',
component: () => import('../../views/machines/MachineForm.vue'),
meta: { requiresAuth: true, plugin: 'equipment' }
},
{
path: 'machines/:id',
name: 'machine-detail',
component: () => import('../../views/machines/MachineDetail.vue'),
meta: { plugin: 'equipment' }
},
{
path: 'machines/:id/edit',
name: 'machine-edit',
component: () => import('../../views/machines/MachineForm.vue'),
meta: { requiresAuth: true, plugin: 'equipment' }
}
]

View File

@@ -1,27 +1,29 @@
/**
* Knowledge Base routes (core feature)
*/
export default [
{
path: 'knowledgebase',
name: 'knowledgebase',
component: () => import('../../views/knowledgebase/KnowledgeBaseList.vue')
},
{
path: 'knowledgebase/new',
name: 'knowledgebase-new',
component: () => import('../../views/knowledgebase/KnowledgeBaseForm.vue'),
meta: { requiresAuth: true }
},
{
path: 'knowledgebase/:id',
name: 'knowledgebase-detail',
component: () => import('../../views/knowledgebase/KnowledgeBaseDetail.vue')
},
{
path: 'knowledgebase/:id/edit',
name: 'knowledgebase-edit',
component: () => import('../../views/knowledgebase/KnowledgeBaseForm.vue'),
meta: { requiresAuth: true }
}
]
/**
* Knowledge Base plugin routes
*/
export default [
{
path: 'knowledgebase',
name: 'knowledgebase',
component: () => import('../../views/knowledgebase/KnowledgeBaseList.vue'),
meta: { plugin: 'knowledgebase' }
},
{
path: 'knowledgebase/new',
name: 'knowledgebase-new',
component: () => import('../../views/knowledgebase/KnowledgeBaseForm.vue'),
meta: { requiresAuth: true, plugin: 'knowledgebase' }
},
{
path: 'knowledgebase/:id',
name: 'knowledgebase-detail',
component: () => import('../../views/knowledgebase/KnowledgeBaseDetail.vue'),
meta: { plugin: 'knowledgebase' }
},
{
path: 'knowledgebase/:id/edit',
name: 'knowledgebase-edit',
component: () => import('../../views/knowledgebase/KnowledgeBaseForm.vue'),
meta: { requiresAuth: true, plugin: 'knowledgebase' }
}
]

View File

@@ -1,40 +1,42 @@
/**
* Network plugin routes
*/
export default [
{
path: 'network',
name: 'network',
component: () => import('../../views/network/NetworkDevicesList.vue')
},
{
path: 'network/new',
name: 'network-new',
component: () => import('../../views/network/NetworkDeviceForm.vue'),
meta: { requiresAuth: true }
},
{
path: 'network/:id',
name: 'network-detail',
component: () => import('../../views/network/NetworkDeviceDetail.vue')
},
{
path: 'network/:id/edit',
name: 'network-edit',
component: () => import('../../views/network/NetworkDeviceForm.vue'),
meta: { requiresAuth: true }
},
// Network-specific settings
{
path: 'settings/vlans',
name: 'vlans',
component: () => import('../../views/settings/VLANsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/subnets',
name: 'subnets',
component: () => import('../../views/settings/SubnetsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
}
]
/**
* Network plugin routes
*/
export default [
{
path: 'network',
name: 'network',
component: () => import('../../views/network/NetworkDevicesList.vue'),
meta: { plugin: 'network' }
},
{
path: 'network/new',
name: 'network-new',
component: () => import('../../views/network/NetworkDeviceForm.vue'),
meta: { requiresAuth: true, plugin: 'network' }
},
{
path: 'network/:id',
name: 'network-detail',
component: () => import('../../views/network/NetworkDeviceDetail.vue'),
meta: { plugin: 'network' }
},
{
path: 'network/:id/edit',
name: 'network-edit',
component: () => import('../../views/network/NetworkDeviceForm.vue'),
meta: { requiresAuth: true, plugin: 'network' }
},
// Network-specific settings
{
path: 'settings/vlans',
name: 'vlans',
component: () => import('../../views/settings/VLANsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
},
{
path: 'settings/subnets',
name: 'subnets',
component: () => import('../../views/settings/SubnetsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
}
]

View File

@@ -1,38 +1,41 @@
/**
* Notifications plugin routes
*/
export default [
{
path: 'notifications',
name: 'notifications',
component: () => import('../../views/notifications/NotificationsList.vue')
},
{
path: 'notifications/new',
name: 'notification-new',
component: () => import('../../views/notifications/NotificationForm.vue'),
meta: { requiresAuth: true }
},
{
path: 'settings/notificationtypes',
name: 'notification-types',
component: () => import('../../views/notifications/NotificationTypesList.vue'),
meta: { requiresAuth: true }
},
{
path: 'notifications/:id',
name: 'notification-detail',
component: () => import('../../views/notifications/NotificationForm.vue')
},
{
path: 'notifications/:id/edit',
name: 'notification-edit',
component: () => import('../../views/notifications/NotificationForm.vue'),
meta: { requiresAuth: true }
},
{
path: 'calendar',
name: 'calendar',
component: () => import('../../views/CalendarView.vue')
}
]
/**
* Notifications plugin routes
*/
export default [
{
path: 'notifications',
name: 'notifications',
component: () => import('../../views/notifications/NotificationsList.vue'),
meta: { plugin: 'notifications' }
},
{
path: 'notifications/new',
name: 'notification-new',
component: () => import('../../views/notifications/NotificationForm.vue'),
meta: { requiresAuth: true, plugin: 'notifications' }
},
{
path: 'settings/notificationtypes',
name: 'notification-types',
component: () => import('../../views/notifications/NotificationTypesList.vue'),
meta: { requiresAuth: true, plugin: 'notifications' }
},
{
path: 'notifications/:id',
name: 'notification-detail',
component: () => import('../../views/notifications/NotificationForm.vue'),
meta: { plugin: 'notifications' }
},
{
path: 'notifications/:id/edit',
name: 'notification-edit',
component: () => import('../../views/notifications/NotificationForm.vue'),
meta: { requiresAuth: true, plugin: 'notifications' }
},
{
path: 'calendar',
name: 'calendar',
component: () => import('../../views/CalendarView.vue'),
meta: { plugin: 'notifications' }
}
]

View File

@@ -1,40 +1,42 @@
/**
* Printers plugin routes
*/
export default [
{
path: 'printers',
name: 'printers',
component: () => import('../../views/printers/PrintersList.vue')
},
{
path: 'printers/new',
name: 'printer-new',
component: () => import('../../views/printers/PrinterForm.vue'),
meta: { requiresAuth: true }
},
{
path: 'printers/:id',
name: 'printer-detail',
component: () => import('../../views/printers/PrinterDetail.vue')
},
{
path: 'printers/:id/edit',
name: 'printer-edit',
component: () => import('../../views/printers/PrinterForm.vue'),
meta: { requiresAuth: true }
},
// printer-specific settings
{
path: 'settings/modelsupplies',
name: 'model-supplies',
component: () => import('../../views/settings/ModelSuppliesList.vue'),
meta: { requiresAuth: true }
},
{
path: 'settings/printerdrivers',
name: 'printer-drivers',
component: () => import('../../views/settings/PrinterDriversList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
}
]
/**
* Printers plugin routes
*/
export default [
{
path: 'printers',
name: 'printers',
component: () => import('../../views/printers/PrintersList.vue'),
meta: { plugin: 'printers' }
},
{
path: 'printers/new',
name: 'printer-new',
component: () => import('../../views/printers/PrinterForm.vue'),
meta: { requiresAuth: true, plugin: 'printers' }
},
{
path: 'printers/:id',
name: 'printer-detail',
component: () => import('../../views/printers/PrinterDetail.vue'),
meta: { plugin: 'printers' }
},
{
path: 'printers/:id/edit',
name: 'printer-edit',
component: () => import('../../views/printers/PrinterForm.vue'),
meta: { requiresAuth: true, plugin: 'printers' }
},
// printer-specific settings
{
path: 'settings/modelsupplies',
name: 'model-supplies',
component: () => import('../../views/settings/ModelSuppliesList.vue'),
meta: { requiresAuth: true, plugin: 'printers' }
},
{
path: 'settings/printerdrivers',
name: 'printer-drivers',
component: () => import('../../views/settings/PrinterDriversList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
}
]

View File

@@ -1,27 +1,29 @@
/**
* USB plugin routes
*/
export default [
{
path: 'usb',
name: 'usb',
component: () => import('../../views/usb/USBList.vue')
},
{
path: 'usb/new',
name: 'usb-new',
component: () => import('../../views/usb/USBForm.vue'),
meta: { requiresAuth: true }
},
{
path: 'usb/:id',
name: 'usb-detail',
component: () => import('../../views/usb/USBDetail.vue')
},
{
path: 'usb/:id/edit',
name: 'usb-edit',
component: () => import('../../views/usb/USBForm.vue'),
meta: { requiresAuth: true }
}
]
/**
* USB plugin routes
*/
export default [
{
path: 'usb',
name: 'usb',
component: () => import('../../views/usb/USBList.vue'),
meta: { plugin: 'usb' }
},
{
path: 'usb/new',
name: 'usb-new',
component: () => import('../../views/usb/USBForm.vue'),
meta: { requiresAuth: true, plugin: 'usb' }
},
{
path: 'usb/:id',
name: 'usb-detail',
component: () => import('../../views/usb/USBDetail.vue'),
meta: { plugin: 'usb' }
},
{
path: 'usb/:id/edit',
name: 'usb-edit',
component: () => import('../../views/usb/USBForm.vue'),
meta: { requiresAuth: true, plugin: 'usb' }
}
]

View File

@@ -6,11 +6,12 @@ export default [
path: 'warranties',
name: 'warranties',
component: () => import('../../views/warranty/WarrantiesList.vue'),
meta: { requiresAuth: true }
meta: { plugin: 'warranty' }
},
{
path: 'reports/warranty',
name: 'warranty-report',
component: () => import('../../views/reports/WarrantyReport.vue')
component: () => import('../../views/reports/WarrantyReport.vue'),
meta: { plugin: 'warranty' }
}
]

View File

@@ -65,6 +65,35 @@ export async function getBrandPrimaryColor() {
return getSetting('brand_primary_color', '')
}
// Primary hover/active color (--primary-dark). Empty = derive from primary.
export async function getBrandPrimaryDarkColor() {
return getSetting('brand_primary_dark_color', '')
}
// Accent color (--secondary). Empty = built-in palette from style.css.
export async function getBrandAccentColor() {
return getSetting('brand_accent_color', '')
}
// Sidebar background color (--sidebar-bg). Empty = built-in palette.
export async function getBrandSidebarColor() {
return getSetting('brand_sidebar_color', '')
}
// Darken a #rrggbb hex color by the given fraction (0.15 = 15% darker).
// Returns null for anything that is not a 6-digit hex so callers can skip it.
function darkenHexColor(hex, fraction) {
const match = /^#([0-9a-fA-F]{6})$/.exec(hex)
if (!match) return null
const num = parseInt(match[1], 16)
const scale = 1 - fraction
const red = Math.round(((num >> 16) & 0xff) * scale)
const green = Math.round(((num >> 8) & 0xff) * scale)
const blue = Math.round((num & 0xff) * scale)
const toHex = (channel) => channel.toString(16).padStart(2, '0')
return `#${toHex(red)}${toHex(green)}${toHex(blue)}`
}
// ServiceNow ticket-link config. Returns the enabled flag, incident/change URL
// templates, and the global-search URL template (all with a {ticket}
// placeholder). Defaults mirror the previously-hardcoded GE ServiceNow URLs.
@@ -104,8 +133,25 @@ export async function applyBranding() {
}
link.href = favicon
}
const rootStyle = document.documentElement.style
const primaryColor = await getBrandPrimaryColor()
if (primaryColor) {
document.documentElement.style.setProperty('--primary', primaryColor)
rootStyle.setProperty('--primary', primaryColor)
}
// Hover color: use the explicit value, else darken primary ~15%.
const primaryDarkColor = await getBrandPrimaryDarkColor()
if (primaryDarkColor) {
rootStyle.setProperty('--primary-dark', primaryDarkColor)
} else if (primaryColor) {
const derived = darkenHexColor(primaryColor, 0.15)
if (derived) rootStyle.setProperty('--primary-dark', derived)
}
const accentColor = await getBrandAccentColor()
if (accentColor) {
rootStyle.setProperty('--secondary', accentColor)
}
const sidebarColor = await getBrandSidebarColor()
if (sidebarColor) {
rootStyle.setProperty('--sidebar-bg', sidebarColor)
}
}

View File

@@ -87,7 +87,7 @@ import { useRouter } from 'vue-router'
import ToastHost from '../components/ToastHost.vue'
import {
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler
} from 'lucide-vue-next'
import { useAuthStore } from '../stores/auth'
import { currentTheme, toggleTheme } from '../stores/theme'
@@ -127,6 +127,7 @@ const iconMap = {
'bar-chart-3': BarChart3,
'image': Image,
'shield': ShieldCheck,
'ruler': Ruler,
}
// Default navigation (used as fallback if API fails)

View File

@@ -75,24 +75,20 @@
<thead>
<tr>
<th>Device</th>
<th>Serial Number</th>
<th>Checked Out</th>
<th>Purpose</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="device in usbDevices" :key="device.usbdeviceid">
<tr v-for="checkout in usbDevices" :key="checkout.log_id">
<td>
<router-link :to="`/usb/${device.usbdeviceid}`">
{{ device.displayname }}
<router-link :to="`/usb/${checkout.device_id}`">
{{ checkout.device_id }}
</router-link>
</td>
<td>{{ device.serialnumber }}</td>
<td>{{ formatDate(device.checkoutdate) }}</td>
<td>{{ device.checkoutpurpose || '-' }}</td>
<td>{{ formatDate(checkout.timestamp) }}</td>
<td class="actions">
<button class="btn btn-small btn-success" @click="checkinDevice(device)">
<button class="btn btn-small btn-success" @click="checkinDevice(checkout)">
Check In
</button>
</td>
@@ -115,20 +111,18 @@
<tr>
<th>Device</th>
<th>Checked Out</th>
<th>Checked In</th>
<th>Purpose</th>
<th>Device Status</th>
</tr>
</thead>
<tbody>
<tr v-for="record in checkoutHistory" :key="record.usbcheckoutid">
<tr v-for="record in checkoutHistory" :key="record.log_id">
<td>
<router-link :to="`/usb/${record.usbdeviceid}`">
{{ record.devicename || `Device #${record.usbdeviceid}` }}
<router-link :to="`/usb/${record.device_id}`">
{{ record.device_id }}
</router-link>
</td>
<td>{{ formatDate(record.checkoutdate) }}</td>
<td>{{ record.checkindate ? formatDate(record.checkindate) : 'Still out' }}</td>
<td>{{ record.purpose || '-' }}</td>
<td>{{ formatDate(record.timestamp) }}</td>
<td>{{ record.device_status || '-' }}</td>
</tr>
</tbody>
</table>
@@ -213,6 +207,7 @@ async function loadRecognitions() {
async function loadUSBDevices() {
usbLoading.value = true
try {
// active check-out log rows for this badge
const response = await usbApi.getUserCheckouts(route.params.sso)
usbDevices.value = response.data.data || []
} catch (err) {
@@ -225,10 +220,9 @@ async function loadUSBDevices() {
async function loadCheckoutHistory() {
historyLoading.value = true
try {
// Get user's checkout history (all past checkouts)
const response = await usbApi.list({ user_id: route.params.sso, include_history: true })
// Filter to only show returned items (not currently checked out)
checkoutHistory.value = (response.data.data || []).filter(d => d.checkindate)
// all check-out log rows for this badge, newest first
const response = await usbApi.getUserCheckouts(route.params.sso, false)
checkoutHistory.value = response.data.data || []
} catch (err) {
console.error('Error loading checkout history:', err)
checkoutHistory.value = []
@@ -237,11 +231,11 @@ async function loadCheckoutHistory() {
}
}
async function checkinDevice(device) {
if (!confirm(`Check in ${device.displayname}?`)) return
async function checkinDevice(checkout) {
if (!confirm(`Check in ${checkout.device_id}?`)) return
try {
await usbApi.checkin(device.usbdeviceid)
await usbApi.checkin(checkout.device_id, { badge: checkout.badge_number || route.params.sso })
await loadUSBDevices()
await loadCheckoutHistory()
} catch (err) {

View File

@@ -1,288 +1,513 @@
<template>
<div class="page-header">
<h1>Reports</h1>
</div>
<div class="reports-grid">
<div class="report-card card" @click="router.push('/reports/toner')">
<h3>Toner Report</h3>
<p>View printers with low or critical toner/supply levels</p>
<span class="badge">Printers</span>
</div>
<div class="report-card card" @click="router.push('/reports/warranty')">
<h3>Warranty Report</h3>
<p>Assets bucketed by coverage: expired, expiring soon, active</p>
<span class="badge">Warranty</span>
</div>
<div v-for="report in reports" :key="report.id" class="report-card card" @click="runReport(report)">
<h3>{{ report.name }}</h3>
<p>{{ report.description }}</p>
<span class="badge">{{ report.category }}</span>
</div>
</div>
<!-- Report Results -->
<div v-if="currentReport" class="report-results card">
<div class="report-header">
<h2>{{ currentReport.name }}</h2>
<div class="report-actions">
<button class="btn btn-secondary" @click="exportCSV">Export CSV</button>
<button class="btn btn-secondary" @click="clearReport">Close</button>
</div>
</div>
<div v-if="loading" class="loading">Loading report...</div>
<div v-else-if="reportData">
<!-- Equipment by Type -->
<div v-if="currentReport.id === 'equipment-by-type'" class="report-content">
<p class="report-summary">Total: {{ reportData.total }}</p>
<table>
<thead>
<tr>
<th>Equipment Type</th>
<th>Description</th>
<th>Count</th>
</tr>
</thead>
<tbody>
<tr v-for="item in reportData.data" :key="item.equipmenttype">
<td>{{ item.equipmenttype }}</td>
<td>{{ item.description }}</td>
<td>{{ item.count }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Assets by Status -->
<div v-else-if="currentReport.id === 'assets-by-status'" class="report-content">
<p class="report-summary">Total: {{ reportData.total }}</p>
<table>
<thead>
<tr>
<th>Status</th>
<th>Count</th>
</tr>
</thead>
<tbody>
<tr v-for="item in reportData.data" :key="item.status">
<td>
<span class="badge" :style="{ backgroundColor: item.color }">{{ item.status }}</span>
</td>
<td>{{ item.count }}</td>
</tr>
</tbody>
</table>
</div>
<!-- KB Popularity -->
<div v-else-if="currentReport.id === 'kb-popularity'" class="report-content">
<table>
<thead>
<tr>
<th>Article</th>
<th>Application</th>
<th>Clicks</th>
</tr>
</thead>
<tbody>
<tr v-for="item in reportData.data" :key="item.linkid">
<td>
<a v-if="item.linkurl" :href="item.linkurl" target="_blank">{{ item.shortdescription }}</a>
<span v-else>{{ item.shortdescription }}</span>
</td>
<td>{{ item.application || '-' }}</td>
<td>{{ item.clicks }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Asset Inventory -->
<div v-else-if="currentReport.id === 'asset-inventory'" class="report-content">
<p class="report-summary">Total Assets: {{ reportData.total }}</p>
<h3>By Type</h3>
<table>
<thead><tr><th>Type</th><th>Count</th></tr></thead>
<tbody>
<tr v-for="item in reportData.data.bytype" :key="item.type">
<td>{{ item.type }}</td><td>{{ item.count }}</td>
</tr>
</tbody>
</table>
<h3>By Status</h3>
<table>
<thead><tr><th>Status</th><th>Count</th></tr></thead>
<tbody>
<tr v-for="item in reportData.data.bystatus" :key="item.status">
<td><span class="badge" :style="{ backgroundColor: item.color }">{{ item.status }}</span></td>
<td>{{ item.count }}</td>
</tr>
</tbody>
</table>
<h3>By Location</h3>
<table>
<thead><tr><th>Location</th><th>Count</th></tr></thead>
<tbody>
<tr v-for="item in reportData.data.bylocation" :key="item.location">
<td>{{ item.location }}</td><td>{{ item.count }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Generic fallback -->
<div v-else class="report-content">
<pre>{{ JSON.stringify(reportData, null, 2) }}</pre>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { reportsApi } from '@/api'
const router = useRouter()
const reports = ref([])
const currentReport = ref(null)
const reportData = ref(null)
const loading = ref(false)
onMounted(async () => {
await loadReports()
})
async function loadReports() {
try {
const response = await reportsApi.list()
reports.value = response.data.data.reports
} catch (error) {
console.error('Error loading reports:', error)
}
}
async function runReport(report) {
// PC Relationships has its own dedicated page
if (report.id === 'pc-relationships') {
router.push('/reports/pc-relationships')
return
}
currentReport.value = report
loading.value = true
reportData.value = null
try {
let response
switch (report.id) {
case 'equipment-by-type':
response = await reportsApi.equipmentByType()
break
case 'assets-by-status':
response = await reportsApi.assetsByStatus()
break
case 'kb-popularity':
response = await reportsApi.kbPopularity()
break
case 'warranty-status':
response = await reportsApi.warrantyStatus()
break
case 'software-compliance':
response = await reportsApi.softwareCompliance()
break
case 'asset-inventory':
response = await reportsApi.assetInventory()
break
default:
console.error('Unknown report:', report.id)
return
}
reportData.value = response.data.data
} catch (error) {
console.error('Error running report:', error)
} finally {
loading.value = false
}
}
function exportCSV() {
if (!currentReport.value) return
window.open(`/api/reports/${currentReport.value.id}?format=csv`, '_blank')
}
function clearReport() {
currentReport.value = null
reportData.value = null
}
</script>
<style scoped>
.reports-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.report-card {
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.report-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.report-card h3 {
margin-top: 0;
margin-bottom: 0.5rem;
}
.report-card p {
color: var(--text-light);
margin-bottom: 1rem;
}
.report-results {
margin-top: 2rem;
}
.report-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.report-header h2 {
margin: 0;
}
.report-actions {
display: flex;
gap: 0.5rem;
}
.report-summary {
font-size: 1.1rem;
font-weight: 500;
margin-bottom: 1rem;
}
.report-content h3 {
margin-top: 1.5rem;
margin-bottom: 0.5rem;
}
.report-content table {
width: 100%;
margin-bottom: 1rem;
}
</style>
<template>
<div class="page-header">
<h1>Reports</h1>
</div>
<!-- Card grid hides while a report is open so results sit at the top. -->
<template v-if="!currentReport">
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search reports..."
/>
</div>
<!-- All report cards, grouped by category. Cards come from GET /api/reports,
which merges core reports with plugin-contributed cards (get_reports). -->
<div v-for="group in groupedReports" :key="group.category" class="report-group">
<h2 class="group-title">{{ titleCase(group.category) }}</h2>
<div class="reports-grid">
<div
v-for="report in group.reports"
:key="report.id"
class="report-card card"
@click="openReport(report)"
>
<h3>{{ report.name }}</h3>
<p>{{ report.description }}</p>
<span class="badge">{{ report.category }}</span>
</div>
</div>
</div>
<div v-if="!groupedReports.length" class="empty-state">
No reports match your search.
</div>
</template>
<!-- Inline report results (endpoint-backed reports) -->
<div v-if="currentReport" class="report-results card">
<div class="report-header">
<h2>{{ currentReport.name }}</h2>
<div class="report-actions">
<button class="btn btn-secondary" @click="exportCSV">Export CSV</button>
<button class="btn btn-secondary" @click="clearReport">Back to Reports</button>
</div>
</div>
<!-- Server-side filters this report accepts (see reportFilterFields) -->
<div v-if="activeFilterFields.length" class="filters">
<select
v-if="activeFilterFields.includes('businessunit')"
v-model="filterState.businessunitid"
class="form-control"
@change="refreshReport"
>
<option value="">All business units</option>
<option v-for="bu in filterOptions.businessunits" :key="bu.businessunitid" :value="bu.businessunitid">
{{ bu.businessunit }}
</option>
</select>
<select
v-if="activeFilterFields.includes('assettype')"
v-model="filterState.assettypeid"
class="form-control"
@change="refreshReport"
>
<option value="">All asset types</option>
<option v-for="at in filterOptions.assettypes" :key="at.assettypeid" :value="at.assettypeid">
{{ titleCase(at.assettype) }}
</option>
</select>
<select
v-if="activeFilterFields.includes('location')"
v-model="filterState.locationid"
class="form-control"
@change="refreshReport"
>
<option value="">All locations</option>
<option v-for="loc in filterOptions.locations" :key="loc.locationid" :value="loc.locationid">
{{ loc.locationname }}
</option>
</select>
<select
v-if="activeFilterFields.includes('application')"
v-model="filterState.appid"
class="form-control"
@change="refreshReport"
>
<option value="">All required applications</option>
<option v-for="app in filterOptions.applications" :key="app.appid" :value="app.appid">
{{ app.appname }}
</option>
</select>
<input
v-if="activeFilterFields.includes('limit')"
v-model.number="filterState.limit"
type="number"
min="1"
max="100"
class="form-control limit-input"
placeholder="Limit (20)"
@change="refreshReport"
/>
</div>
<div v-if="loading" class="loading">Loading report...</div>
<div v-else-if="reportData">
<!-- Equipment by Type -->
<div v-if="currentReport.id === 'equipment-by-type'" class="report-content">
<p class="report-summary">Total: {{ reportData.total }}</p>
<table>
<thead>
<tr>
<th>Equipment Type</th>
<th>Description</th>
<th>Count</th>
</tr>
</thead>
<tbody>
<tr v-for="item in reportData.data" :key="item.equipmenttype">
<td>{{ item.equipmenttype }}</td>
<td>{{ item.description }}</td>
<td>{{ item.count }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Assets by Status -->
<div v-else-if="currentReport.id === 'assets-by-status'" class="report-content">
<p class="report-summary">Total: {{ reportData.total }}</p>
<table>
<thead>
<tr>
<th>Status</th>
<th>Count</th>
</tr>
</thead>
<tbody>
<tr v-for="item in reportData.data" :key="item.status">
<td>
<span class="badge" :style="{ backgroundColor: item.color }">{{ item.status }}</span>
</td>
<td>{{ item.count }}</td>
</tr>
</tbody>
</table>
</div>
<!-- KB Popularity -->
<div v-else-if="currentReport.id === 'kb-popularity'" class="report-content">
<table>
<thead>
<tr>
<th>Article</th>
<th>Application</th>
<th>Clicks</th>
</tr>
</thead>
<tbody>
<tr v-for="item in reportData.data" :key="item.linkid">
<td>
<a v-if="item.linkurl" :href="item.linkurl" target="_blank">{{ item.shortdescription }}</a>
<span v-else>{{ item.shortdescription }}</span>
</td>
<td>{{ item.application || '-' }}</td>
<td>{{ item.clicks }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Asset Inventory -->
<div v-else-if="currentReport.id === 'asset-inventory'" class="report-content">
<p class="report-summary">Total Assets: {{ reportData.total }}</p>
<h3>By Type</h3>
<table>
<thead><tr><th>Type</th><th>Count</th></tr></thead>
<tbody>
<tr v-for="item in reportData.data.bytype" :key="item.type">
<td>{{ item.type }}</td><td>{{ item.count }}</td>
</tr>
</tbody>
</table>
<h3>By Status</h3>
<table>
<thead><tr><th>Status</th><th>Count</th></tr></thead>
<tbody>
<tr v-for="item in reportData.data.bystatus" :key="item.status">
<td><span class="badge" :style="{ backgroundColor: item.color }">{{ item.status }}</span></td>
<td>{{ item.count }}</td>
</tr>
</tbody>
</table>
<h3>By Location</h3>
<table>
<thead><tr><th>Location</th><th>Count</th></tr></thead>
<tbody>
<tr v-for="item in reportData.data.bylocation" :key="item.location">
<td>{{ item.location }}</td><td>{{ item.count }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Generic fallback -->
<div v-else class="report-content">
<pre>{{ JSON.stringify(reportData, null, 2) }}</pre>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { reportsApi, businessunitsApi, assetsApi, locationsApi, applicationsApi } from '@/api'
const router = useRouter()
const route = useRoute()
const reports = ref([])
const search = ref('')
const currentReport = ref(null)
const reportData = ref(null)
const loading = ref(false)
// Preferred category order; anything else falls in after, sorted alphabetically.
const categoryOrder = ['inventory', 'compliance', 'usage']
// server-side filters each inline report accepts (query params on its endpoint)
const reportFilterFields = {
'equipment-by-type': ['businessunit'],
'assets-by-status': ['assettype', 'businessunit'],
'asset-inventory': ['businessunit', 'location'],
'kb-popularity': ['limit'],
'software-compliance': ['application']
}
const filterState = ref({ businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '' })
const filterOptions = ref({ businessunits: [], assettypes: [], locations: [], applications: [] })
const activeFilterFields = computed(() =>
currentReport.value ? (reportFilterFields[currentReport.value.id] || []) : []
)
function resetFilters() {
filterState.value = { businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '' }
}
// fetch dropdown options once, only for the fields the open report needs
async function loadFilterOptions(fields) {
try {
if (fields.includes('businessunit') && !filterOptions.value.businessunits.length) {
const response = await businessunitsApi.list({ perpage: 100 })
filterOptions.value.businessunits = response.data.data || []
}
if (fields.includes('assettype') && !filterOptions.value.assettypes.length) {
const response = await assetsApi.types.list()
filterOptions.value.assettypes = response.data.data || []
}
if (fields.includes('location') && !filterOptions.value.locations.length) {
const response = await locationsApi.list({ perpage: 100 })
filterOptions.value.locations = response.data.data || []
}
if (fields.includes('application') && !filterOptions.value.applications.length) {
const response = await applicationsApi.list({ perpage: 100 })
filterOptions.value.applications = response.data.data || []
}
} catch (error) {
console.error('Error loading filter options:', error)
}
}
function filterParams() {
const params = {}
const state = filterState.value
if (state.businessunitid) params.businessunitid = state.businessunitid
if (state.assettypeid) params.assettypeid = state.assettypeid
if (state.locationid) params.locationid = state.locationid
if (state.appid) params.appid = state.appid
if (state.limit) params.limit = state.limit
return params
}
function refreshReport() {
if (currentReport.value) runReport(currentReport.value)
}
onMounted(async () => {
await loadReports()
// Honor a deep link / refresh with ?report=<id> already in the URL.
applyQuery(route.query.report)
})
async function loadReports() {
try {
const response = await reportsApi.list()
reports.value = response.data.data.reports
} catch (error) {
console.error('Error loading reports:', error)
}
}
function titleCase(text) {
return String(text || '')
.split(/[\s_-]+/)
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
// Filter by name/description/category, then group by category in the fixed order.
const groupedReports = computed(() => {
const term = search.value.trim().toLowerCase()
const matches = reports.value.filter(report => {
if (!term) return true
return (
(report.name || '').toLowerCase().includes(term) ||
(report.description || '').toLowerCase().includes(term) ||
(report.category || '').toLowerCase().includes(term)
)
})
const byCategory = {}
for (const report of matches) {
const category = report.category || 'other'
if (!byCategory[category]) byCategory[category] = []
byCategory[category].push(report)
}
const categories = Object.keys(byCategory).sort((a, b) => {
const ai = categoryOrder.indexOf(a)
const bi = categoryOrder.indexOf(b)
if (ai !== -1 || bi !== -1) {
return (ai === -1 ? Infinity : ai) - (bi === -1 ? Infinity : bi)
}
return a.localeCompare(b)
})
return categories.map(category => ({ category, reports: byCategory[category] }))
})
function openReport(report) {
// Cards with a dedicated frontend page carry a route; navigate to it.
if (report.route) {
router.push(report.route)
return
}
// PC Relationships has its own dedicated page.
if (report.id === 'pc-relationships') {
router.push('/reports/pc-relationships')
return
}
// Inline reports live in the URL query so browser back/forward works:
// back pops the query and returns to the card grid.
router.push({ query: { report: report.id } })
}
// The query param is the source of truth for the open inline report.
watch(() => route.query.report, applyQuery)
function applyQuery(id) {
if (!id) {
currentReport.value = null
reportData.value = null
return
}
const report = reports.value.find(r => r.id === id)
if (report) {
resetFilters()
loadFilterOptions(reportFilterFields[id] || [])
runReport(report)
}
}
async function runReport(report) {
currentReport.value = report
loading.value = true
reportData.value = null
window.scrollTo(0, 0)
const params = filterParams()
try {
let response
switch (report.id) {
case 'equipment-by-type':
response = await reportsApi.equipmentByType(params)
break
case 'assets-by-status':
response = await reportsApi.assetsByStatus(params)
break
case 'kb-popularity':
response = await reportsApi.kbPopularity(params)
break
case 'software-compliance':
response = await reportsApi.softwareCompliance(params)
break
case 'asset-inventory':
response = await reportsApi.assetInventory(params)
break
default:
console.error('Unknown report:', report.id)
return
}
reportData.value = response.data.data
} catch (error) {
console.error('Error running report:', error)
} finally {
loading.value = false
}
}
function exportCSV() {
if (!currentReport.value) return
const params = new URLSearchParams({ format: 'csv', ...filterParams() })
window.open(`/api/reports/${currentReport.value.id}?${params}`, '_blank')
}
function clearReport() {
// Drop the query param; the watcher clears the panel state.
router.push({ query: {} })
}
</script>
<style scoped>
.report-group {
margin-bottom: 2rem;
}
.group-title {
margin: 0 0 1rem;
font-size: 1.25rem;
color: var(--text);
border-bottom: 1px solid var(--border);
padding-bottom: 0.5rem;
}
.reports-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.report-card {
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.report-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.report-card h3 {
margin-top: 0;
margin-bottom: 0.5rem;
}
.report-card p {
color: var(--text-light);
margin-bottom: 1rem;
}
.empty-state {
color: var(--text-light);
padding: 2rem 0;
}
.report-results {
margin-top: 0.5rem;
}
.report-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.report-header h2 {
margin: 0;
}
.report-actions {
display: flex;
gap: 0.5rem;
}
.report-summary {
font-size: 1.1rem;
font-weight: 500;
margin-bottom: 1rem;
}
.report-content h3 {
margin-top: 1.5rem;
margin-bottom: 0.5rem;
}
.report-content table {
width: 100%;
margin-bottom: 1rem;
}
.limit-input {
max-width: 140px;
}
</style>

View File

@@ -3,6 +3,7 @@
<div class="page-header">
<h1>Toner Report</h1>
<div class="header-actions">
<button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div>
</div>
@@ -118,6 +119,26 @@ const filteredPrinters = computed(() => {
)
})
function exportCSV() {
// one row per supply, honoring the active filter
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
const rows = [['printer', 'assetnumber', 'location', 'ipaddress', 'supply', 'level', 'status']]
for (const printer of filteredPrinters.value) {
for (const supply of printer.supplies || []) {
rows.push([
printer.printername || '', printer.assetnumber || '', printer.location || '',
printer.ipaddress || '', supply.name || '', supply.level, supply.status || ''
])
}
}
const csv = rows.map(row => row.map(quote).join(',')).join('\n')
const link = document.createElement('a')
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }))
link.download = 'toner_report.csv'
link.click()
URL.revokeObjectURL(link.href)
}
onMounted(async () => {
try {
const response = await printersApi.lowSupplies()

View File

@@ -2,7 +2,10 @@
<div>
<div class="page-header">
<h1>Warranty Report</h1>
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
<div class="header-actions">
<button v-if="!loading" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div>
</div>
<div v-if="loading" class="loading">Loading...</div>
@@ -71,6 +74,26 @@ function assetLink(a) {
return (map[a.assettypename] || '/assets/') + a.assetid
}
function exportCSV() {
// one row per warranty, covered assets joined with ;
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
const rows = [['bucket', 'vendor', 'servicelevel', 'enddate', 'assets']]
for (const b of bucketOrder) {
for (const w of buckets.value[b.key] || []) {
rows.push([
b.label, w.vendor || '', w.servicelevel || '', w.enddate || '',
(w.assets || []).map(a => a.assetnumber).join('; ')
])
}
}
const csv = rows.map(row => row.map(quote).join(',')).join('\n')
const link = document.createElement('a')
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }))
link.download = 'warranty_report.csv'
link.click()
URL.revokeObjectURL(link.href)
}
onMounted(async () => {
try {
const response = await warrantyApi.report()
@@ -100,5 +123,6 @@ onMounted(async () => {
background: var(--bg); border-radius: 12px; font-size: 0.8rem; text-decoration: none; color: var(--text);
}
.muted { color: var(--text-light); }
.header-actions { display: flex; gap: 0.5rem; }
.servicelevel-cell { max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</style>

View File

@@ -292,6 +292,75 @@
<small class="input-hint">Hex color for the primary accent. Leave blank to use the built-in palette.</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Primary hover color</span>
<div class="color-input-row">
<input
type="color"
:value="settings.brand_primary_dark_color || '#000000'"
@input="settings.brand_primary_dark_color = $event.target.value"
@change="saveSetting('brand_primary_dark_color', settings.brand_primary_dark_color)"
:disabled="saving"
>
<input
type="text"
v-model="settings.brand_primary_dark_color"
placeholder="(blank = derived from primary)"
@blur="saveSetting('brand_primary_dark_color', settings.brand_primary_dark_color)"
:disabled="saving"
>
</div>
<small class="input-hint">Hover/active shade of the primary color. Leave blank to auto-darken the primary color ~15%.</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Accent color</span>
<div class="color-input-row">
<input
type="color"
:value="settings.brand_accent_color || '#000000'"
@input="settings.brand_accent_color = $event.target.value"
@change="saveSetting('brand_accent_color', settings.brand_accent_color)"
:disabled="saving"
>
<input
type="text"
v-model="settings.brand_accent_color"
placeholder="(blank = built-in)"
@blur="saveSetting('brand_accent_color', settings.brand_accent_color)"
:disabled="saving"
>
</div>
<small class="input-hint">Accent for secondary buttons and badges. Leave blank to use the built-in palette.</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Sidebar color</span>
<div class="color-input-row">
<input
type="color"
:value="settings.brand_sidebar_color || '#000000'"
@input="settings.brand_sidebar_color = $event.target.value"
@change="saveSetting('brand_sidebar_color', settings.brand_sidebar_color)"
:disabled="saving"
>
<input
type="text"
v-model="settings.brand_sidebar_color"
placeholder="(blank = built-in)"
@blur="saveSetting('brand_sidebar_color', settings.brand_sidebar_color)"
:disabled="saving"
>
</div>
<small class="input-hint">Sidebar background color. Leave blank to use the built-in palette.</small>
</label>
</div>
</div>
</div>
@@ -925,6 +994,9 @@ const settings = reactive({
badge_logo: '',
site_favicon: '',
brand_primary_color: '',
brand_primary_dark_color: '',
brand_accent_color: '',
brand_sidebar_color: '',
// Printing and labels
qr_target_printer: '',
qr_target_usb: '',

View File

@@ -1,7 +1,7 @@
// Shared settings navigation catalog.
// Used by SettingsLayout (left rail) and SettingsIndex (landing overview) so the
// grouping lives in one place.
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact } from 'lucide-vue-next'
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Ruler } from 'lucide-vue-next'
export const settingsGroups = [
{
@@ -46,6 +46,12 @@ export const settingsGroups = [
{ to: '/settings/machinetypes', icon: Monitor, title: 'Machine Types', description: 'Manage machine type categories' },
],
},
{
title: 'Measuring Tools',
cards: [
{ to: '/settings/measuringtooltypes', icon: Ruler, title: 'Measuring Tool Types', description: 'Manage measuring-tool subtypes (caliper, micrometer, thread gage...) + map colors' },
],
},
{
title: 'Locations & Organization',
cards: [

View File

@@ -14,25 +14,21 @@
<div class="hero-card">
<div class="hero-content">
<div class="hero-title">
<h1>{{ device.alias || device.machinenumber }}</h1>
<h1>{{ device.device_desc || device.device_id }}</h1>
</div>
<div class="hero-meta">
<span class="badge badge-lg" :class="device.ischeckedout ? 'badge-warning' : 'badge-success'">
{{ device.ischeckedout ? 'Checked Out' : 'Available' }}
<span class="badge badge-lg" :class="device.status === 'checked-out' ? 'badge-warning' : 'badge-success'">
{{ device.status === 'checked-out' ? 'Checked Out' : 'Available' }}
</span>
</div>
<div class="hero-details">
<div class="hero-detail" v-if="device.serialnumber">
<div class="hero-detail" v-if="device.device_id">
<span class="hero-detail-label">Serial Number</span>
<span class="hero-detail-value mono">{{ device.serialnumber }}</span>
<span class="hero-detail-value mono">{{ device.device_id }}</span>
</div>
<div class="hero-detail" v-if="device.vendorname">
<span class="hero-detail-label">Vendor</span>
<span class="hero-detail-value">{{ device.vendorname }}</span>
</div>
<div class="hero-detail" v-if="device.modelname">
<span class="hero-detail-label">Model</span>
<span class="hero-detail-value">{{ device.modelname }}</span>
<div class="hero-detail" v-if="device.locker_location">
<span class="hero-detail-label">Locker Location</span>
<span class="hero-detail-value">{{ device.locker_location }}</span>
</div>
</div>
</div>
@@ -41,7 +37,7 @@
<!-- Action Buttons -->
<div class="action-buttons">
<button
v-if="!device.ischeckedout"
v-if="device.status !== 'checked-out'"
class="btn btn-primary btn-lg"
@click="openCheckoutModal"
>
@@ -57,31 +53,27 @@
</div>
<!-- Current Checkout Info -->
<div class="section-card" v-if="device.currentcheckout">
<div class="section-card" v-if="device.status === 'checked-out'">
<h3 class="section-title">Current Checkout</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Checked Out By</span>
<span class="info-value">{{ device.currentcheckout.sso }}</span>
<span class="info-value">{{ device.current_holder_name || device.current_holder }}</span>
</div>
<div class="info-row">
<span class="info-label">Checkout Time</span>
<span class="info-value">{{ formatDate(device.currentcheckout.checkouttime) }}</span>
</div>
<div class="info-row" v-if="device.currentcheckout.checkoutreason">
<span class="info-label">Reason</span>
<span class="info-value">{{ device.currentcheckout.checkoutreason }}</span>
<span class="info-value">{{ formatDate(device.checkout_time) }}</span>
</div>
</div>
</div>
<!-- Checkout History -->
<!-- Check-in/out Log -->
<div class="card">
<div class="card-header">
<h3>Checkout History</h3>
</div>
<div v-if="!device.checkouthistory?.length" class="empty-state">
<div v-if="!device.checkinoutlog?.length" class="empty-state">
No checkout history
</div>
@@ -89,23 +81,21 @@
<table>
<thead>
<tr>
<th>User SSO</th>
<th>Checkout Time</th>
<th>Check-in Time</th>
<th>Wiped</th>
<th>Reason</th>
<th>Action</th>
<th>User</th>
<th>Time</th>
<th>Sanitized</th>
</tr>
</thead>
<tbody>
<tr v-for="checkout in device.checkouthistory" :key="checkout.checkoutid">
<td>{{ checkout.sso }}</td>
<td>{{ formatDate(checkout.checkouttime) }}</td>
<td>{{ checkout.checkintime ? formatDate(checkout.checkintime) : 'Still out' }}</td>
<tr v-for="entry in device.checkinoutlog" :key="entry.log_id">
<td>{{ entry.action === 'check-in' ? 'Check In' : 'Check Out' }}</td>
<td>{{ entry.badge_name || entry.badge_number }}</td>
<td>{{ formatDate(entry.timestamp) }}</td>
<td>
<span v-if="checkout.checkintime">{{ checkout.waswiped ? 'Yes' : 'No' }}</span>
<span v-if="entry.action === 'check-in'">{{ entry.sanitized ? 'Yes' : 'No' }}</span>
<span v-else>-</span>
</td>
<td>{{ checkout.checkoutreason || '-' }}</td>
</tr>
</tbody>
</table>
@@ -215,7 +205,11 @@ function closeModals() {
async function doCheckout() {
try {
await usbApi.checkout(device.value.machineid, checkoutForm.value)
// The SSO the operator enters is the badge; API resolves the name.
await usbApi.checkout(device.value.device_id, {
badge: checkoutForm.value.sso,
reason: checkoutForm.value.reason
})
closeModals()
await loadDevice()
} catch (error) {
@@ -226,7 +220,12 @@ async function doCheckout() {
async function doCheckin() {
try {
await usbApi.checkin(device.value.machineid, checkinForm.value)
// Check the current holder back in; sanitized mirrors the wiped checkbox.
await usbApi.checkin(device.value.device_id, {
badge: device.value.current_holder,
sanitized: checkinForm.value.waswiped,
notes: checkinForm.value.notes
})
closeModals()
await loadDevice()
} catch (error) {

View File

@@ -9,102 +9,42 @@
<form v-else @submit.prevent="saveDevice">
<div class="form-group">
<label for="serialnumber">Serial Number *</label>
<label for="device_id">Serial Number *</label>
<input
id="serialnumber"
v-model="form.serialnumber"
id="device_id"
v-model="form.device_id"
type="text"
class="form-control"
required
maxlength="100"
:disabled="isEdit"
/>
</div>
<div class="form-group">
<label for="displayname">Display Name *</label>
<label for="device_desc">Description</label>
<input
id="displayname"
v-model="form.displayname"
id="device_desc"
v-model="form.device_desc"
type="text"
class="form-control"
required
maxlength="100"
placeholder="e.g., USB Flash Drive #1"
/>
</div>
<div class="form-group">
<label for="label">Label</label>
<label for="locker_location">Locker Location</label>
<input
id="label"
v-model="form.label"
id="locker_location"
v-model="form.locker_location"
type="text"
class="form-control"
maxlength="100"
placeholder="Physical label on device"
maxlength="200"
placeholder="Where the device is stored"
/>
</div>
<div class="form-row">
<div class="form-group">
<label for="usbtypeid">Device Type</label>
<select
id="usbtypeid"
v-model="form.usbtypeid"
class="form-control"
>
<option value="">-- Select Type --</option>
<option
v-for="type in types"
:key="type.usbtypeid"
:value="type.usbtypeid"
>
{{ type.typename }}
</option>
</select>
</div>
<div class="form-group">
<label for="capacitygb">Capacity (GB)</label>
<input
id="capacitygb"
v-model.number="form.capacitygb"
type="number"
class="form-control"
min="0"
step="1"
/>
</div>
</div>
<div class="form-group">
<label for="vendorid">Vendor</label>
<select
id="vendorid"
v-model="form.vendorid"
class="form-control"
>
<option value="">-- Select Vendor --</option>
<option
v-for="vendor in vendors"
:key="vendor.vendorid"
:value="vendor.vendorid"
>
{{ vendor.vendorname }}
</option>
</select>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea
id="notes"
v-model="form.notes"
class="form-control"
rows="3"
></textarea>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="form-actions">
@@ -121,7 +61,7 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { usbApi, vendorsApi } from '@/api'
import { usbApi } from '@/api'
import { apiError } from '../../utils/apiError'
const route = useRoute()
@@ -133,42 +73,23 @@ const loading = ref(true)
const saving = ref(false)
const error = ref('')
const types = ref([])
const vendors = ref([])
const form = ref({
serialnumber: '',
displayname: '',
label: '',
usbtypeid: '',
capacitygb: null,
vendorid: '',
notes: ''
device_id: '',
device_desc: '',
locker_location: ''
})
onMounted(async () => {
try {
// Load types and vendors
const [typesRes, vendorsRes] = await Promise.all([
usbApi.types.list(),
vendorsApi.list({ perpage: 1000 })
])
types.value = typesRes.data.data || []
vendors.value = vendorsRes.data.data || []
// Load device if editing
if (isEdit.value) {
const response = await usbApi.get(route.params.id)
const device = response.data.data
form.value = {
serialnumber: device.serialnumber || '',
displayname: device.displayname || '',
label: device.label || '',
usbtypeid: device.usbtypeid || '',
capacitygb: device.capacitygb || null,
vendorid: device.vendorid || '',
notes: device.notes || ''
device_id: device.device_id || '',
device_desc: device.device_desc || '',
locker_location: device.locker_location || ''
}
}
} catch (err) {
@@ -184,19 +105,16 @@ async function saveDevice() {
saving.value = true
try {
// Create needs device_id; update keys off the path id so it is omitted.
const data = {
serialnumber: form.value.serialnumber,
displayname: form.value.displayname,
label: form.value.label || null,
usbtypeid: form.value.usbtypeid || null,
capacitygb: form.value.capacitygb || null,
vendorid: form.value.vendorid || null,
notes: form.value.notes || null
device_desc: form.value.device_desc || null,
locker_location: form.value.locker_location || null
}
if (isEdit.value) {
await usbApi.update(route.params.id, data)
} else {
data.device_id = form.value.device_id
await usbApi.create(data)
}

View File

@@ -36,27 +36,27 @@
</tr>
</thead>
<tbody>
<tr v-for="device in devices" :key="device.machineid">
<tr v-for="device in devices" :key="device.device_id">
<td>
<strong>{{ device.alias || device.machinenumber }}</strong>
<div v-if="device.modelname" class="text-muted">{{ device.modelname }}</div>
<strong>{{ device.device_desc || device.device_id }}</strong>
<div v-if="device.locker_location" class="text-muted">{{ device.locker_location }}</div>
</td>
<td class="mono">{{ device.serialnumber || '-' }}</td>
<td class="mono">{{ device.device_id || '-' }}</td>
<td>
<span class="badge" :class="device.ischeckedout ? 'badge-warning' : 'badge-success'">
{{ device.ischeckedout ? 'Checked Out' : 'Available' }}
<span class="badge" :class="device.status === 'checked-out' ? 'badge-warning' : 'badge-success'">
{{ device.status === 'checked-out' ? 'Checked Out' : 'Available' }}
</span>
</td>
<td>
<template v-if="device.currentcheckout">
{{ device.currentcheckout.checkoutname || device.currentcheckout.sso }}
<div class="text-muted">{{ formatDate(device.currentcheckout.checkouttime) }}</div>
<template v-if="device.status === 'checked-out'">
{{ device.current_holder_name || device.current_holder }}
<div class="text-muted">{{ formatDate(device.checkout_time) }}</div>
</template>
<span v-else>-</span>
</td>
<td class="actions">
<button
v-if="!device.ischeckedout"
v-if="device.status !== 'checked-out'"
class="btn btn-primary btn-sm"
@click="openCheckoutModal(device)"
>
@@ -70,7 +70,7 @@
Check In
</button>
<router-link
:to="`/usb/${device.machineid}`"
:to="`/usb/${device.device_id}`"
class="btn btn-secondary btn-sm"
>
View
@@ -102,7 +102,7 @@
<template #header>
<h3>Checkout USB Device</h3>
</template>
<p>Checking out: <strong>{{ selectedDevice?.alias || selectedDevice?.machinenumber }}</strong></p>
<p>Checking out: <strong>{{ selectedDevice?.device_desc || selectedDevice?.device_id }}</strong></p>
<div class="form-group">
<label>Employee *</label>
<EmployeeSearch v-model="selectedEmployee" placeholder="Search by name..." />
@@ -122,7 +122,7 @@
<template #header>
<h3>Check In USB Device</h3>
</template>
<p>Checking in: <strong>{{ selectedDevice?.alias || selectedDevice?.machinenumber }}</strong></p>
<p>Checking in: <strong>{{ selectedDevice?.device_desc || selectedDevice?.device_id }}</strong></p>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="checkinForm.waswiped" />
@@ -235,9 +235,9 @@ async function doCheckout() {
if (!selectedEmployee.value) return
try {
await usbApi.checkout(selectedDevice.value.machineid, {
sso: selectedEmployee.value.sso,
name: selectedEmployee.value.name,
// API resolves the holder name from the badge; send badge = employee SSO.
await usbApi.checkout(selectedDevice.value.device_id, {
badge: selectedEmployee.value.sso,
reason: checkoutForm.value.reason
})
closeModals()
@@ -250,7 +250,12 @@ async function doCheckout() {
async function doCheckin() {
try {
await usbApi.checkin(selectedDevice.value.machineid, checkinForm.value)
// Check the current holder back in; sanitized mirrors the wiped checkbox.
await usbApi.checkin(selectedDevice.value.device_id, {
badge: selectedDevice.value.current_holder,
sanitized: checkinForm.value.waswiped,
notes: checkinForm.value.notes
})
closeModals()
loadDevices()
} catch (error) {

View File

@@ -0,0 +1,14 @@
"""Alembic environment for the computers plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which
filters the metadata to this plugin's tables and drives Alembic against the
per-plugin version table alembic_version_computers. See ADR-008 for the
ownership cutover between the core chain and per-plugin chains.
"""
import os
os.environ['PLUGIN_NAME'] = 'computers'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
run_migrations()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,28 @@
"""computers plugin anchor (ownership cutover).
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
already created every table this plugin owns at the cutover point, so there is
nothing to build here. This revision gives the plugin's own chain a base that
`flask plugin upgrade-all` can stamp into alembic_version_computers. From this
anchor forward, new computers schema changes land as 000N revisions in this
directory, never in the core chain. See ADR-008.
"""
from alembic import op # noqa: F401
import sqlalchemy as sa # noqa: F401
# revision identifiers, used by Alembic.
revision = 'computers0001anchor'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# No-op: the core chain owns every table that exists at the cutover.
pass
def downgrade():
# No-op: this anchor never unwinds core-owned tables.
pass

View File

@@ -0,0 +1,14 @@
"""Alembic environment for the employees plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which
filters the metadata to this plugin's tables and drives Alembic against the
per-plugin version table alembic_version_employees. See ADR-008 for the
ownership cutover between the core chain and per-plugin chains.
"""
import os
os.environ['PLUGIN_NAME'] = 'employees'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
run_migrations()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,28 @@
"""employees plugin anchor (ownership cutover).
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
already created every table this plugin owns at the cutover point, so there is
nothing to build here. This revision gives the plugin's own chain a base that
`flask plugin upgrade-all` can stamp into alembic_version_employees. From this
anchor forward, new employees schema changes land as 000N revisions in this
directory, never in the core chain. See ADR-008.
"""
from alembic import op # noqa: F401
import sqlalchemy as sa # noqa: F401
# revision identifiers, used by Alembic.
revision = 'employees0001anchor'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# No-op: the core chain owns every table that exists at the cutover.
pass
def downgrade():
# No-op: this anchor never unwinds core-owned tables.
pass

View File

@@ -0,0 +1,14 @@
"""Alembic environment for the equipment plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which
filters the metadata to this plugin's tables and drives Alembic against the
per-plugin version table alembic_version_equipment. See ADR-008 for the
ownership cutover between the core chain and per-plugin chains.
"""
import os
os.environ['PLUGIN_NAME'] = 'equipment'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
run_migrations()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,28 @@
"""equipment plugin anchor (ownership cutover).
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
already created every table this plugin owns at the cutover point, so there is
nothing to build here. This revision gives the plugin's own chain a base that
`flask plugin upgrade-all` can stamp into alembic_version_equipment. From this
anchor forward, new equipment schema changes land as 000N revisions in this
directory, never in the core chain. See ADR-008.
"""
from alembic import op # noqa: F401
import sqlalchemy as sa # noqa: F401
# revision identifiers, used by Alembic.
revision = 'equipment0001anchor'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# No-op: the core chain owns every table that exists at the cutover.
pass
def downgrade():
# No-op: this anchor never unwinds core-owned tables.
pass

View File

@@ -0,0 +1,14 @@
"""Alembic environment for the knowledgebase plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which
filters the metadata to this plugin's tables and drives Alembic against the
per-plugin version table alembic_version_knowledgebase. See ADR-008 for the
ownership cutover between the core chain and per-plugin chains.
"""
import os
os.environ['PLUGIN_NAME'] = 'knowledgebase'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
run_migrations()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,28 @@
"""knowledgebase plugin anchor (ownership cutover).
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
already created every table this plugin owns at the cutover point, so there is
nothing to build here. This revision gives the plugin's own chain a base that
`flask plugin upgrade-all` can stamp into alembic_version_knowledgebase. From this
anchor forward, new knowledgebase schema changes land as 000N revisions in this
directory, never in the core chain. See ADR-008.
"""
from alembic import op # noqa: F401
import sqlalchemy as sa # noqa: F401
# revision identifiers, used by Alembic.
revision = 'knowledgebase0001anchor'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# No-op: the core chain owns every table that exists at the cutover.
pass
def downgrade():
# No-op: this anchor never unwinds core-owned tables.
pass

View File

@@ -0,0 +1,14 @@
"""Alembic environment for the network plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which
filters the metadata to this plugin's tables and drives Alembic against the
per-plugin version table alembic_version_network. See ADR-008 for the
ownership cutover between the core chain and per-plugin chains.
"""
import os
os.environ['PLUGIN_NAME'] = 'network'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
run_migrations()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,28 @@
"""network plugin anchor (ownership cutover).
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
already created every table this plugin owns at the cutover point, so there is
nothing to build here. This revision gives the plugin's own chain a base that
`flask plugin upgrade-all` can stamp into alembic_version_network. From this
anchor forward, new network schema changes land as 000N revisions in this
directory, never in the core chain. See ADR-008.
"""
from alembic import op # noqa: F401
import sqlalchemy as sa # noqa: F401
# revision identifiers, used by Alembic.
revision = 'network0001anchor'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# No-op: the core chain owns every table that exists at the cutover.
pass
def downgrade():
# No-op: this anchor never unwinds core-owned tables.
pass

View File

@@ -0,0 +1,14 @@
"""Alembic environment for the notifications plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which
filters the metadata to this plugin's tables and drives Alembic against the
per-plugin version table alembic_version_notifications. See ADR-008 for the
ownership cutover between the core chain and per-plugin chains.
"""
import os
os.environ['PLUGIN_NAME'] = 'notifications'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
run_migrations()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,28 @@
"""notifications plugin anchor (ownership cutover).
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
already created every table this plugin owns at the cutover point, so there is
nothing to build here. This revision gives the plugin's own chain a base that
`flask plugin upgrade-all` can stamp into alembic_version_notifications. From this
anchor forward, new notifications schema changes land as 000N revisions in this
directory, never in the core chain. See ADR-008.
"""
from alembic import op # noqa: F401
import sqlalchemy as sa # noqa: F401
# revision identifiers, used by Alembic.
revision = 'notifications0001anchor'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# No-op: the core chain owns every table that exists at the cutover.
pass
def downgrade():
# No-op: this anchor never unwinds core-owned tables.
pass

View File

@@ -0,0 +1,14 @@
"""Alembic environment for the printers plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which
filters the metadata to this plugin's tables and drives Alembic against the
per-plugin version table alembic_version_printers. See ADR-008 for the
ownership cutover between the core chain and per-plugin chains.
"""
import os
os.environ['PLUGIN_NAME'] = 'printers'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
run_migrations()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,28 @@
"""printers plugin anchor (ownership cutover).
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
already created every table this plugin owns at the cutover point, so there is
nothing to build here. This revision gives the plugin's own chain a base that
`flask plugin upgrade-all` can stamp into alembic_version_printers. From this
anchor forward, new printers schema changes land as 000N revisions in this
directory, never in the core chain. See ADR-008.
"""
from alembic import op # noqa: F401
import sqlalchemy as sa # noqa: F401
# revision identifiers, used by Alembic.
revision = 'printers0001anchor'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# No-op: the core chain owns every table that exists at the cutover.
pass
def downgrade():
# No-op: this anchor never unwinds core-owned tables.
pass

View File

@@ -218,3 +218,15 @@ class PrintersPlugin(BasePlugin):
'position': 20,
},
]
def get_reports(self) -> List[Dict]:
"""Return report card definitions for the Reports hub."""
return [
{
'id': 'toner',
'name': 'Toner Report',
'description': 'Printers with low or critical toner/supply levels',
'category': 'printers',
'route': '/reports/toner',
},
]

View File

@@ -0,0 +1,14 @@
"""Alembic environment for the slides plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which
filters the metadata to this plugin's tables and drives Alembic against the
per-plugin version table alembic_version_slides. See ADR-008 for the
ownership cutover between the core chain and per-plugin chains.
"""
import os
os.environ['PLUGIN_NAME'] = 'slides'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
run_migrations()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,28 @@
"""slides plugin anchor (ownership cutover).
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
already created every table this plugin owns at the cutover point, so there is
nothing to build here. This revision gives the plugin's own chain a base that
`flask plugin upgrade-all` can stamp into alembic_version_slides. From this
anchor forward, new slides schema changes land as 000N revisions in this
directory, never in the core chain. See ADR-008.
"""
from alembic import op # noqa: F401
import sqlalchemy as sa # noqa: F401
# revision identifiers, used by Alembic.
revision = 'slides0001anchor'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# No-op: the core chain owns every table that exists at the cutover.
pass
def downgrade():
# No-op: this anchor never unwinds core-owned tables.
pass

View File

@@ -683,9 +683,19 @@ def list_all_checkouts():
@usb_bp.route('/checkouts/active', methods=['GET'])
@jwt_required(optional=True)
def list_active_checkouts():
"""List the latest check-out log row for every currently checked-out device."""
"""List the latest check-out log row for every currently checked-out device.
Query parameters:
- badge: filter by badge_number
"""
if _usb_selfhosted():
return selfhosted.list_checkouts(request, active_only=True)
where_badge = ''
params = []
badge = request.args.get('badge', '').strip()
if badge:
where_badge = 'AND l.badge_number = %s '
params.append(badge)
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
@@ -697,11 +707,13 @@ def list_active_checkouts():
'JOIN checkinoutlog l ON l.device_id = d.device_id '
"AND l.action = 'check-out' "
"WHERE d.status = 'checked-out' "
+ where_badge +
'AND l.log_id = ('
' SELECT MAX(l2.log_id) FROM checkinoutlog l2 '
" WHERE l2.device_id = d.device_id AND l2.action = 'check-out'"
') '
'ORDER BY l.timestamp DESC, l.log_id DESC',
params,
)
rows = cur.fetchall()
data = [_log_to_dict(cur, row) for row in rows]

View File

@@ -0,0 +1,14 @@
"""Alembic environment for the usb plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which
filters the metadata to this plugin's tables and drives Alembic against the
per-plugin version table alembic_version_usb. See ADR-008 for the
ownership cutover between the core chain and per-plugin chains.
"""
import os
os.environ['PLUGIN_NAME'] = 'usb'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
run_migrations()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,28 @@
"""usb plugin anchor (ownership cutover).
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
already created every table this plugin owns at the cutover point, so there is
nothing to build here. This revision gives the plugin's own chain a base that
`flask plugin upgrade-all` can stamp into alembic_version_usb. From this
anchor forward, new usb schema changes land as 000N revisions in this
directory, never in the core chain. See ADR-008.
"""
from alembic import op # noqa: F401
import sqlalchemy as sa # noqa: F401
# revision identifiers, used by Alembic.
revision = 'usb0001anchor'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# No-op: the core chain owns every table that exists at the cutover.
pass
def downgrade():
# No-op: this anchor never unwinds core-owned tables.
pass

View File

@@ -0,0 +1,14 @@
"""Alembic environment for the warranty plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which
filters the metadata to this plugin's tables and drives Alembic against the
per-plugin version table alembic_version_warranty. See ADR-008 for the
ownership cutover between the core chain and per-plugin chains.
"""
import os
os.environ['PLUGIN_NAME'] = 'warranty'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
run_migrations()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,28 @@
"""warranty plugin anchor (ownership cutover).
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
already created every table this plugin owns at the cutover point, so there is
nothing to build here. This revision gives the plugin's own chain a base that
`flask plugin upgrade-all` can stamp into alembic_version_warranty. From this
anchor forward, new warranty schema changes land as 000N revisions in this
directory, never in the core chain. See ADR-008.
"""
from alembic import op # noqa: F401
import sqlalchemy as sa # noqa: F401
# revision identifiers, used by Alembic.
revision = 'warranty0001anchor'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# No-op: the core chain owns every table that exists at the cutover.
pass
def downgrade():
# No-op: this anchor never unwinds core-owned tables.
pass

View File

@@ -61,5 +61,16 @@ class WarrantyPlugin(BasePlugin):
},
]
def get_reports(self) -> List[Dict]:
return [
{
'id': 'warranty',
'name': 'Warranty Report',
'description': 'Assets bucketed by coverage: expired, expiring soon, active',
'category': 'warranty',
'route': '/reports/warranty',
},
]
def init_app(self, app: Flask, db_instance) -> None:
logger.info(f"Warranty plugin initialized (v{self.meta.version})")

View File

@@ -18,7 +18,9 @@ from .plugins import plugin_manager
# 0.4.0: removed the never-implemented get_searchable_fields hook (search is a
# core concern over the asset model) and wired the get_dashboard_widgets hook to
# a real consumer (/api/dashboard/widgets). Pre-1.0 contract reduction.
__contract_version__ = '0.5.0'
# 0.6.0: added the get_reports hook, consumed by GET /api/reports to merge
# plugin report cards into the Reports hub. Additive optional hook, minor bump.
__contract_version__ = '0.6.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent

View File

@@ -26,6 +26,21 @@ def list_plugins():
})
@plugins_bp.route('/enabled', methods=['GET'])
@jwt_required(optional=True)
def list_enabled_plugins():
"""Return just the names of enabled plugins as a flat array.
Cheap registry read (no DB). Exposed to anonymous callers on purpose:
the navigation endpoint already leaks the same enabled/disabled signal,
and the frontend needs it (including unauthenticated kiosk routes like
/tv) to gate plugin-owned routes. No metadata beyond the names.
"""
pm = current_app.extensions.get('plugin_manager')
names = pm.registry.get_enabled_plugins() if pm else []
return success_response(sorted(names))
@plugins_bp.route('/<name>', methods=['PUT'])
@jwt_required()
@require_role('admin')

View File

@@ -2,8 +2,8 @@
import csv
import io
from datetime import datetime, timedelta, timezone
from flask import Blueprint, request, Response
from datetime import datetime, timezone
from flask import Blueprint, request, Response, current_app
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
@@ -206,109 +206,6 @@ def kb_popularity():
})
# =============================================================================
# Report: Warranty Status
# =============================================================================
@reports_bp.route('/warranty-status', methods=['GET'])
@jwt_required(optional=True)
def warranty_status():
"""
Report: Assets by warranty expiration status.
Categories: Expired, Expiring Soon (90 days), Valid, No Warranty Data
Query parameters:
- assettypeid: Filter by asset type
- format: 'json' (default) or 'csv'
"""
now = datetime.now(timezone.utc).replace(tzinfo=None)
expiring_threshold = now + timedelta(days=90)
# Try to get warranty data from equipment or machines
try:
from plugins.equipment.models import Equipment
from plugins.computers.models import Computer
# Equipment warranty
equipment_query = db.session.query(
Asset.assetid,
Asset.assetnumber,
Asset.name,
Equipment.warrantyenddate
).join(Equipment, Equipment.assetid == Asset.assetid
).filter(Asset.isactive == True)
if type_id := request.args.get('assettypeid'):
equipment_query = equipment_query.filter(Asset.assettypeid == int(type_id))
equipment_data = equipment_query.all()
expired = []
expiring_soon = []
valid = []
no_data = []
for row in equipment_data:
item = {
'assetid': row.assetid,
'assetnumber': row.assetnumber,
'name': row.name,
'warrantyenddate': row.warrantyenddate.isoformat() if row.warrantyenddate else None
}
if row.warrantyenddate is None:
no_data.append(item)
elif row.warrantyenddate < now:
expired.append(item)
elif row.warrantyenddate < expiring_threshold:
expiring_soon.append(item)
else:
valid.append(item)
data = {
'expired': {'count': len(expired), 'items': expired},
'expiringsoon': {'count': len(expiring_soon), 'items': expiring_soon},
'valid': {'count': len(valid), 'items': valid},
'nodata': {'count': len(no_data), 'items': no_data}
}
except (ImportError, AttributeError):
# Fallback: no warranty data available
data = {
'expired': {'count': 0, 'items': []},
'expiringsoon': {'count': 0, 'items': []},
'valid': {'count': 0, 'items': []},
'nodata': {'count': 0, 'items': []}
}
if request.args.get('format') == 'csv':
# Flatten for CSV
flat_data = []
for status, info in data.items():
for item in info['items']:
item['warrantystatus'] = status
flat_data.append(item)
csv_data = generate_csv(flat_data, ['assetid', 'assetnumber', 'name', 'warrantyenddate', 'warrantystatus'])
return Response(
csv_data,
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=warranty_status.csv'}
)
return success_response({
'report': 'warranty_status',
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
'data': data,
'summary': {
'expired': data['expired']['count'],
'expiringsoon': data['expiringsoon']['count'],
'valid': data['valid']['count'],
'nodata': data['nodata']['count']
}
})
# =============================================================================
# Report: Software Compliance
# =============================================================================
@@ -631,13 +528,6 @@ def list_reports():
'endpoint': '/api/reports/kb-popularity',
'category': 'usage'
},
{
'id': 'warranty-status',
'name': 'Warranty Status',
'description': 'Assets by warranty expiration status',
'endpoint': '/api/reports/warranty-status',
'category': 'compliance'
},
{
'id': 'software-compliance',
'name': 'Software Compliance',
@@ -661,6 +551,24 @@ def list_reports():
}
]
# Merge report cards contributed by enabled plugins (get_reports hook).
# Same access pattern as dashboard.get_navigation: skip disabled plugins,
# fail loud in dev/test, isolate a broken plugin in prod.
pm = current_app.extensions.get('plugin_manager')
if pm:
for name, plugin in pm.get_all_plugins().items():
if not pm.registry.is_enabled(name):
continue
try:
for entry in plugin.get_reports() or []:
entry['plugin'] = name
reports.append(entry)
except Exception:
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
raise
current_app.logger.exception(
'Plugin %s get_reports failed', name)
return success_response({
'reports': reports,
'total': len(reports)

View File

@@ -466,6 +466,27 @@ def build_default_settings():
'category': 'branding',
'description': 'Primary brand color as a CSS color value (blank = built-in theme color)'
},
{
'key': 'brand_primary_dark_color',
'value': '',
'valuetype': 'string',
'category': 'branding',
'description': 'Primary hover/active color (blank = derived by darkening the primary color ~15%)'
},
{
'key': 'brand_accent_color',
'value': '',
'valuetype': 'string',
'category': 'branding',
'description': 'Accent color for secondary buttons and badges (blank = built-in theme color)'
},
{
'key': 'brand_sidebar_color',
'value': '',
'valuetype': 'string',
'category': 'branding',
'description': 'Sidebar background color (blank = built-in theme color)'
},
]
# Printed QR/label targets. Blank template = QR links to the asset's own

View File

@@ -85,6 +85,11 @@ class Permission(db.Model):
('warranty.create', 'Create warranties', 'warranty'),
('warranty.edit', 'Edit warranties', 'warranty'),
('warranty.delete', 'Delete warranties', 'warranty'),
# Measuring tools
('measuringtools.view', 'View measuring tools', 'measuringtools'),
('measuringtools.create', 'Create measuring tools', 'measuringtools'),
('measuringtools.edit', 'Edit measuring tools', 'measuringtools'),
('measuringtools.delete', 'Delete measuring tools', 'measuringtools'),
# Reports
('reports.view', 'View reports', 'reports'),
('reports.export', 'Export reports', 'reports'),

View File

@@ -79,13 +79,14 @@ class PluginManager:
self._register_plugin_components(plugin)
def upgrade_all_plugins(self) -> Dict[str, str]:
"""Run pending Alembic migrations for every loaded plugin.
"""Run pending Alembic migrations for every discovered plugin.
Returns {plugin_name: 'ok'|'no-migrations'|<error str>}. Skips
plugins with no migrations/ directory. Use from the CLI
(`flask plugin upgrade-all`) on a fresh deploy after the core
schema is in place; existing deploys that still use db.create_all
can ignore this and continue to do so.
plugins with no migrations/ directory. Driven by the CLI
(`flask plugin upgrade-all`), which every deploy runs after
`flask db upgrade`. Each bundled plugin's chain begins with a
stamp-only anchor (the core chain already built its tables); later
per-plugin migrations extend that chain. See ADR-008. Idempotent.
"""
results: Dict[str, str] = {}
if not self.migration_manager:
@@ -285,6 +286,18 @@ class PluginManager:
self.registry.enable(name)
# Surface (do not auto-apply) a plugin chain that is ahead of the DB, so
# an operator enabling a plugin knows to run `flask plugin upgrade-all`.
try:
if self.migration_manager and \
self.migration_manager.has_unapplied_migrations(name):
logger.warning(
f"Plugin {name} has unapplied migrations; "
f"run 'flask plugin upgrade-all'"
)
except Exception:
logger.debug(f"Could not check migration state for {name}")
# Fire the on_enable hook best-effort. Do NOT register the blueprint
# here: Flask forbids register_blueprint after the first request, so
# routes/nav for a re-enabled plugin take effect on the next restart

View File

@@ -1,7 +1,8 @@
"""Shared Alembic env.py logic for bundled plugins.
Each bundled plugin (computers, equipment, network, notifications, printers,
usb) has a `migrations/env.py` that does the minimum:
Every bundled plugin that owns tables (computers, employees, equipment,
knowledgebase, network, notifications, printers, slides, usb, warranty) has a
`migrations/env.py` that does the minimum:
import os
os.environ['PLUGIN_NAME'] = 'computers'
@@ -12,6 +13,13 @@ This module wires the plugin's models into a MetaData object filtered to
only the tables that belong to that plugin, then runs Alembic in either
offline or online mode against the Flask app's configured engine.
Ownership cutover (see ADR-008): the core Alembic chain created every table
that exists through its head (`7d16_directoryemployees`), including the plugin
tables. Each plugin's `0001` migration is therefore a stamp-only no-op that
just records the anchor revision in `alembic_version_<plugin>`. NEW plugin
schema changes land as `plugins/<name>/migrations/000N` from here on, never in
the core chain.
Plugin tables must be importable via `plugins.<name>.models`. Plugins
register their `__tablename__` set in PLUGIN_TABLE_OWNERS below so the
filter is explicit (avoids depending on import-side-effect global state).
@@ -31,12 +39,18 @@ logger = logging.getLogger('alembic.env.plugin')
# Explicit table-ownership map. Adding tables to a plugin requires updating
# this dict so the per-plugin migration knows which tables to include.
PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
'computers': ('computertypes', 'computers', 'computerinstalledapps'),
'computers': ('computertypes', 'computers', 'computerinstalledapps',
'accessprotocols', 'computeraccess'),
'employees': ('directoryemployees',),
'equipment': ('equipmenttypes', 'equipment'),
'knowledgebase': ('knowledgebase',),
'measuringtools': ('measuringtooltypes', 'measuringtools'),
'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'),
'notifications': ('notificationtypes', 'notifications'),
'printers': ('printertypes', 'printers', 'modelsupplies'),
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers'),
'slides': ('tvslides',),
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
'warranty': ('warranties', 'warrantyassets'),
}
@@ -129,12 +143,12 @@ def run_migrations():
with context.begin_transaction():
context.run_migrations()
else:
from sqlalchemy import engine_from_config
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
# Build the engine straight from the resolved URL. The plugin manager
# drives this via a programmatic alembic Config (no ini file), so
# config.get_section returns an empty dict and engine_from_config would
# find no sqlalchemy.url. db_url is already resolved above.
from sqlalchemy import create_engine
connectable = create_engine(db_url, poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(
connection=connection,

View File

@@ -208,3 +208,22 @@ class BasePlugin(ABC):
}
"""
return []
def get_reports(self) -> List[Dict]:
"""
Return report card definitions for the Reports hub.
Each entry: {
'id': str, # stable report id
'name': str, # card title
'description': str, # one-line blurb
'category': str, # grouping key (lowercase)
# plus EXACTLY ONE of:
'route': str, # frontend path for a dedicated report page
'endpoint': str, # API endpoint for generic inline rendering
}
Consumed by GET /api/reports, which merges these after the static core
reports. Disabled plugins are skipped by the consumer.
"""
return []

View File

@@ -110,6 +110,15 @@ def enable_plugin(name: str):
if pm.enable_plugin(name):
click.echo(click.style(f"Enabled {name}", fg='green'))
# Nudge the operator if the plugin's chain is ahead of the DB.
try:
if pm.migration_manager and \
pm.migration_manager.has_unapplied_migrations(name):
click.echo(click.style(
f" {name} has unapplied migrations - "
f"run 'flask plugin upgrade-all'", fg='yellow'))
except Exception:
pass
else:
click.echo(click.style(f"Failed to enable {name}", fg='red'))
raise SystemExit(1)
@@ -213,9 +222,11 @@ def new_plugin(name: str, description: str, overwrite: bool):
click.echo('Next steps:')
click.echo(f' 1. Edit plugins/{name}/models/{name}.py with your domain fields')
click.echo(f' 2. Edit plugins/{name}/api/routes.py with your endpoints')
click.echo(f' 3. Run: flask plugin install {name}')
click.echo(f' 4. Run: flask db migrate -m "Add {name} plugin"')
click.echo(f' 5. Run: flask db upgrade')
click.echo(f' 3. Add plugins/{name}/migrations/ with an Alembic chain that')
click.echo(f' creates your tables (per-plugin chain, NOT the core chain;')
click.echo(f' see ADR-008). Register the tables in PLUGIN_TABLE_OWNERS.')
click.echo(f' 4. Run: flask plugin install {name}')
click.echo(f' 5. Run: flask plugin upgrade-all')
click.echo(f' 6. Run: pytest plugins/{name}/tests/')
@@ -246,12 +257,12 @@ def migrate_plugin(name: str, revision: str):
@plugin_cli.command('upgrade-all')
@with_appcontext
def upgrade_all_plugins():
"""Run pending migrations for every loaded plugin.
"""Run pending migrations for every discovered plugin.
Idempotent. Use on a fresh deploy after core schema is in place,
instead of (or alongside) `db-utils create-all`. Existing deployments
that still bootstrap plugin tables via db.create_all can ignore this
command until ready to move a plugin onto its Alembic version chain.
Idempotent. Run this after `flask db upgrade` on every deploy and
upgrade. It stamps each bundled plugin's anchor revision into
alembic_version_<plugin> and applies any per-plugin migrations added
after the ownership cutover (ADR-008). Safe to re-run at head.
"""
pm = current_app.extensions.get('plugin_manager')
if not pm:

View File

@@ -158,8 +158,13 @@ class PluginMigrationManager:
return None
def has_pending_migrations(self, plugin_name: str) -> bool:
"""Check if plugin has pending migrations."""
# Simplified check - would need DB connection for full check
"""Check if plugin has any migration scripts on disk.
File-level check only (no DB): True when the plugin ships a
migrations/versions dir with at least one script. Used by
upgrade-all to decide whether the plugin participates in the
per-plugin Alembic flow at all.
"""
migrations_dir = self.get_migrations_dir(plugin_name)
if not migrations_dir:
return False
@@ -168,6 +173,35 @@ class PluginMigrationManager:
if not versions_dir.exists():
return False
# Check if there are any migration files
# Any migration files on disk?
migration_files = list(versions_dir.glob('*.py'))
return len(migration_files) > 0
def get_applied_revision(self, plugin_name: str) -> Optional[str]:
"""Return the revision stamped in alembic_version_<plugin> in the DB,
or None if the plugin chain has never been stamped (or on any error)."""
migrations_dir = self.get_migrations_dir(plugin_name)
if not migrations_dir or not self.database_url:
return None
try:
from sqlalchemy import create_engine
from alembic.runtime.migration import MigrationContext
engine = create_engine(self.database_url)
with engine.connect() as connection:
context = MigrationContext.configure(
connection,
opts={'version_table': f'alembic_version_{plugin_name}'},
)
return context.get_current_revision()
except Exception:
return None
def has_unapplied_migrations(self, plugin_name: str) -> bool:
"""True when the plugin's on-disk chain head is ahead of what the DB
has stamped. Compares the script head against alembic_version_<plugin>.
Best-effort: returns False when it cannot tell (no chain / no DB)."""
head = self.get_current_revision(plugin_name) # script head on disk
if not head:
return False
return self.get_applied_revision(plugin_name) != head

View File

@@ -0,0 +1,29 @@
"""GET /api/plugins/enabled: the flat enabled-plugin-name list that drives
frontend route gating (ADR-009).
Pins two properties: the endpoint is reachable anonymously and returns a
plain array of names, and a disabled plugin's name drops out of that array
(so its frontend routes gate off), mirroring the search-disabled pattern.
"""
def test_enabled_anonymous_returns_array(client):
"""No auth required (jwt-optional): returns a flat array of name strings."""
resp = client.get('/api/plugins/enabled')
assert resp.status_code == 200, resp.get_json()
data = resp.get_json()['data']
assert isinstance(data, list)
assert all(isinstance(name, str) for name in data)
def test_disabled_plugin_absent(app, client, monkeypatch):
"""A disabled plugin's name is missing; enabled ones remain present."""
pm = app.extensions['plugin_manager']
# Pretend printers is enabled but usb is not.
monkeypatch.setattr(pm.registry, 'get_enabled_plugins',
lambda: ['printers', 'computers'])
resp = client.get('/api/plugins/enabled')
assert resp.status_code == 200, resp.get_json()
data = resp.get_json()['data']
assert 'printers' in data
assert 'usb' not in data

View File

@@ -0,0 +1,55 @@
"""Tests for the get_reports hook consumer (GET /api/reports).
Pins the wiring added for BasePlugin.get_reports: the reports list merges
enabled plugins' report cards after the static core reports and skips disabled
ones. Also guards the removed legacy warranty-status core report.
Plugin enabled-state is monkeypatched (not persisted) so these tests do not
mutate the shared instance/plugins.json registry file.
"""
def _report_ids(client, headers):
response = client.get('/api/reports', headers=headers)
assert response.status_code == 200, response.get_json()
reports = response.get_json()['data']['reports']
assert isinstance(reports, list)
return reports, {r['id'] for r in reports}
def test_reports_include_core_reports(client, auth_headers):
"""The static core reports are always listed."""
_, ids = _report_ids(client, auth_headers)
assert 'equipment-by-type' in ids
assert 'pc-relationships' in ids
def test_legacy_warranty_status_report_absent(client, auth_headers):
"""The dead warranty-status core report was removed."""
_, ids = _report_ids(client, auth_headers)
assert 'warranty-status' not in ids
def test_enabled_plugin_reports_are_merged(app, client, auth_headers, monkeypatch):
"""Enabled plugins implementing get_reports contribute their cards."""
pm = app.extensions['plugin_manager']
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
reports, ids = _report_ids(client, auth_headers)
# warranty + printers implement get_reports
assert 'warranty' in ids
assert 'toner' in ids
# merged cards carry their originating plugin name
warranty_card = next(r for r in reports if r['id'] == 'warranty')
assert warranty_card['plugin'] == 'warranty'
assert warranty_card['route'] == '/reports/warranty'
def test_disabled_plugin_reports_drop_out(app, client, auth_headers, monkeypatch):
"""A disabled plugin's report cards disappear from the list."""
pm = app.extensions['plugin_manager']
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'warranty')
_, ids = _report_ids(client, auth_headers)
assert 'warranty' not in ids
assert 'toner' in ids # printers still enabled

View File

@@ -137,6 +137,25 @@ def test_baseplugin_has_dashboard_widgets_hook():
assert hasattr(BasePlugin, 'get_dashboard_widgets')
def test_baseplugin_has_reports_hook():
"""The reports hook is on the contract surface (contract 0.6.0)."""
assert hasattr(BasePlugin, 'get_reports')
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
def test_plugin_get_reports_is_iterable(plugin_instances, name):
"""get_reports returns a list (default empty)."""
plugin = plugin_instances[name]
reports = plugin.get_reports()
assert isinstance(reports, list)
# Each card carries an id/name/category plus exactly one of route/endpoint.
for card in reports:
assert card.get('id') and card.get('name') and card.get('category')
assert ('route' in card) ^ ('endpoint' in card), (
f'{name} report card must have exactly one of route/endpoint'
)
def test_get_services_hook_has_consumer(app):
"""get_services is consumed by plugin_manager.get_service (no dead hook)."""
with app.app_context():
@@ -187,7 +206,11 @@ _PLUGIN_IMPORT_RE = re.compile(
def _plugin_source_files():
root = Path(__file__).resolve().parent.parent / 'plugins'
return [p for p in root.rglob('*.py') if '__pycache__' not in p.parts]
# Skip migrations/ - Alembic env.py is framework glue that legitimately
# calls the shared runner in shopdb.plugins.alembic_template (see ADR-008),
# not plugin domain code. Mirrors the style check excluding versions/.
return [p for p in root.rglob('*.py')
if '__pycache__' not in p.parts and 'migrations' not in p.parts]
def test_plugins_only_import_contract_surface():

View File

@@ -1,10 +1,22 @@
"""Plugin table-ownership tests.
"""Per-plugin Alembic migration-chain guard tests.
Bundled plugin schema is owned by the core migration chain (deploys run
`flask db upgrade` only, which reproduces the full schema). The per-plugin
Alembic helpers remain for external/filesystem plugins; these tests pin the
PLUGIN_TABLE_OWNERS registry those helpers consume.
Ownership cutover (ADR-008): the core Alembic chain created every table that
exists through its head (`7d16_directoryemployees`), including the plugin
tables. From that point on, each bundled plugin that owns tables carries its
own chain under `plugins/<name>/migrations/`. The `0001` migration in each
chain is a stamp-only no-op anchor: the core chain already built the tables, so
there is nothing to create; the anchor just gives the plugin chain a base that
`flask plugin upgrade-all` stamps into `alembic_version_<plugin>`.
These tests pin that contract:
* PLUGIN_TABLE_OWNERS stays in sync with what the models declare.
* Every plugin that owns tables has a valid single-head chain.
* The anchor migrations are genuine no-ops.
* `flask plugin upgrade-all` runs clean on a fresh DB and is idempotent.
"""
import ast
from pathlib import Path
import pytest
from shopdb.plugins.alembic_template import (
@@ -12,26 +24,209 @@ from shopdb.plugins.alembic_template import (
_get_plugin_metadata,
)
PLUGINS_DIR = Path(__file__).resolve().parent.parent / 'plugins'
BUNDLED_PLUGINS = ('computers', 'equipment', 'network', 'notifications', 'printers', 'usb')
# Plugins that own tables carry a migration chain; blueprint-only plugins do
# not. Today every bundled plugin owns tables, so this is the full set.
TABLE_OWNING_PLUGINS = tuple(sorted(PLUGIN_TABLE_OWNERS))
# The ADR-008 cutover froze this exact set of ten plugins whose tables the core
# chain had already created. Their 0001 revision is a stamp-only no-op anchor.
# Plugins built AFTER the cutover (e.g. measuringtools) are NOT in this list:
# their 0001 is a real baseline that genuinely creates their tables, so the
# no-op assertion must not apply to them. This is a frozen list on purpose - a
# newly discovered plugin does not silently get treated as a cutover no-op.
CUTOVER_PLUGINS = (
'computers', 'employees', 'equipment', 'knowledgebase', 'network',
'notifications', 'printers', 'slides', 'usb', 'warranty',
)
# Expected head revision id per table-owning plugin, so the upgrade-all test can
# check both the cutover anchors and post-cutover baselines. Cutover plugins
# stamp '<plugin>0001anchor'; measuringtools stamps its real baseline id.
EXPECTED_HEAD_REVISION = {plugin: f'{plugin}0001anchor' for plugin in CUTOVER_PLUGINS}
EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline'
# Plugins built after the cutover: their 0001 baseline really creates tables the
# core chain never owned.
POST_CUTOVER_PLUGINS = tuple(p for p in PLUGIN_TABLE_OWNERS if p not in CUTOVER_PLUGINS)
@pytest.mark.parametrize('plugin', BUNDLED_PLUGINS)
def _declared_tablenames(plugin: str) -> set:
"""Scan a plugin's models package for every __tablename__ literal.
Static parse (no import) so the test can compare what the code declares
against PLUGIN_TABLE_OWNERS without side effects.
"""
names = set()
models_dir = PLUGINS_DIR / plugin / 'models'
if not models_dir.exists():
return names
for source in models_dir.glob('*.py'):
tree = ast.parse(source.read_text())
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
targets = [t.id for t in node.targets if isinstance(t, ast.Name)]
if '__tablename__' in targets and isinstance(node.value, ast.Constant):
names.add(node.value.value)
return names
def test_table_owners_match_declared_models():
"""Every plugin that declares a __tablename__ is registered in
PLUGIN_TABLE_OWNERS, and vice versa. Catches a new plugin table that
forgot to update the ownership map."""
declared = {p.name for p in PLUGINS_DIR.iterdir()
if p.is_dir() and _declared_tablenames(p.name)}
assert declared == set(PLUGIN_TABLE_OWNERS), (
f"PLUGIN_TABLE_OWNERS keys {set(PLUGIN_TABLE_OWNERS)} do not match "
f"plugins declaring tables {declared}"
)
@pytest.mark.parametrize('plugin', TABLE_OWNING_PLUGINS)
def test_bundled_plugin_has_table_owner_entry(plugin):
"""Every bundled plugin appears in PLUGIN_TABLE_OWNERS with at least
"""Every table-owning plugin appears in PLUGIN_TABLE_OWNERS with at least
one table, documenting which tables it contributes to the schema."""
assert plugin in PLUGIN_TABLE_OWNERS
assert len(PLUGIN_TABLE_OWNERS[plugin]) > 0
@pytest.mark.parametrize('plugin', BUNDLED_PLUGINS)
@pytest.mark.parametrize('plugin', TABLE_OWNING_PLUGINS)
def test_owned_tables_match_declared_models(plugin):
"""The tables named in PLUGIN_TABLE_OWNERS are exactly the ones the
plugin's models declare. Catches drift in either direction."""
assert set(PLUGIN_TABLE_OWNERS[plugin]) == _declared_tablenames(plugin)
@pytest.mark.parametrize('plugin', TABLE_OWNING_PLUGINS)
def test_plugin_metadata_has_all_owned_tables(plugin, app):
"""The MetaData filtered to a plugin's owned tables actually contains
every table named in PLUGIN_TABLE_OWNERS. Catches drift between the
registry and what the models declare."""
every table named in PLUGIN_TABLE_OWNERS."""
with app.app_context():
md = _get_plugin_metadata(plugin)
owned = set(PLUGIN_TABLE_OWNERS[plugin])
present = set(md.tables.keys())
missing = owned - present
assert not missing, f"Plugin {plugin}: tables in PLUGIN_TABLE_OWNERS not in metadata: {missing}"
missing = owned - set(md.tables.keys())
assert not missing, f"Plugin {plugin}: owned tables not in metadata: {missing}"
@pytest.mark.parametrize('plugin', TABLE_OWNING_PLUGINS)
def test_plugin_has_migration_chain(plugin):
"""Every table-owning plugin has a migrations dir with env.py and exactly
one anchor revision whose down_revision is None (a valid single-root
chain)."""
mig = PLUGINS_DIR / plugin / 'migrations'
assert (mig / 'env.py').exists(), f"{plugin}: missing migrations/env.py"
versions = sorted((mig / 'versions').glob('*.py'))
assert versions, f"{plugin}: no version scripts"
roots = []
heads = set()
down_revisions = set()
revisions = set()
for script in versions:
tree = ast.parse(script.read_text())
rev = down = None
found_down = False
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
names = [t.id for t in node.targets if isinstance(t, ast.Name)]
if 'revision' in names and isinstance(node.value, ast.Constant):
rev = node.value.value
if 'down_revision' in names:
found_down = True
if isinstance(node.value, ast.Constant):
down = node.value.value
assert rev, f"{plugin}: {script.name} has no revision id"
assert found_down, f"{plugin}: {script.name} has no down_revision"
revisions.add(rev)
if down is None:
roots.append(rev)
else:
down_revisions.add(down)
heads = revisions - down_revisions
assert len(roots) == 1, f"{plugin}: expected 1 root revision, got {roots}"
assert len(heads) == 1, f"{plugin}: chain must have a single head, got {heads}"
@pytest.mark.parametrize('plugin', CUTOVER_PLUGINS)
def test_anchor_migration_is_noop(plugin):
"""The 0001 anchor's upgrade() and downgrade() are pure no-ops: no DDL
operations, just `pass`. The core chain owns the tables at cutover.
Scoped to CUTOVER_PLUGINS only. A plugin built after the cutover ships a
real baseline (measuringtools), which is deliberately NOT a no-op."""
anchor = PLUGINS_DIR / plugin / 'migrations' / 'versions' / f'0001_{plugin}_anchor.py'
assert anchor.exists(), f"{plugin}: missing 0001 anchor migration"
tree = ast.parse(anchor.read_text())
funcs = {n.name: n for n in ast.walk(tree)
if isinstance(n, ast.FunctionDef) and n.name in ('upgrade', 'downgrade')}
assert set(funcs) == {'upgrade', 'downgrade'}, f"{plugin}: anchor missing up/downgrade"
for name, fn in funcs.items():
# Body may only be a docstring/comment plus a bare `pass`. No calls.
calls = [n for n in ast.walk(fn) if isinstance(n, ast.Call)]
assert not calls, f"{plugin}: anchor {name}() is not a no-op (found calls)"
def test_upgrade_all_on_fresh_db_is_clean_and_idempotent(tmp_path, monkeypatch):
"""`flask plugin upgrade-all` on a fresh SQLite DB (after the core schema
is created) stamps every plugin anchor without error, and a second run is
a no-op. Mirrors the deploy sequence: `flask db upgrade` then
`flask plugin upgrade-all`."""
from sqlalchemy import inspect, text
from shopdb.config import TestingConfig
from shopdb import create_app
from shopdb.extensions import db
from shopdb.plugins import plugin_manager
db_file = tmp_path / 'fresh.db'
url = f'sqlite:///{db_file}'
# Point the whole app (db engine + migration manager) at one file DB so the
# anchor stamps land where the app can read them back.
monkeypatch.setattr(TestingConfig, 'SQLALCHEMY_DATABASE_URI', url)
# create_app repoints the process-wide plugin_manager singleton; snapshot
# its wiring and restore it so later tests see the session app unchanged.
saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry,
plugin_manager.loader, plugin_manager.migration_manager,
plugin_manager._registered_prefixes)
try:
app = create_app('testing')
with app.app_context():
db.create_all() # stand in for the core `flask db upgrade`
# db.create_all() over-creates: it builds EVERY table registered on
# the metadata, including post-cutover plugin tables the core chain
# would never own. Drop those so each post-cutover baseline creates
# its own tables exactly as it does after a real core upgrade (where
# the tables are simply absent). The cutover anchors are no-ops, so
# their create_all-built tables stay put.
insp0 = inspect(db.engine)
for plugin in POST_CUTOVER_PLUGINS:
# Drop by owned name (SQLite tolerates any order with no rows);
# avoids resolving cross-metadata FKs via sorted_tables.
for tablename in PLUGIN_TABLE_OWNERS[plugin]:
if insp0.has_table(tablename):
db.session.execute(text(f'DROP TABLE {tablename}'))
db.session.commit()
first = app.extensions['plugin_manager'].upgrade_all_plugins()
second = app.extensions['plugin_manager'].upgrade_all_plugins()
assert set(first) == set(PLUGIN_TABLE_OWNERS)
assert all(status == 'ok' for status in first.values()), first
assert all(status == 'ok' for status in second.values()), second
insp = inspect(db.engine)
for plugin in PLUGIN_TABLE_OWNERS:
version_table = f'alembic_version_{plugin}'
assert insp.has_table(version_table), f"missing {version_table}"
row = db.session.execute(
text(f'SELECT version_num FROM {version_table}')
).fetchone()
assert row and row[0] == EXPECTED_HEAD_REVISION[plugin]
finally:
(plugin_manager._app, plugin_manager._db, plugin_manager.registry,
plugin_manager.loader, plugin_manager.migration_manager,
plugin_manager._registered_prefixes) = saved