Mar 02 2026: implement audit trail

This commit is contained in:
2026-03-02 17:22:48 -05:00
parent 90ea135917
commit da13cb1a60
12 changed files with 586 additions and 6 deletions
+92
View File
@@ -0,0 +1,92 @@
import logging
from flask import Blueprint, render_template, request
from flask_login import login_required
from app.models.audit import AuditLog
from app.models.user import User
from app.utils.decorators import admin_required
bp = Blueprint('audit', __name__, url_prefix='/audit')
logger = logging.getLogger(__name__)
# ── List (paginated, filterable) ──────────────────────────────────────────────
@bp.route('/')
@login_required
@admin_required
def index():
page = request.args.get('page', 1, type=int)
# ── Filter params ─────────────────────────────────────────────────────
filter_user = request.args.get('user_id', '', type=str)
filter_action = request.args.get('action', '', type=str)
filter_entity_type = request.args.get('entity_type', '', type=str)
filter_date_from = request.args.get('date_from', '', type=str)
filter_date_to = request.args.get('date_to', '', type=str)
q = AuditLog.query.order_by(AuditLog.created_at.desc())
if filter_user.isdigit():
q = q.filter(AuditLog.user_id == int(filter_user))
if filter_action:
q = q.filter(AuditLog.action == filter_action)
if filter_entity_type:
q = q.filter(AuditLog.entity_type == filter_entity_type)
if filter_date_from:
try:
from datetime import datetime
q = q.filter(AuditLog.created_at >= datetime.strptime(filter_date_from, '%Y-%m-%d'))
except ValueError:
pass
if filter_date_to:
try:
from datetime import datetime, timedelta
# Include the full day_to by shifting to midnight of next day
q = q.filter(AuditLog.created_at < datetime.strptime(filter_date_to, '%Y-%m-%d') + timedelta(days=1))
except ValueError:
pass
logs = q.paginate(page=page, per_page=50, error_out=False)
users = User.query.order_by(User.username).all()
# Distinct action and entity_type values for the filter dropdowns
distinct_actions = (
db.session.query(AuditLog.action)
.distinct()
.order_by(AuditLog.action)
.all()
)
distinct_entity_types = (
db.session.query(AuditLog.entity_type)
.distinct()
.order_by(AuditLog.entity_type)
.all()
)
return render_template(
'audit/index.html',
logs=logs,
users=users,
distinct_actions=[r[0] for r in distinct_actions],
distinct_entity_types=[r[0] for r in distinct_entity_types],
filter_user=filter_user,
filter_action=filter_action,
filter_entity_type=filter_entity_type,
filter_date_from=filter_date_from,
filter_date_to=filter_date_to,
)
# ── Detail ────────────────────────────────────────────────────────────────────
@bp.route('/<int:log_id>')
@login_required
@admin_required
def view(log_id):
entry = AuditLog.query.get_or_404(log_id)
return render_template('audit/view.html', entry=entry)
# Avoid circular import — imported after function definitions
from app import db # noqa: E402
+12 -1
View File
@@ -6,6 +6,7 @@ from app.models.user import User
from app.utils.forms import LoginForm, UserForm, ProfileForm
from app.utils.decorators import admin_required
import logging
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT
logger = logging.getLogger(__name__)
@@ -41,6 +42,7 @@ def login():
login_user(user, remember=form.remember_me.data)
# Use validated next URL — never redirect blindly to request.args['next']
next_page = _safe_next(request.args.get('next'))
log_action(ACTION_LOGIN, 'User', user.id, user.username)
flash(f'Welcome back, {user.username}!', 'success')
return redirect(next_page)
else:
@@ -53,6 +55,7 @@ def login():
@bp.route('/logout')
@login_required
def logout():
log_action(ACTION_LOGOUT, 'User', current_user.id, current_user.username)
logout_user()
flash('Successfully logged out.', 'success')
return redirect(url_for('auth.login'))
@@ -78,6 +81,8 @@ def profile():
db.session.commit()
logger.info('AUTH | profile_update | user_id=%s username=%s email=%s',
current_user.id, current_user.username, current_user.email)
log_action(ACTION_UPDATE, 'User', current_user.id, current_user.username,
'self-service profile update')
flash('Profile updated successfully.', 'success')
return redirect(url_for('auth.profile'))
@@ -134,6 +139,8 @@ def create_user():
db.session.commit()
logger.info('AUTH | user_create | admin_id=%s admin=%s new_user=%s role=%s',
current_user.id, current_user.username, user.username, user.role)
log_action(ACTION_CREATE, 'User', user.id, user.username,
f'role={user.role}; email={user.email}')
flash(f'User {user.username} created successfully.', 'success')
return redirect(url_for('auth.list_users'))
@@ -158,6 +165,8 @@ def edit_user(user_id):
db.session.commit()
logger.info('AUTH | user_edit | admin_id=%s admin=%s target_user_id=%s target_user=%s',
current_user.id, current_user.username, user.id, user.username)
log_action(ACTION_UPDATE, 'User', user.id, user.username,
f'role={user.role}; email={user.email}')
flash(f'User {user.username} updated successfully.', 'success')
return redirect(url_for('auth.list_users'))
@@ -186,9 +195,11 @@ def delete_user(user_id):
return redirect(url_for('auth.list_users'))
username = user.username
user_id = user.id
db.session.delete(user)
db.session.commit()
logger.info('AUTH | user_delete | admin_id=%s admin=%s deleted_user=%s',
current_user.id, current_user.username, username)
log_action(ACTION_DELETE, 'User', user_id, username)
flash(f'User {username} deleted successfully.', 'success')
return redirect(url_for('auth.list_users'))
return redirect(url_for('auth.list_users'))
+14 -5
View File
@@ -1,9 +1,10 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required
from flask_login import login_required, current_user
from app import db
from app.models.facility import Facility, Area
from app.utils.forms import FacilityForm, AreaForm
from app.utils.decorators import supervisor_required, admin_required
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
bp = Blueprint('facilities', __name__, url_prefix='/facilities')
@@ -30,7 +31,8 @@ def create_facility():
db.session.add(facility)
db.session.commit()
log_action(ACTION_CREATE, 'Facility', facility.id, facility.name,
f'contact={facility.contact_person or ""}; active={facility.active}')
flash(f'Facility "{facility.name}" created successfully.', 'success')
return redirect(url_for('facilities.view_facility', facility_id=facility.id))
@@ -58,6 +60,8 @@ def edit_facility(facility_id):
facility.active = form.active.data
db.session.commit()
log_action(ACTION_UPDATE, 'Facility', facility.id, facility.name,
f'active={facility.active}')
flash(f'Facility "{facility.name}" updated successfully.', 'success')
return redirect(url_for('facilities.view_facility', facility_id=facility.id))
@@ -74,9 +78,10 @@ def delete_facility(facility_id):
return redirect(url_for('facilities.view_facility', facility_id=facility.id))
facility_name = facility.name
facility_id_snap = facility.id
db.session.delete(facility)
db.session.commit()
log_action(ACTION_DELETE, 'Facility', facility_id_snap, facility_name)
flash(f'Facility "{facility_name}" has been permanently deleted.', 'success')
return redirect(url_for('facilities.list_facilities'))
@@ -100,7 +105,8 @@ def create_area(facility_id):
db.session.add(area)
db.session.commit()
log_action(ACTION_CREATE, 'Area', area.id, area.name,
f'facility={facility.name}; type={area.area_type or ""}')
flash(f'Area "{area.name}" created successfully.', 'success')
return redirect(url_for('facilities.view_facility', facility_id=facility.id))
@@ -122,6 +128,8 @@ def edit_area(area_id):
area.facility_id = form.facility_id.data
db.session.commit()
log_action(ACTION_UPDATE, 'Area', area.id, area.name,
f'facility_id={area.facility_id}; type={area.area_type or ""}')
flash(f'Area "{area.name}" updated successfully.', 'success')
return redirect(url_for('facilities.view_facility', facility_id=area.facility_id))
@@ -139,8 +147,9 @@ def delete_area(area_id):
return redirect(url_for('facilities.view_facility', facility_id=facility_id))
area_name = area.name
area_id_snap = area.id
db.session.delete(area)
db.session.commit()
log_action(ACTION_DELETE, 'Area', area_id_snap, area_name)
flash(f'Area "{area_name}" deleted successfully.', 'success')
return redirect(url_for('facilities.view_facility', facility_id=facility_id))
+13
View File
@@ -17,6 +17,7 @@ from app.utils.decorators import supervisor_required
from app.utils.pdf_export import generate_inspection_pdf
from app.utils.notifications import notify
from app.models.notification import EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
bp = Blueprint('inspections', __name__, url_prefix='/inspections')
@@ -233,6 +234,9 @@ def start():
)
db.session.add(inspection)
db.session.commit()
log_action(ACTION_CREATE, 'Inspection', inspection.id,
f'{inspection.template.name} @ {inspection.facility.name}',
f'template_id={inspection.template_id}; facility_id={inspection.facility_id}')
flash('Inspection started. Fill in the form below and submit when complete.', 'info')
return redirect(url_for('inspections.execute', inspection_id=inspection.id))
@@ -333,6 +337,9 @@ def execute(inspection_id):
send_email = True,
)
db.session.commit() # Commit notifications
log_action(ACTION_UPDATE, 'Inspection', inspection.id,
f'{inspection.template.name} @ {inspection.facility.name}',
f'status=completed; score={score}')
flash('Inspection submitted successfully!', 'success')
return redirect(url_for('inspections.view', inspection_id=inspection_id))
@@ -526,6 +533,9 @@ def export_pdf(inspection_id):
'PDF export | inspection_id=%s | inspector=%s | by=%s',
inspection.id, inspection.inspector.username, current_user.username
)
log_action(ACTION_EXPORT, 'Inspection', inspection.id,
f'{inspection.template.name} @ {inspection.facility.name}',
f'format=pdf')
return Response(
pdf_bytes,
@@ -586,6 +596,9 @@ def delete(inspection_id):
insp_id, facility_name, template_name,
insp_date, inspector_name, current_user.username
)
log_action(ACTION_DELETE, 'Inspection', insp_id,
f'{template_name} @ {facility_name}',
f'date={insp_date}; inspector={inspector_name}')
flash(
f'Inspection #{insp_id} ({template_name}{facility_name}, {insp_date}) '
+7
View File
@@ -13,6 +13,7 @@ from app.models.notification import (
from app.utils.forms import IssueForm, IssueUpdateForm
from app.utils.decorators import supervisor_required
from app.utils.notifications import notify
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
bp = Blueprint('issues', __name__, url_prefix='/issues')
@@ -237,6 +238,9 @@ def view(issue_id):
)
db.session.commit() # Commit all notifications
log_action(ACTION_UPDATE, 'Issue', issue.id,
f'#{issue.id} in {issue.area.name}',
f'status={issue.status}; assigned_to={issue.assigned_to}')
flash('Issue updated.', 'success')
return redirect(url_for('issues.view', issue_id=issue_id))
@@ -324,6 +328,9 @@ def create():
'ISSUE CREATED | id=%s | severity=%s | area_id=%s | assigned_to=%s | created_by=%s',
issue.id, issue.severity, issue.area_id, issue.assigned_to, current_user.username
)
log_action(ACTION_CREATE, 'Issue', issue.id,
f'#{issue.id} {issue.severity} in {issue.area.name}',
f'severity={issue.severity}; assigned_to={issue.assigned_to}')
if issue.assigned_to:
assignee = User.query.get(issue.assigned_to)
+9
View File
@@ -5,6 +5,7 @@ from app.models.inspection import InspectionTemplate, ChecklistItem
from app.utils.forms import InspectionTemplateForm, ChecklistItemForm
from app.utils.decorators import supervisor_required
import json
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
bp = Blueprint('templates', __name__, url_prefix='/templates')
@@ -35,6 +36,8 @@ def create_template():
)
db.session.add(template)
db.session.commit()
log_action(ACTION_CREATE, 'Template', template.id, template.name,
f'frequency={template.frequency}')
flash(f'Template "{template.name}" created successfully.', 'success')
return redirect(url_for('templates.form_editor', template_id=template.id))
@@ -66,6 +69,8 @@ def edit_template(template_id):
template.description = form.description.data
template.frequency = form.frequency.data
db.session.commit()
log_action(ACTION_UPDATE, 'Template', template.id, template.name,
f'frequency={template.frequency}')
flash(f'Template "{template.name}" updated successfully.', 'success')
return redirect(url_for('templates.view_template', template_id=template.id))
@@ -118,8 +123,10 @@ def delete_template(template_id):
return redirect(url_for('templates.index'))
template_name = template.name
template_id_snap = template.id
db.session.delete(template)
db.session.commit()
log_action(ACTION_DELETE, 'Template', template_id_snap, template_name)
flash(f'Template "{template_name}" deleted successfully.', 'success')
return redirect(url_for('templates.index'))
@@ -159,6 +166,8 @@ def duplicate_template(template_id):
new_tpl.form_schema = src.form_schema
db.session.commit()
log_action(ACTION_CREATE, 'Template', new_tpl.id, new_tpl.name,
f'duplicated_from={src.id}; frequency={new_tpl.frequency}')
flash(f'Template "{src.name}" duplicated successfully.', 'success')
return redirect(url_for('templates.index'))