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

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

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

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

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

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

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

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

View File

@@ -5,7 +5,7 @@ never stored. Warranties link to assets many-to-many via warrantyassets, though
the common case is one warranty per asset.
"""
from datetime import date, datetime
from datetime import date, datetime, timezone
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
@@ -46,7 +46,7 @@ def _warranty_payload(warranty, today=None):
data = warranty.to_dict(today)
assets = []
for link in warranty.links:
asset = Asset.query.get(link.assetid)
asset = db.session.get(Asset, link.assetid)
if asset:
assets.append(_asset_summary(asset))
data['assets'] = assets
@@ -60,7 +60,7 @@ def _apply_links(warranty, assetids):
wanted = {int(a) for a in assetids if str(a).strip()}
existing = {link.assetid: link for link in warranty.links}
for assetid in wanted - set(existing):
if Asset.query.get(assetid):
if db.session.get(Asset, assetid):
warranty.links.append(WarrantyAsset(assetid=assetid))
for assetid in set(existing) - wanted:
warranty.links.remove(existing[assetid])
@@ -100,7 +100,7 @@ def warranties_for_asset(assetid):
today = date.today()
items = []
for link in links:
w = Warranty.query.get(link.warrantyid)
w = db.session.get(Warranty, link.warrantyid)
if w and w.isactive:
items.append(_warranty_payload(w, today))
return success_response(items)
@@ -109,7 +109,7 @@ def warranties_for_asset(assetid):
@warranty_bp.route('/<int:warrantyid>', methods=['GET'])
@jwt_required(optional=True)
def get_warranty(warrantyid):
warranty = Warranty.query.get(warrantyid)
warranty = db.session.get(Warranty, warrantyid)
if not warranty:
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404)
return success_response(_warranty_payload(warranty))
@@ -142,7 +142,7 @@ def create_warranty():
@jwt_required()
@require_permission('warranty.edit')
def update_warranty(warrantyid):
warranty = Warranty.query.get(warrantyid)
warranty = db.session.get(Warranty, warrantyid)
if not warranty:
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404)
data = request.get_json() or {}
@@ -172,7 +172,7 @@ def update_warranty(warrantyid):
@jwt_required()
@require_permission('warranty.delete')
def delete_warranty(warrantyid):
warranty = Warranty.query.get(warrantyid)
warranty = db.session.get(Warranty, warrantyid)
if not warranty:
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404)
db.session.delete(warranty)
@@ -188,7 +188,7 @@ def delete_warranty(warrantyid):
@jwt_required()
@require_permission('warranty.edit')
def refresh_warranty(warrantyid):
warranty = Warranty.query.get(warrantyid)
warranty = db.session.get(Warranty, warrantyid)
if not warranty:
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404)
provider = get_provider(warranty.provider)
@@ -205,7 +205,7 @@ def refresh_warranty(warrantyid):
warranty.startdate = _parse_date(result['startdate'])
if result.get('enddate'):
warranty.enddate = _parse_date(result['enddate'])
warranty.lastcheckeddate = datetime.utcnow()
warranty.lastcheckeddate = datetime.now(timezone.utc).replace(tzinfo=None)
db.session.commit()
return success_response(_warranty_payload(warranty), message='Warranty refreshed')
@@ -232,7 +232,7 @@ def sync_dell():
covered = set()
if not recheck_all:
for link in WarrantyAsset.query.all():
w = Warranty.query.get(link.warrantyid)
w = db.session.get(Warranty, link.warrantyid)
if w and w.isactive and w.enddate:
covered.add(link.assetid)
@@ -259,7 +259,7 @@ def sync_dell():
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
created = updated = matched = 0
now = datetime.utcnow()
now = datetime.now(timezone.utc).replace(tzinfo=None)
for tag, assetids in by_tag.items():
found = results.get(tag)
if not found:
@@ -269,7 +269,7 @@ def sync_dell():
# Reuse an existing Dell warranty for this asset if there is one.
existing = None
for link in WarrantyAsset.query.filter_by(assetid=assetid).all():
candidate = Warranty.query.get(link.warrantyid)
candidate = db.session.get(Warranty, link.warrantyid)
if candidate and candidate.provider == 'dell':
existing = candidate
break

View File

@@ -17,7 +17,7 @@ import requests
from flask import current_app
from shopdb.api import db
from shopdb.core.models import Setting
from shopdb.api import Setting
# Two-level Dell token cache. Dell rate-limits the token endpoint, so a fresh
# request per refresh (or per app restart) trips a 401 cooldown. Tokens live ~1h.