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

@@ -46,7 +46,7 @@ def list_asset_types():
@jwt_required(optional=True)
def get_asset_type(type_id: int):
"""Get a single asset type."""
t = AssetType.query.get(type_id)
t = db.session.get(AssetType, type_id)
if not t:
return error_response(
@@ -96,7 +96,7 @@ def create_asset_type():
def update_asset_type(type_id: int):
"""Update an asset type's display fields (color/icon/description). The name,
plugin, and table are structural and not editable here."""
t = AssetType.query.get(type_id)
t = db.session.get(AssetType, type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Asset type not found', http_code=404)
data = request.get_json() or {}
@@ -134,7 +134,7 @@ def list_asset_statuses():
@jwt_required(optional=True)
def get_asset_status(status_id: int):
"""Get a single asset status."""
s = AssetStatus.query.get(status_id)
s = db.session.get(AssetStatus, status_id)
if not s:
return error_response(
@@ -180,7 +180,7 @@ def create_asset_status():
@require_permission('assets.edit')
def update_asset_status(status_id: int):
"""Update an asset status."""
s = AssetStatus.query.get(status_id)
s = db.session.get(AssetStatus, status_id)
if not s:
return error_response(ErrorCodes.NOT_FOUND, 'Asset status not found',
http_code=404)
@@ -209,7 +209,7 @@ def update_asset_status(status_id: int):
@require_permission('assets.delete')
def delete_asset_status(status_id: int):
"""Delete an asset status. Refused if any asset still uses it."""
s = AssetStatus.query.get(status_id)
s = db.session.get(AssetStatus, status_id)
if not s:
return error_response(ErrorCodes.NOT_FOUND, 'Asset status not found',
http_code=404)
@@ -285,7 +285,7 @@ def _rel_type_dict(t):
@require_permission('assets.edit')
def update_relationship_type(type_id: int):
"""Update a relationship type."""
t = RelationshipType.query.get(type_id)
t = db.session.get(RelationshipType, type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Relationship type not found', http_code=404)
data = request.get_json() or {}
@@ -305,7 +305,7 @@ def update_relationship_type(type_id: int):
@require_permission('assets.delete')
def delete_relationship_type(type_id: int):
"""Delete a relationship type. Refused if any relationship still uses it."""
t = RelationshipType.query.get(type_id)
t = db.session.get(RelationshipType, type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Relationship type not found', http_code=404)
inuse = AssetRelationship.query.filter_by(relationshiptypeid=type_id).count()
@@ -412,7 +412,7 @@ def get_asset(asset_id: int):
Query parameters:
- include_type_data: Include category-specific extension data (default: true)
"""
asset = Asset.query.get(asset_id)
asset = db.session.get(Asset, asset_id)
if not asset:
return error_response(
@@ -450,7 +450,7 @@ def create_asset():
)
# Validate foreign keys exist
if not AssetType.query.get(data['assettypeid']):
if not db.session.get(AssetType, data['assettypeid']):
return error_response(
ErrorCodes.VALIDATION_ERROR,
f"Asset type with ID {data['assettypeid']} not found"
@@ -480,7 +480,7 @@ def create_asset():
@require_permission('assets.edit')
def update_asset(asset_id: int):
"""Update an asset."""
asset = Asset.query.get(asset_id)
asset = db.session.get(Asset, asset_id)
if not asset:
return error_response(
@@ -521,7 +521,7 @@ def update_asset(asset_id: int):
@require_permission('assets.delete')
def delete_asset(asset_id: int):
"""Delete (soft delete) an asset."""
asset = Asset.query.get(asset_id)
asset = db.session.get(Asset, asset_id)
if not asset:
return error_response(
@@ -568,7 +568,7 @@ def get_asset_relationships(asset_id: int):
Returns both outgoing (source) and incoming (target) relationships.
"""
asset = Asset.query.get(asset_id)
asset = db.session.get(Asset, asset_id)
if not asset:
return error_response(
@@ -628,11 +628,11 @@ def create_asset_relationship():
type_id = data['relationshiptypeid']
# Validate assets exist
if not Asset.query.get(source_id):
if not db.session.get(Asset, source_id):
return error_response(ErrorCodes.NOT_FOUND, f'Source asset {source_id} not found', http_code=404)
if not Asset.query.get(target_id):
if not db.session.get(Asset, target_id):
return error_response(ErrorCodes.NOT_FOUND, f'Target asset {target_id} not found', http_code=404)
if not RelationshipType.query.get(type_id):
if not db.session.get(RelationshipType, type_id):
return error_response(ErrorCodes.NOT_FOUND, f'Relationship type {type_id} not found', http_code=404)
# Check for duplicate relationship
@@ -667,7 +667,7 @@ def create_asset_relationship():
@require_permission('assets.delete')
def delete_asset_relationship(rel_id: int):
"""Delete an asset relationship."""
rel = AssetRelationship.query.get(rel_id)
rel = db.session.get(AssetRelationship, rel_id)
if not rel:
return error_response(
@@ -972,7 +972,7 @@ def get_asset_communications(asset_id: int):
"""Get all communications for an asset."""
from shopdb.core.models import Communication
asset = Asset.query.get(asset_id)
asset = db.session.get(Asset, asset_id)
if not asset:
return error_response(