Files
shopdb-flask/shopdb/core/api/auditlogs.py
cproudlock b8c22244a1
Some checks failed
CI / backend (push) Failing after 2s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Multi-site distribution readiness: settings-driven site config, security closeout, release engineering, v0.5.0
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>
2026-07-10 15:02:07 -04:00

147 lines
4.5 KiB
Python

"""Audit log API routes."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.core.models import AuditLog
from shopdb.utils.responses import success_response, error_response, ErrorCodes
auditlogs_bp = Blueprint('auditlogs', __name__)
@auditlogs_bp.route('', methods=['GET'])
@jwt_required()
def list_auditlogs():
"""
List audit logs with filtering and pagination.
Query params:
page: Page number (default 1)
perpage: Items per page (default 50, max 200)
action: Filter by action (created, updated, deleted)
entitytype: Filter by entity type
userid: Filter by user ID
search: Search in entityname or username
from_date: Filter from date (ISO format)
to_date: Filter to date (ISO format)
"""
page = request.args.get('page', 1, type=int)
perpage = min(request.args.get('perpage', 50, type=int), 200)
query = AuditLog.query
# Filters
action = request.args.get('action')
if action:
query = query.filter(AuditLog.action == action)
entitytype = request.args.get('entitytype')
if entitytype:
query = query.filter(AuditLog.entitytype == entitytype)
userid = request.args.get('userid', type=int)
if userid:
query = query.filter(AuditLog.userid == userid)
search = request.args.get('search')
if search:
search_term = f'%{search}%'
query = query.filter(
(AuditLog.entityname.ilike(search_term)) |
(AuditLog.username.ilike(search_term))
)
from_date = request.args.get('from_date')
if from_date:
from datetime import datetime
try:
dt = datetime.fromisoformat(from_date.replace('Z', '+00:00'))
query = query.filter(AuditLog.timestamp >= dt)
except ValueError:
pass
to_date = request.args.get('to_date')
if to_date:
from datetime import datetime
try:
dt = datetime.fromisoformat(to_date.replace('Z', '+00:00'))
query = query.filter(AuditLog.timestamp <= dt)
except ValueError:
pass
# Order by most recent first
query = query.order_by(AuditLog.timestamp.desc())
# Paginate
pagination = query.paginate(page=page, per_page=perpage, error_out=False)
return success_response(
[log.to_dict() for log in pagination.items],
meta={
'page': page,
'perpage': perpage,
'total': pagination.total,
'pages': pagination.pages
}
)
@auditlogs_bp.route('/entity/<entitytype>/<int:entityid>', methods=['GET'])
@jwt_required()
def get_entity_history(entitytype: str, entityid: int):
"""Get audit history for a specific entity."""
logs = AuditLog.query.filter_by(
entitytype=entitytype,
entityid=entityid
).order_by(AuditLog.timestamp.desc()).all()
return success_response([log.to_dict() for log in logs])
@auditlogs_bp.route('/stats', methods=['GET'])
@jwt_required()
def get_stats():
"""Get audit log statistics."""
from sqlalchemy import func
from datetime import datetime, timedelta, timezone
# Actions by type
actions = db_func_count_by(AuditLog.action)
# Entity types
entities = db_func_count_by(AuditLog.entitytype)
# Recent activity (last 7 days)
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)
from shopdb.extensions import db
active_users = db.session.query(
AuditLog.username,
func.count(AuditLog.auditlogid).label('count')
).filter(
AuditLog.timestamp >= week_ago,
AuditLog.username.isnot(None)
).group_by(AuditLog.username).order_by(func.count(AuditLog.auditlogid).desc()).limit(5).all()
return success_response({
'actions': actions,
'entities': entities,
'recentCount': recent_count,
'activeUsers': [{'username': u[0], 'count': u[1]} for u in active_users]
})
def db_func_count_by(column):
"""Helper to count grouped by a column."""
from sqlalchemy import func
from shopdb.extensions import db
results = db.session.query(
column,
func.count().label('count')
).group_by(column).all()
return {r[0]: r[1] for r in results}