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

@@ -20,6 +20,12 @@ from .plugins import plugin_manager
# a real consumer (/api/dashboard/widgets). Pre-1.0 contract reduction.
__contract_version__ = '0.5.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent
# bump rules; they happen to coincide at 0.5.0. Not part of the
# shopdb.api contract surface, so it is not re-exported there.
__version__ = '0.5.0'
def create_app(config_name: str = None) -> Flask:
"""

View File

@@ -85,6 +85,14 @@ class Config:
CACHE_TYPE = 'SimpleCache'
CACHE_DEFAULT_TIMEOUT = 600
# IP-based login rate limit (fixed window). Defense in depth atop the
# per-account lockout. Backed by the existing cache extension.
AUTH_RATELIMIT_ENABLED = os.environ.get(
'AUTH_RATELIMIT_ENABLED', 'true').lower() == 'true'
AUTH_RATELIMIT_MAX = int(os.environ.get('AUTH_RATELIMIT_MAX', 30))
AUTH_RATELIMIT_WINDOW_SECONDS = int(
os.environ.get('AUTH_RATELIMIT_WINDOW_SECONDS', 300))
DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100
@@ -105,6 +113,9 @@ class TestingConfig(Config):
"""Testing configuration."""
TESTING = True
# Off by default so login-heavy fixtures do not trip the limiter; tests
# that exercise rate limiting flip it on via app.config override.
AUTH_RATELIMIT_ENABLED = False
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
SQLALCHEMY_ENGINE_OPTIONS = {
'connect_args': {'check_same_thread': False},

View File

@@ -115,7 +115,7 @@ def list_applications():
@jwt_required(optional=True)
def get_application(app_id: int):
"""Get a single application with details."""
app = Application.query.get(app_id)
app = db.session.get(Application, app_id)
if not app:
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
@@ -188,7 +188,7 @@ def create_application():
@require_permission('applications.edit')
def update_application(app_id: int):
"""Update an application."""
app = Application.query.get(app_id)
app = db.session.get(Application, app_id)
if not app:
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
@@ -233,7 +233,7 @@ def update_application(app_id: int):
@require_permission('applications.delete')
def delete_application(app_id: int):
"""Delete (deactivate) an application."""
app = Application.query.get(app_id)
app = db.session.get(Application, app_id)
if not app:
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
@@ -253,7 +253,7 @@ def delete_application(app_id: int):
@jwt_required(optional=True)
def list_versions(app_id: int):
"""List all versions for an application."""
app = Application.query.get(app_id)
app = db.session.get(Application, app_id)
if not app:
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
@@ -266,7 +266,7 @@ def list_versions(app_id: int):
@require_permission('applications.create')
def create_version(app_id: int):
"""Create a new version for an application."""
app = Application.query.get(app_id)
app = db.session.get(Application, app_id)
if not app:
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
@@ -300,7 +300,7 @@ def create_version(app_id: int):
@jwt_required(optional=True)
def list_installed_machines(app_id: int):
"""List all computers that have this application installed."""
app = Application.query.get(app_id)
app = db.session.get(Application, app_id)
if not app:
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
@@ -344,7 +344,7 @@ def list_machine_applications(machine_id: int):
return err
Computer, ComputerInstalledApp = models
comp = Computer.query.get(machine_id)
comp = db.session.get(Computer, machine_id)
if not comp:
return error_response(ErrorCodes.NOT_FOUND, 'Computer not found', http_code=404)
@@ -362,7 +362,7 @@ def install_application(machine_id: int):
return err
Computer, ComputerInstalledApp = models
comp = Computer.query.get(machine_id)
comp = db.session.get(Computer, machine_id)
if not comp:
return error_response(ErrorCodes.NOT_FOUND, 'Computer not found', http_code=404)
@@ -370,7 +370,7 @@ def install_application(machine_id: int):
if not data or not data.get('appid'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'appid is required')
app = Application.query.get(data['appid'])
app = db.session.get(Application, data['appid'])
if not app:
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)

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(

View File

@@ -103,7 +103,7 @@ def get_entity_history(entitytype: str, entityid: int):
def get_stats():
"""Get audit log statistics."""
from sqlalchemy import func
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
# Actions by type
actions = db_func_count_by(AuditLog.action)
@@ -112,7 +112,7 @@ def get_stats():
entities = db_func_count_by(AuditLog.entitytype)
# Recent activity (last 7 days)
week_ago = datetime.utcnow() - timedelta(days=7)
week_ago = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=7)
recent_count = AuditLog.query.filter(AuditLog.timestamp >= week_ago).count()
# Most active users (last 7 days)

View File

@@ -1,8 +1,9 @@
"""Authentication API endpoints."""
from datetime import datetime, timedelta
import time
from datetime import datetime, timedelta, timezone
from flask import Blueprint, request
from flask import Blueprint, request, current_app
from flask_jwt_extended import (
create_access_token,
create_refresh_token,
@@ -12,7 +13,7 @@ from flask_jwt_extended import (
)
from werkzeug.security import check_password_hash
from shopdb.extensions import db
from shopdb.extensions import db, cache
from shopdb.core.models import User
from shopdb.utils.responses import success_response, error_response, ErrorCodes
@@ -24,6 +25,39 @@ MAX_FAILED_LOGINS = 5
LOCKOUT_MINUTES = 15
def _login_ip():
"""Caller IP for rate limiting, honoring the first X-Forwarded-For hop."""
forwarded = request.headers.get('X-Forwarded-For')
if forwarded:
return forwarded.split(',')[0].strip()
return request.remote_addr or 'unknown'
def _login_ratelimited():
"""Fixed-window per-IP login limiter. Returns True when the caller is over
budget for the current window.
Backed by the existing cache extension (no new dependency). Under the
default SimpleCache the counter is per-process, so with N gunicorn workers
the effective budget is N x AUTH_RATELIMIT_MAX. This is defense in depth
layered on top of the per-account lockout (see login()); a shared cache
backend (Redis/memcached) tightens it to a true global budget.
"""
if not current_app.config.get('AUTH_RATELIMIT_ENABLED', True):
return False
window = current_app.config.get('AUTH_RATELIMIT_WINDOW_SECONDS', 300)
maxhits = current_app.config.get('AUTH_RATELIMIT_MAX', 30)
# Time bucket makes this a fixed window: the key rolls over at each window
# boundary, so a per-hit set() cannot turn it into a sliding window.
bucket = int(time.time() // window) if window > 0 else 0
key = f'loginratelimit:{_login_ip()}:{bucket}'
count = cache.get(key) or 0
if count >= maxhits:
return True
cache.set(key, count + 1, timeout=window)
return False
@auth_bp.route('/login', methods=['POST'])
def login():
"""
@@ -44,6 +78,13 @@ def login():
}
}
"""
if _login_ratelimited():
return error_response(
'RATE_LIMITED',
'Too many login attempts. Try again later.',
http_code=429
)
data = request.get_json()
if not data or not data.get('username') or not data.get('password'):
@@ -72,7 +113,9 @@ def login():
if user:
user.failedlogins = (user.failedlogins or 0) + 1
if user.failedlogins >= MAX_FAILED_LOGINS:
user.lockeduntil = datetime.utcnow() + timedelta(minutes=LOCKOUT_MINUTES)
# Naive UTC to match the naive lockeduntil column comparisons.
user.lockeduntil = datetime.now(timezone.utc).replace(tzinfo=None) \
+ timedelta(minutes=LOCKOUT_MINUTES)
user.failedlogins = 0
db.session.commit()
return error_response(

View File

@@ -49,7 +49,7 @@ def list_businessunits():
@jwt_required(optional=True)
def get_businessunit(bu_id: int):
"""Get a single business unit."""
bu = BusinessUnit.query.get(bu_id)
bu = db.session.get(BusinessUnit, bu_id)
if not bu:
return error_response(
@@ -100,7 +100,7 @@ def create_businessunit():
@require_role('admin')
def update_businessunit(bu_id: int):
"""Update a business unit."""
bu = BusinessUnit.query.get(bu_id)
bu = db.session.get(BusinessUnit, bu_id)
if not bu:
return error_response(
@@ -134,7 +134,7 @@ def update_businessunit(bu_id: int):
@require_role('admin')
def delete_businessunit(bu_id: int):
"""Delete (deactivate) a business unit."""
bu = BusinessUnit.query.get(bu_id)
bu = db.session.get(BusinessUnit, bu_id)
if not bu:
return error_response(

View File

@@ -6,7 +6,7 @@ API key (not JWT) for unattended scripts. Writes the asset/computer model
(ADR-001), not the retired Machine model.
"""
from datetime import datetime
from datetime import datetime, timezone
from functools import wraps
from flask import Blueprint, request, current_app
@@ -37,7 +37,9 @@ def require_api_key(f):
"""Require API key authentication."""
@wraps(f)
def decorated(*args, **kwargs):
api_key = request.headers.get('X-API-Key') or request.args.get('api_key')
# Header only. Querystring api_key was dropped so keys do not land in
# access logs / proxy history (breaking change, see COLLECTOR-INTEGRATION.md).
api_key = request.headers.get('X-API-Key')
expected_key = current_app.config.get('COLLECTOR_API_KEY')
if not expected_key:
@@ -138,7 +140,8 @@ def generic_collect(pluginname):
if not expected_key:
return error_response(ErrorCodes.INTERNAL_ERROR,
'Collector API key not configured', http_code=500)
api_key = request.headers.get('X-API-Key') or request.args.get('api_key')
# Header only (querystring fallback dropped, see require_api_key).
api_key = request.headers.get('X-API-Key')
if api_key != expected_key:
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
http_code=401)
@@ -161,12 +164,17 @@ def generic_collect(pluginname):
f'Plugin {pluginname} does not implement apply_collector_payload',
http_code=500)
except ValueError as exc:
# ValueError is the plugin's controlled validation signal; its message
# is safe to return to the caller.
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
except Exception as exc:
except Exception:
# Do not leak internals (stack detail, DB errors) to the caller; log it.
db.session.rollback()
current_app.logger.exception('Collector upsert failed for %s', pluginname)
return error_response(ErrorCodes.INTERNAL_ERROR, str(exc), http_code=500)
return error_response(ErrorCodes.INTERNAL_ERROR,
'Internal error processing collector payload',
http_code=500)
action = outcome.get('action', 'noop')
AuditLog.log(
@@ -217,7 +225,7 @@ def update_pc_info():
f'PC with hostname {hostname} not found',
http_code=404)
comp.lastreporteddate = datetime.utcnow()
comp.lastreporteddate = datetime.now(timezone.utc).replace(tzinfo=None)
if data.get('lastboottime'):
boot = _parse_boot(data['lastboottime'])
@@ -326,7 +334,7 @@ def pc_heartbeat():
updated = 0
not_found = []
now = datetime.utcnow()
now = datetime.now(timezone.utc).replace(tzinfo=None)
for hostname in hostnames:
comp = _find_pc(hostname)
if comp:
@@ -359,7 +367,7 @@ def bulk_update():
updated = 0
not_found = []
errors = []
now = datetime.utcnow()
now = datetime.now(timezone.utc).replace(tzinfo=None)
for pc_data in pcs:
hostname = pc_data.get('hostname')
@@ -380,8 +388,11 @@ def bulk_update():
if boot:
comp.lastboottime = boot
updated += 1
except Exception as exc:
errors.append({'hostname': hostname, 'error': str(exc)})
except Exception:
# Keep the hostname so the caller knows which PC failed, but do not
# leak the exception detail; log it server-side.
current_app.logger.exception('Bulk update failed for %s', hostname)
errors.append({'hostname': hostname, 'error': 'processing failed'})
db.session.commit()
@@ -399,7 +410,7 @@ def collector_status():
"""Check collector API status."""
return success_response({
'status': 'ok',
'timestamp': datetime.utcnow().isoformat(),
'timestamp': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
'endpoints': [
'POST /api/collector/<plugin>',
'GET /api/collector/_schemas',

View File

@@ -68,7 +68,7 @@ def create_field():
label = (data.get('label') or '').strip()
if not assettypeid or not label:
return error_response(ErrorCodes.VALIDATION_ERROR, 'assettypeid and label are required')
if not AssetType.query.get(assettypeid):
if not db.session.get(AssetType, assettypeid):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown assettypeid')
datatype = data.get('datatype') or 'text'
@@ -112,7 +112,7 @@ def create_field():
@jwt_required()
@require_role('admin')
def update_field(fieldid):
field = CustomField.query.get(fieldid)
field = db.session.get(CustomField, fieldid)
if not field:
return error_response(ErrorCodes.NOT_FOUND, 'Custom field not found', http_code=404)
data = request.get_json() or {}
@@ -137,7 +137,7 @@ def update_field(fieldid):
@jwt_required()
@require_role('admin')
def delete_field(fieldid):
field = CustomField.query.get(fieldid)
field = db.session.get(CustomField, fieldid)
if not field:
return error_response(ErrorCodes.NOT_FOUND, 'Custom field not found', http_code=404)
# Drop the field and any stored values for it.
@@ -172,7 +172,7 @@ def _fields_with_values(asset):
@jwt_required(optional=True)
def get_asset_fields(assetid):
"""Active custom fields for an asset's type, merged with its values."""
asset = Asset.query.get(assetid)
asset = db.session.get(Asset, assetid)
if not asset:
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
return success_response(_fields_with_values(asset))
@@ -183,7 +183,7 @@ def get_asset_fields(assetid):
@require_role('admin')
def save_asset_fields(assetid):
"""Upsert values for an asset. Body: {values: {fieldid: value, ...}}."""
asset = Asset.query.get(assetid)
asset = db.session.get(Asset, assetid)
if not asset:
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
data = request.get_json() or {}

View File

@@ -185,6 +185,8 @@ def get_widgets():
@dashboard_bp.route('/health', methods=['GET'])
def health_check():
"""Health check endpoint (no auth required)."""
from shopdb import __version__
try:
db.session.execute(db.text('SELECT 1'))
db_status = 'healthy'
@@ -194,5 +196,5 @@ def health_check():
return success_response({
'status': 'ok' if db_status == 'healthy' else 'degraded',
'database': db_status,
'version': '1.0.0'
'version': __version__
})

View File

@@ -10,6 +10,7 @@ from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import DashboardDefault, BusinessUnit, AuditLog
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
dashboarddefaults_bp = Blueprint('dashboarddefaults', __name__)
@@ -64,6 +65,7 @@ def list_defaults():
@dashboarddefaults_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_default():
"""Create a visitor-IP -> business-unit mapping."""
data = request.get_json() or {}
@@ -74,7 +76,7 @@ def create_default():
return error_response(ErrorCodes.VALIDATION_ERROR, 'ipaddress is required')
if not businessunitid:
return error_response(ErrorCodes.VALIDATION_ERROR, 'businessunitid is required')
if not BusinessUnit.query.get(businessunitid):
if not db.session.get(BusinessUnit, businessunitid):
return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found',
http_code=404)
if DashboardDefault.query.filter_by(ipaddress=ipaddress, isactive=True).first():
@@ -94,15 +96,16 @@ def create_default():
@dashboarddefaults_bp.route('/<int:default_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_default(default_id):
"""Update a mapping."""
default = DashboardDefault.query.get(default_id)
default = db.session.get(DashboardDefault, default_id)
if not default:
return error_response(ErrorCodes.NOT_FOUND, 'Mapping not found', http_code=404)
data = request.get_json() or {}
if 'businessunitid' in data:
if not BusinessUnit.query.get(data['businessunitid']):
if not db.session.get(BusinessUnit, data['businessunitid']):
return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found',
http_code=404)
default.businessunitid = data['businessunitid']
@@ -117,9 +120,10 @@ def update_default(default_id):
@dashboarddefaults_bp.route('/<int:default_id>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_default(default_id):
"""Delete (deactivate) a mapping."""
default = DashboardDefault.query.get(default_id)
default = db.session.get(DashboardDefault, default_id)
if not default:
return error_response(ErrorCodes.NOT_FOUND, 'Mapping not found', http_code=404)
default.isactive = False

View File

@@ -68,7 +68,7 @@ def create_location_type():
@jwt_required()
@require_role('admin')
def update_location_type(type_id):
t = LocationType.query.get(type_id)
t = db.session.get(LocationType, type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Location type not found', http_code=404)
data = request.get_json() or {}
@@ -87,7 +87,7 @@ def update_location_type(type_id):
@jwt_required()
@require_role('admin')
def delete_location_type(type_id):
t = LocationType.query.get(type_id)
t = db.session.get(LocationType, type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Location type not found', http_code=404)
inuse = Location.query.filter_by(locationtypeid=type_id).count()
@@ -130,7 +130,7 @@ def list_locations():
@jwt_required(optional=True)
def get_location(location_id: int):
"""Get a single location."""
loc = Location.query.get(location_id)
loc = db.session.get(Location, location_id)
if not loc:
return error_response(
@@ -183,7 +183,7 @@ def create_location():
@require_role('admin')
def update_location(location_id: int):
"""Update a location."""
loc = Location.query.get(location_id)
loc = db.session.get(Location, location_id)
if not loc:
return error_response(
@@ -219,7 +219,7 @@ def update_location(location_id: int):
@require_role('admin')
def delete_location(location_id: int):
"""Delete (deactivate) a location."""
loc = Location.query.get(location_id)
loc = db.session.get(Location, location_id)
if not loc:
return error_response(

View File

@@ -47,7 +47,7 @@ def list_machinetypes():
@jwt_required(optional=True)
def get_machinetype(type_id: int):
"""Get a single machine type."""
mt = MachineType.query.get(type_id)
mt = db.session.get(MachineType, type_id)
if not mt:
return error_response(
@@ -94,7 +94,7 @@ def create_machinetype():
@require_role('admin')
def update_machinetype(type_id: int):
"""Update a machine type."""
mt = MachineType.query.get(type_id)
mt = db.session.get(MachineType, type_id)
if not mt:
return error_response(
@@ -129,7 +129,7 @@ def update_machinetype(type_id: int):
@require_role('admin')
def delete_machinetype(type_id: int):
"""Delete (deactivate) a machine type."""
mt = MachineType.query.get(type_id)
mt = db.session.get(MachineType, type_id)
if not mt:
return error_response(

View File

@@ -56,7 +56,7 @@ def list_models():
@jwt_required(optional=True)
def get_model(model_id: int):
"""Get a single model."""
m = Model.query.get(model_id)
m = db.session.get(Model, model_id)
if not m:
return error_response(
@@ -115,7 +115,7 @@ def create_model():
@require_role('admin')
def update_model(model_id: int):
"""Update a model."""
m = Model.query.get(model_id)
m = db.session.get(Model, model_id)
if not m:
return error_response(
@@ -141,7 +141,7 @@ def update_model(model_id: int):
@require_role('admin')
def delete_model(model_id: int):
"""Delete (deactivate) a model."""
m = Model.query.get(model_id)
m = db.session.get(Model, model_id)
if not m:
return error_response(

View File

@@ -44,7 +44,7 @@ def list_operatingsystems():
@jwt_required(optional=True)
def get_operatingsystem(os_id: int):
"""Get a single operating system."""
os = OperatingSystem.query.get(os_id)
os = db.session.get(OperatingSystem, os_id)
if not os:
return error_response(
@@ -95,7 +95,7 @@ def create_operatingsystem():
@require_role('admin')
def update_operatingsystem(os_id: int):
"""Update an operating system."""
os = OperatingSystem.query.get(os_id)
os = db.session.get(OperatingSystem, os_id)
if not os:
return error_response(
@@ -121,7 +121,7 @@ def update_operatingsystem(os_id: int):
@require_role('admin')
def delete_operatingsystem(os_id: int):
"""Delete (deactivate) an operating system."""
os = OperatingSystem.query.get(os_id)
os = db.session.get(OperatingSystem, os_id)
if not os:
return error_response(

View File

@@ -2,7 +2,7 @@
import csv
import io
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from flask import Blueprint, request, Response
from flask_jwt_extended import jwt_required
@@ -84,7 +84,7 @@ def equipment_by_type():
return success_response({
'report': 'equipment_by_type',
'generated': datetime.utcnow().isoformat(),
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
'data': data,
'total': total
})
@@ -143,7 +143,7 @@ def assets_by_status():
return success_response({
'report': 'assets_by_status',
'generated': datetime.utcnow().isoformat(),
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
'data': data,
'total': total
})
@@ -200,7 +200,7 @@ def kb_popularity():
return success_response({
'report': 'kb_popularity',
'generated': datetime.utcnow().isoformat(),
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
'data': data,
'total': len(data)
})
@@ -222,7 +222,7 @@ def warranty_status():
- assettypeid: Filter by asset type
- format: 'json' (default) or 'csv'
"""
now = datetime.utcnow()
now = datetime.now(timezone.utc).replace(tzinfo=None)
expiring_threshold = now + timedelta(days=90)
# Try to get warranty data from equipment or machines
@@ -298,7 +298,7 @@ def warranty_status():
return success_response({
'report': 'warranty_status',
'generated': datetime.utcnow().isoformat(),
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
'data': data,
'summary': {
'expired': data['expired']['count'],
@@ -344,7 +344,7 @@ def software_compliance():
if not required_apps:
return success_response({
'report': 'software_compliance',
'generated': datetime.utcnow().isoformat(),
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
'data': [],
'message': 'No required applications defined'
})
@@ -422,7 +422,7 @@ def software_compliance():
return success_response({
'report': 'software_compliance',
'generated': datetime.utcnow().isoformat(),
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
'data': data,
'total': len(data)
})
@@ -507,7 +507,7 @@ def asset_inventory():
csv_output = io.StringIO()
csv_output.write("Asset Inventory Report\n")
csv_output.write(f"Generated: {datetime.utcnow().isoformat()}\n\n")
csv_output.write(f"Generated: {datetime.now(timezone.utc).replace(tzinfo=None).isoformat()}\n\n")
csv_output.write("By Type\n")
csv_output.write("Type,Count\n")
@@ -532,7 +532,7 @@ def asset_inventory():
return success_response({
'report': 'asset_inventory',
'generated': datetime.utcnow().isoformat(),
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
'data': data,
'total': total
})
@@ -599,7 +599,7 @@ def pc_relationships():
return success_response({
'report': 'pc_relationships',
'generated': datetime.utcnow().isoformat(),
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
'data': data,
'total': len(data)
})

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,

File diff suppressed because it is too large Load Diff

View File

@@ -7,17 +7,16 @@ from werkzeug.security import generate_password_hash
from shopdb.extensions import db
from shopdb.core.models import User, Role, Permission, AuditLog
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
users_bp = Blueprint('users', __name__)
@users_bp.route('', methods=['GET'])
@jwt_required()
@require_role('admin')
def list_users():
"""List all users."""
if not current_user.hasrole('admin'):
return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
users = User.query.order_by(User.username).all()
return success_response([user_to_dict(u) for u in users])
@@ -26,10 +25,11 @@ def list_users():
@jwt_required()
def get_user(userid: int):
"""Get a single user."""
# inline: decorators cannot express admin-or-self
if not current_user.hasrole('admin') and current_user.userid != userid:
return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403)
user = User.query.get(userid)
user = db.session.get(User, userid)
if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
@@ -38,11 +38,9 @@ def get_user(userid: int):
@users_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_user():
"""Create a new user."""
if not current_user.hasrole('admin'):
return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required')
@@ -90,10 +88,11 @@ def create_user():
@jwt_required()
def update_user(userid: int):
"""Update a user."""
# inline: decorators cannot express admin-or-self
if not current_user.hasrole('admin') and current_user.userid != userid:
return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403)
user = User.query.get(userid)
user = db.session.get(User, userid)
if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
@@ -120,7 +119,7 @@ def update_user(userid: int):
changes['lastname'] = {'old': user.lastname, 'new': data['lastname']}
user.lastname = data['lastname']
# Admin-only fields
# Admin-only fields (inline: gates a subset of fields on a shared route)
if current_user.hasrole('admin'):
if 'isactive' in data:
if data['isactive'] != user.isactive:
@@ -156,15 +155,13 @@ def update_user(userid: int):
@users_bp.route('/<int:userid>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_user(userid: int):
"""Delete a user."""
if not current_user.hasrole('admin'):
return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
if current_user.userid == userid:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete your own account')
user = User.query.get(userid)
user = db.session.get(User, userid)
if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
@@ -225,11 +222,9 @@ def list_roles():
@users_bp.route('/roles', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_role():
"""Create a new role."""
if not current_user.hasrole('admin'):
return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
data = request.get_json()
if not data or not data.get('rolename'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Role name is required')
@@ -263,12 +258,10 @@ def create_role():
@users_bp.route('/roles/<int:roleid>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_role(roleid: int):
"""Update a role."""
if not current_user.hasrole('admin'):
return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
role = Role.query.get(roleid)
role = db.session.get(Role, roleid)
if not role:
return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404)
@@ -308,12 +301,10 @@ def update_role(roleid: int):
@users_bp.route('/roles/<int:roleid>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_role(roleid: int):
"""Delete a role."""
if not current_user.hasrole('admin'):
return error_response(ErrorCodes.FORBIDDEN, 'Admin access required', http_code=403)
role = Role.query.get(roleid)
role = db.session.get(Role, roleid)
if not role:
return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404)

View File

@@ -44,7 +44,7 @@ def list_vendors():
@jwt_required(optional=True)
def get_vendor(vendor_id: int):
"""Get a single vendor."""
v = Vendor.query.get(vendor_id)
v = db.session.get(Vendor, vendor_id)
if not v:
return error_response(
@@ -93,7 +93,7 @@ def create_vendor():
@require_role('admin')
def update_vendor(vendor_id: int):
"""Update a vendor."""
v = Vendor.query.get(vendor_id)
v = db.session.get(Vendor, vendor_id)
if not v:
return error_response(
@@ -127,7 +127,7 @@ def update_vendor(vendor_id: int):
@require_role('admin')
def delete_vendor(vendor_id: int):
"""Delete (deactivate) a vendor."""
v = Vendor.query.get(vendor_id)
v = db.session.get(Vendor, vendor_id)
if not v:
return error_response(

View File

@@ -1,8 +1,12 @@
"""Audit log model for tracking changes."""
from datetime import datetime
from datetime import datetime, timezone
from shopdb.extensions import db
def _utcnow():
# naive UTC for DB columns (stored without tzinfo)
return datetime.now(timezone.utc).replace(tzinfo=None)
class AuditLog(db.Model):
"""
@@ -19,7 +23,7 @@ class AuditLog(db.Model):
username = db.Column(db.String(100), nullable=True) # Denormalized for history
# When
timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
timestamp = db.Column(db.DateTime, default=_utcnow, nullable=False, index=True)
# Where (client info)
ipaddress = db.Column(db.String(45), nullable=True) # IPv6 max length

View File

@@ -1,9 +1,14 @@
"""Base model class with common fields."""
from datetime import datetime
from datetime import datetime, timezone
from shopdb.extensions import db
def _utcnow():
# naive UTC for DB columns (stored without tzinfo)
return datetime.now(timezone.utc).replace(tzinfo=None)
class BaseModel(db.Model):
"""
Abstract base model with common fields.
@@ -13,13 +18,13 @@ class BaseModel(db.Model):
createddate = db.Column(
db.DateTime,
default=datetime.utcnow,
default=_utcnow,
nullable=False
)
modifieddate = db.Column(
db.DateTime,
default=datetime.utcnow,
onupdate=datetime.utcnow,
default=_utcnow,
onupdate=_utcnow,
nullable=False
)
isactive = db.Column(db.Boolean, default=True, nullable=False)
@@ -55,7 +60,7 @@ class SoftDeleteMixin:
def soft_delete(self, deleted_by: str = None):
"""Mark record as deleted."""
self.isactive = False
self.deleteddate = datetime.utcnow()
self.deleteddate = datetime.now(timezone.utc).replace(tzinfo=None)
self.deletedby = deleted_by

View File

@@ -1,9 +1,17 @@
"""System settings model for key-value configuration storage."""
from datetime import datetime
from datetime import datetime, timezone
from sqlalchemy.exc import IntegrityError
from shopdb.extensions import db
def _utcnow():
"""Naive UTC now for column defaults (matches the app's naive datetime cols)."""
return datetime.now(timezone.utc).replace(tzinfo=None)
class Setting(db.Model):
"""
Key-value store for system settings.
@@ -19,8 +27,8 @@ class Setting(db.Model):
valuetype = db.Column(db.String(20), default='string') # string, boolean, integer, json
category = db.Column(db.String(50), default='general') # For grouping in UI
description = db.Column(db.String(255), nullable=True)
createddate = db.Column(db.DateTime, default=datetime.utcnow)
modifieddate = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
createddate = db.Column(db.DateTime, default=_utcnow)
modifieddate = db.Column(db.DateTime, default=_utcnow, onupdate=_utcnow)
def to_dict(self):
return {
@@ -55,19 +63,40 @@ class Setting(db.Model):
return setting.get_typed_value()
return default
@staticmethod
def _stringify(value):
"""Convert a value to its stored string form."""
if isinstance(value, bool):
return 'true' if value else 'false'
return str(value) if value is not None else None
@classmethod
def set(cls, key: str, value, valuetype: str = 'string', category: str = 'general', description: str = None):
"""Set a setting value, creating if it doesn't exist."""
"""Set a setting value, creating if it doesn't exist.
Handles the create race: two concurrent callers can both find no row and
both try to INSERT the same unique key. The loser's commit raises
IntegrityError; we roll back, re-fetch the row the winner created, and
apply our value to it.
"""
setting = cls.query.filter_by(key=key).first()
if not setting:
setting = cls(key=key, valuetype=valuetype, category=category, description=description)
db.session.add(setting)
if setting:
setting.value = cls._stringify(value)
db.session.commit()
return setting
# Convert value to string for storage
if isinstance(value, bool):
setting.value = 'true' if value else 'false'
else:
setting.value = str(value) if value is not None else None
db.session.commit()
return setting
setting = cls(key=key, valuetype=valuetype, category=category,
description=description, value=cls._stringify(value))
db.session.add(setting)
try:
db.session.commit()
return setting
except IntegrityError:
db.session.rollback()
# Another transaction inserted this key first; update that row.
setting = cls.query.filter_by(key=key).first()
if setting is None:
raise
setting.value = cls._stringify(value)
db.session.commit()
return setting

View File

@@ -1,6 +1,6 @@
"""User and authentication models."""
from datetime import datetime
from datetime import datetime, timezone
from shopdb.extensions import db
from .base import BaseModel
@@ -179,7 +179,7 @@ class User(BaseModel):
def islocked(self):
"""Check if account is locked."""
if self.lockeduntil:
return datetime.utcnow() < self.lockeduntil
return datetime.now(timezone.utc).replace(tzinfo=None) < self.lockeduntil
return False
def hasrole(self, rolename: str) -> bool:

View File

@@ -4,7 +4,7 @@ import json
from pathlib import Path
from typing import Dict, List, Optional
from dataclasses import dataclass, field, asdict
from datetime import datetime
from datetime import datetime, timezone
@dataclass
@@ -59,7 +59,7 @@ class PluginRegistry:
state = PluginState(
name=name,
version=version,
installed_at=datetime.utcnow().isoformat(),
installed_at=datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
enabled=enabled
)
self._plugins[name] = state

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="3300" height="2550" viewBox="0 0 3300 2550">
<defs>
<pattern id="grid" width="150" height="150" patternUnits="userSpaceOnUse">
<path d="M 150 0 L 0 0 0 150" fill="none" stroke="#d0d4d9" stroke-width="2"/>
</pattern>
</defs>
<rect width="3300" height="2550" fill="#f4f6f8"/>
<rect width="3300" height="2550" fill="url(#grid)"/>
<rect x="20" y="20" width="3260" height="2510" fill="none" stroke="#b8bec6" stroke-width="4"/>
<text x="1650" y="1275" fill="#8a9099" font-family="Arial, Helvetica, sans-serif" font-size="90" font-weight="600" text-anchor="middle" dominant-baseline="central">Upload your facility floor plan in Settings &gt; Map</text>
</svg>

After

Width:  |  Height:  |  Size: 726 B

View File

@@ -2,7 +2,7 @@
from flask import jsonify, make_response
from typing import Any, Dict, List, Optional
from datetime import datetime
from datetime import datetime, timezone
import uuid
@@ -43,7 +43,7 @@ def api_response(
response = {
'status': status,
'meta': {
'timestamp': datetime.utcnow().isoformat() + 'Z',
'timestamp': datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z',
'requestid': str(uuid.uuid4())[:8],
**(meta or {})
}