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

@@ -4,7 +4,7 @@ import re
import ipaddress
import logging
from datetime import datetime
from datetime import datetime, timezone
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
from sqlalchemy.orm import joinedload
@@ -14,6 +14,7 @@ from shopdb.core.models import (
Application, Setting,
Asset, AssetType, Communication, Vendor, Model
)
from shopdb.core.api.settings import get_cached_settings
from shopdb.utils.responses import success_response
logger = logging.getLogger(__name__)
@@ -32,22 +33,74 @@ def _require_enabled(name):
if pm and not pm.registry.is_enabled(name):
raise ImportError(f'{name} plugin disabled')
# ServiceNOW URL template
SERVICENOW_URL = (
'https://geit.service-now.com/now/nav/ui/search/'
# Shipped GE defaults. Settings override these per-site; identical fallbacks
# live here so this consumer works even if the settings seed has not run.
SERVICENOW_URL_DEFAULT = (
'https://geaerospaceqa.service-now.com/now/nav/ui/search/'
'0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/'
'global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
'back-button-label/IT4IT%20Homepage/search-context/now%2Fnav%2Fui'
)
SERVICENOW_PREFIXES_DEFAULT = 'GEINC,GECHG,GERIT,GESCT'
EMPLOYEEID_PATTERN_DEFAULT = r'^\d{9}$'
def _classify_query(query):
def _get_search_integrations():
"""Resolve the settings-driven search integration config.
Reads employeeid_pattern, servicenow_ticket_prefixes, servicenow_enabled
and servicenow_search_url from cached settings, falling back to the shipped
GE defaults for any missing key. An invalid employeeid_pattern regex falls
back to the default rather than raising (search must never 500 on bad
config). ServiceNow is inactive when disabled, when the URL is blank, or
when no ticket prefixes are configured.
"""
settings = get_cached_settings()
# Employee-ID pattern. Bad regex falls back so search never 500s.
pattern = settings.get('employeeid_pattern') or EMPLOYEEID_PATTERN_DEFAULT
try:
employeeid_re = re.compile(pattern)
except re.error:
employeeid_re = re.compile(EMPLOYEEID_PATTERN_DEFAULT)
# Ticket prefixes -> case-insensitive alternation built at request time.
prefixes_raw = settings.get('servicenow_ticket_prefixes')
if prefixes_raw is None:
prefixes_raw = SERVICENOW_PREFIXES_DEFAULT
prefixes = [p.strip() for p in str(prefixes_raw).split(',') if p.strip()]
servicenow_enabled = settings.get('servicenow_enabled')
if servicenow_enabled is None:
servicenow_enabled = True
servicenow_url = settings.get('servicenow_search_url')
if servicenow_url is None:
servicenow_url = SERVICENOW_URL_DEFAULT
servicenow_active = bool(servicenow_enabled) and bool(servicenow_url) and bool(prefixes)
prefix_re = None
if servicenow_active:
alternation = '|'.join(re.escape(p) for p in prefixes)
prefix_re = re.compile(r'^(' + alternation + r')\d+', re.IGNORECASE)
return {
'employeeid_re': employeeid_re,
'prefix_re': prefix_re,
'servicenow_active': servicenow_active,
'servicenow_url': servicenow_url,
}
def _classify_query(query, integrations):
"""Analyze the query string to determine its nature."""
prefix_re = integrations['prefix_re']
sn_match = prefix_re.match(query) if prefix_re else None
return {
'is_ip': bool(re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', query)),
'is_sso': bool(re.match(r'^\d{9}$', query)),
'is_servicenow': bool(re.match(r'^(GEINC|GECHG|GERIT|GESCT)\d+', query, re.IGNORECASE)),
'servicenow_prefix': re.match(r'^(GEINC|GECHG|GERIT|GESCT)', query, re.IGNORECASE).group(1) if re.match(r'^(GEINC|GECHG|GERIT|GESCT)', query, re.IGNORECASE) else None,
'is_sso': bool(integrations['employeeid_re'].match(query)),
'is_servicenow': bool(sn_match),
'servicenow_prefix': sn_match.group(1) if sn_match else None,
'is_fqdn': bool(re.match(r'^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$', query)),
}
@@ -393,7 +446,7 @@ def _search_notifications(query, search_term):
)
).order_by(Notification.starttime.desc()).limit(15).all()
now = datetime.utcnow()
now = datetime.now(timezone.utc).replace(tzinfo=None)
for notif in notifications:
base_relevance = 20
if notif.ticketnumber and query.lower() == notif.ticketnumber.lower():
@@ -671,12 +724,13 @@ def global_search():
'message': 'Search query too long'
})
classification = _classify_query(query)
integrations = _get_search_integrations()
classification = _classify_query(query, integrations)
# ServiceNOW prefix detection - return redirect immediately
if classification['is_servicenow']:
from urllib.parse import quote
servicenow_url = SERVICENOW_URL.format(ticket=quote(query))
servicenow_url = integrations['servicenow_url'].format(ticket=quote(query))
return success_response({
'results': [],
'query': query,