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>
176 lines
3.8 KiB
Python
176 lines
3.8 KiB
Python
"""Standardized API response helpers."""
|
|
|
|
from flask import jsonify, make_response
|
|
from typing import Any, Dict, List, Optional
|
|
from datetime import datetime, timezone
|
|
import uuid
|
|
|
|
|
|
class ErrorCodes:
|
|
"""Standard error codes."""
|
|
|
|
VALIDATION_ERROR = 'VALIDATION_ERROR'
|
|
NOT_FOUND = 'NOT_FOUND'
|
|
UNAUTHORIZED = 'UNAUTHORIZED'
|
|
FORBIDDEN = 'FORBIDDEN'
|
|
CONFLICT = 'CONFLICT'
|
|
INTERNAL_ERROR = 'INTERNAL_ERROR'
|
|
BAD_REQUEST = 'BAD_REQUEST'
|
|
PLUGIN_ERROR = 'PLUGIN_ERROR'
|
|
|
|
|
|
def api_response(
|
|
data: Any = None,
|
|
message: str = None,
|
|
status: str = 'success',
|
|
meta: Dict = None,
|
|
http_code: int = 200
|
|
):
|
|
"""
|
|
Create standardized API response.
|
|
|
|
Response format:
|
|
{
|
|
"status": "success" | "error",
|
|
"data": {...} | [...],
|
|
"message": "Optional message",
|
|
"meta": {
|
|
"timestamp": "2025-01-12T...",
|
|
"request_id": "uuid"
|
|
}
|
|
}
|
|
"""
|
|
response = {
|
|
'status': status,
|
|
'meta': {
|
|
'timestamp': datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z',
|
|
'requestid': str(uuid.uuid4())[:8],
|
|
**(meta or {})
|
|
}
|
|
}
|
|
|
|
if data is not None:
|
|
response['data'] = data
|
|
|
|
if message:
|
|
response['message'] = message
|
|
|
|
return make_response(jsonify(response), http_code)
|
|
|
|
|
|
def success_response(
|
|
data: Any = None,
|
|
message: str = None,
|
|
meta: Dict = None,
|
|
http_code: int = 200
|
|
):
|
|
"""Success response helper."""
|
|
return api_response(
|
|
data=data,
|
|
message=message,
|
|
meta=meta,
|
|
status='success',
|
|
http_code=http_code
|
|
)
|
|
|
|
|
|
def error_response(
|
|
code: str,
|
|
message: str,
|
|
details: Dict = None,
|
|
http_code: int = 400
|
|
):
|
|
"""Error response helper.
|
|
|
|
Response format:
|
|
{
|
|
"status": "error",
|
|
"data": {
|
|
"error": {
|
|
"code": "VALIDATION_ERROR",
|
|
"message": "Human-readable message",
|
|
"details": {...}
|
|
}
|
|
},
|
|
"meta": {
|
|
"timestamp": "...",
|
|
"requestid": "..."
|
|
}
|
|
}
|
|
"""
|
|
error_data = {
|
|
'code': code,
|
|
'message': message
|
|
}
|
|
if details:
|
|
error_data['details'] = details
|
|
|
|
return api_response(
|
|
data={'error': error_data},
|
|
status='error',
|
|
http_code=http_code
|
|
)
|
|
|
|
|
|
def api_error(
|
|
message: str,
|
|
code: str = ErrorCodes.BAD_REQUEST,
|
|
details: Dict = None,
|
|
http_code: int = 400
|
|
):
|
|
"""
|
|
Simplified error response helper.
|
|
|
|
Args:
|
|
message: Human-readable error message
|
|
code: Error code (default BAD_REQUEST)
|
|
details: Optional error details
|
|
http_code: HTTP status code (default 400)
|
|
"""
|
|
return error_response(code=code, message=message, details=details, http_code=http_code)
|
|
|
|
|
|
def paginated_response(
|
|
items: List,
|
|
page: int,
|
|
per_page: int,
|
|
total: int,
|
|
schema=None
|
|
):
|
|
"""Paginated list response.
|
|
|
|
Response format:
|
|
{
|
|
"status": "success",
|
|
"data": [...],
|
|
"meta": {
|
|
"pagination": {
|
|
"page": 1,
|
|
"perpage": 20,
|
|
"total": 150,
|
|
"totalpages": 8,
|
|
"hasnext": true,
|
|
"hasprev": false
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
total_pages = (total + per_page - 1) // per_page if per_page > 0 else 0
|
|
|
|
if schema:
|
|
items = schema.dump(items, many=True)
|
|
|
|
return api_response(
|
|
data=items,
|
|
meta={
|
|
'pagination': {
|
|
'page': page,
|
|
'perpage': per_page,
|
|
'total': total,
|
|
'totalpages': total_pages,
|
|
'hasnext': page < total_pages,
|
|
'hasprev': page > 1
|
|
}
|
|
}
|
|
)
|