You are right that this is the one we keep hitting. perpage is capped at 100
server-side, and the cap is applied SILENTLY: a request for 500 returns the
first 100 rows with a 200, so a picker built that way shows the first 100
options and drops the rest with nothing anywhere reporting a problem. The
missing vendor simply is not in the list.
Twelve call sites were still doing it - vendor and business-unit pickers on the
machine and measuring-tool forms, and the type lists behind PCs, machines,
printers, network devices, measuring tools and configuration policies. Each now
either pages through with listAll()/fetchAllPages() or asks for what it can
actually have. vendorsApi and businessunitsApi gained listAll(), which they
lacked, which is why those two sites reached for 500 in the first place.
The gate is the point. tests/test_core/test_perpage_cap.py reads every .vue and
.js source under frontend/src and plugins/*/frontend and fails on any perpage
over 100. It has to read the SOURCE rather than the running app, because the
response is a valid 200 either way - no runtime assertion can see this one.
Verified by planting a perpage: 500 back into MachineForm: the gate names that
exact line.
Separately, /api/auditlogs built its own meta with the pagination fields flat
and a 'pages' key, while every other list returns
meta.pagination.{page,perpage,total,totalpages,hasnext,hasprev}. A client
written against the documented contract found nothing where it looked, on the
one list whose whole purpose is being read by somebody checking what happened.
It uses paginated_response now.
Also carried here, from the previous batch's working tree: Asset.to_dict emits
ownlocationid/ownmapx/ownmapy/ownlevelid alongside the resolved values, and the
PC form binds to those. The merge of an inherited location is right for a reader
and wrong for a form, which could not tell an owned value from a derived one and
wrote the inherited one back as the record's own.
Scope: product
262 lines
9.1 KiB
Python
262 lines
9.1 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.extensions import db
|
|
from shopdb.utils.responses import (success_response, error_response,
|
|
paginated_response, ErrorCodes)
|
|
from shopdb.utils.authz import require_permission
|
|
|
|
auditlogs_bp = Blueprint('auditlogs', __name__)
|
|
|
|
|
|
def _resolve_full_names(ssos):
|
|
"""Best-effort SSO -> full name map for audit rows. Mode-aware, guarded,
|
|
degrades to {} so the list still renders if the directory is unreachable."""
|
|
wanted = {s for s in ssos if s and str(s).isdigit()}
|
|
if not wanted:
|
|
return {}
|
|
names = {}
|
|
# Self-hosted directory first (no external dependency).
|
|
try:
|
|
from plugins.employees.models import DirectoryEmployee
|
|
rows = DirectoryEmployee.query.filter(
|
|
DirectoryEmployee.sso.in_(wanted)).all()
|
|
for emp in rows:
|
|
full = f'{(emp.firstname or "").strip()} {(emp.lastname or "").strip()}'.strip()
|
|
if full:
|
|
names[str(emp.sso)] = full
|
|
except Exception:
|
|
pass
|
|
missing = wanted - set(names)
|
|
if not missing:
|
|
return names
|
|
# External HR directory for anything still unresolved.
|
|
try:
|
|
from shopdb.utils.employee_db import employee_connection
|
|
conn = employee_connection()
|
|
placeholders = ','.join(['%s'] * len(missing))
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
'SELECT SSO, First_Name, Last_Name FROM employees '
|
|
f'WHERE SSO IN ({placeholders})', tuple(missing))
|
|
for emp in cur.fetchall():
|
|
full = f'{(emp["First_Name"] or "").strip()} {(emp["Last_Name"] or "").strip()}'.strip()
|
|
if full:
|
|
names[str(emp['SSO'])] = full
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
return names
|
|
|
|
|
|
@auditlogs_bp.route('', methods=['GET'])
|
|
@require_permission('audit.view')
|
|
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)
|
|
|
|
rows = [log.to_dict() for log in pagination.items]
|
|
# Attach full names (hover tooltip on the SSO); best-effort.
|
|
fullnames = _resolve_full_names({r.get('username') for r in rows})
|
|
for r in rows:
|
|
r['userfullname'] = fullnames.get(str(r.get('username')))
|
|
|
|
# paginated_response, like every other list in the product. This one built
|
|
# its own meta with the fields FLAT and named 'pages', so a client written
|
|
# against the documented meta.pagination.{page,perpage,total,totalpages}
|
|
# found nothing where it looked - on the one list whose whole purpose is to
|
|
# be read by somebody checking what happened.
|
|
return paginated_response(rows, page, perpage, pagination.total)
|
|
|
|
|
|
@auditlogs_bp.route('/entity/<entitytype>/<int:entityid>', methods=['GET'])
|
|
@require_permission('audit.view')
|
|
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'])
|
|
@require_permission('audit.view')
|
|
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}
|
|
|
|
|
|
@auditlogs_bp.route('/retention', methods=['GET'])
|
|
@require_permission('audit.view')
|
|
def get_retention():
|
|
"""Size of the log and what the configured retention would remove.
|
|
|
|
Drives the Site & Facility card: an admin should see the number of entries a
|
|
purge deletes BEFORE pressing anything.
|
|
"""
|
|
from shopdb.core.services import auditretention
|
|
days = auditretention.configureddays()
|
|
return success_response(auditretention.stats(days or None))
|
|
|
|
|
|
@auditlogs_bp.route('/purge', methods=['POST'])
|
|
@require_permission('audit.purge')
|
|
def purge_auditlogs():
|
|
"""Delete entries older than the retention window.
|
|
|
|
Separate permission from audit.view: reading the log and destroying it are
|
|
not the same act, and plenty of people need the first.
|
|
|
|
Batch-capped, because this runs inside a request that a proxy will time out.
|
|
An incomplete purge returns complete=false and the number still to go rather
|
|
than reporting a finished job - the caller re-posts, or runs
|
|
`flask audit purge --apply` on the server, which has no cap.
|
|
"""
|
|
from flask import request as flask_request
|
|
from shopdb.core.services import auditretention
|
|
|
|
body = flask_request.get_json(silent=True) or {}
|
|
days = body.get('days')
|
|
if days is None:
|
|
days = auditretention.configureddays()
|
|
try:
|
|
days = int(days)
|
|
except (TypeError, ValueError):
|
|
days = 0
|
|
if days <= 0:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'No retention period is configured. Set one in Site & Facility '
|
|
'settings before purging.')
|
|
|
|
dryrun = bool(body.get('dryrun'))
|
|
result = auditretention.purge(
|
|
days, dryrun=dryrun,
|
|
maxbatches=None if dryrun else auditretention.MAXBATCHES_REQUEST)
|
|
|
|
if dryrun:
|
|
message = '{:,} entry(s) would be deleted'.format(result['matched'])
|
|
elif result['complete']:
|
|
message = 'Deleted {:,} entry(s)'.format(result['deleted'])
|
|
else:
|
|
message = ('Deleted {:,} entry(s); {:,} still older than the cutoff. '
|
|
'Run it again to continue.').format(
|
|
result['deleted'], result['remaining'])
|
|
|
|
# The purge is itself an audited act, and this row postdates the cutoff so
|
|
# it survives the purge that created it.
|
|
if not dryrun and result['deleted']:
|
|
from shopdb.api import audit_log
|
|
audit_log(action='deleted', entitytype='AuditLog',
|
|
entityname='purge older than {} days'.format(days),
|
|
changes={'deleted': result['deleted'],
|
|
'cutoff': result['cutoff']})
|
|
|
|
return success_response(result, message=message)
|