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
+2
View File
@@ -96,6 +96,7 @@ def create_app(config_name='default'):
from app.routes import auth, dashboard, inspections, templates, reports, facilities from app.routes import auth, dashboard, inspections, templates, reports, facilities
from app.routes import issues # Phase 3 from app.routes import issues # Phase 3
from app.routes import notifications # Notification system from app.routes import notifications # Notification system
from app.routes import audit # Audit Trail
app.register_blueprint(auth.bp) app.register_blueprint(auth.bp)
app.register_blueprint(dashboard.bp) app.register_blueprint(dashboard.bp)
@@ -105,6 +106,7 @@ def create_app(config_name='default'):
app.register_blueprint(facilities.bp) app.register_blueprint(facilities.bp)
app.register_blueprint(issues.bp) app.register_blueprint(issues.bp)
app.register_blueprint(notifications.bp) app.register_blueprint(notifications.bp)
app.register_blueprint(audit.bp)
# ── Error handler: 413 Request Entity Too Large ─────────────────────── # ── Error handler: 413 Request Entity Too Large ───────────────────────
# Nginx can return 413 before Flask sees the request; this handler covers # Nginx can return 413 before Flask sees the request; this handler covers
+35
View File
@@ -0,0 +1,35 @@
from app import db
from app.utils.time_utils import now_eastern
class AuditLog(db.Model):
"""
Persistent record of every create / edit / delete action performed by
a user. Entries are immutable once written — never updated or deleted
through the application.
"""
__tablename__ = 'audit_logs'
id = db.Column(db.Integer, primary_key=True)
# Who performed the action (NULL-safe: user may be deleted later)
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
username = db.Column(db.String(100), nullable=False) # snapshot at time of action
user_role = db.Column(db.String(20), nullable=False) # snapshot at time of action
# What happened
action = db.Column(db.String(50), nullable=False) # CREATE / UPDATE / DELETE / LOGIN / LOGOUT / EXPORT
entity_type = db.Column(db.String(50), nullable=False) # User / Facility / Area / Template / Inspection / Issue / …
entity_id = db.Column(db.Integer, nullable=True) # PK of the affected record (NULL for bulk ops)
entity_label = db.Column(db.String(255), nullable=True) # Human-readable identifier snapshot
# Extra context stored as free-text (key=value pairs, comma-separated)
details = db.Column(db.Text, nullable=True)
# When
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False, index=True)
# Request context
ip_address = db.Column(db.String(45), nullable=True) # supports IPv6
# Relationship — may be None if user was deleted
user = db.relationship('User', foreign_keys=[user_id])
def __repr__(self):
return (f'<AuditLog {self.id} {self.action} {self.entity_type}:{self.entity_id}'
f' by {self.username}>')
+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
+11
View File
@@ -6,6 +6,7 @@ from app.models.user import User
from app.utils.forms import LoginForm, UserForm, ProfileForm from app.utils.forms import LoginForm, UserForm, ProfileForm
from app.utils.decorators import admin_required from app.utils.decorators import admin_required
import logging import logging
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -41,6 +42,7 @@ def login():
login_user(user, remember=form.remember_me.data) login_user(user, remember=form.remember_me.data)
# Use validated next URL — never redirect blindly to request.args['next'] # Use validated next URL — never redirect blindly to request.args['next']
next_page = _safe_next(request.args.get('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') flash(f'Welcome back, {user.username}!', 'success')
return redirect(next_page) return redirect(next_page)
else: else:
@@ -53,6 +55,7 @@ def login():
@bp.route('/logout') @bp.route('/logout')
@login_required @login_required
def logout(): def logout():
log_action(ACTION_LOGOUT, 'User', current_user.id, current_user.username)
logout_user() logout_user()
flash('Successfully logged out.', 'success') flash('Successfully logged out.', 'success')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
@@ -78,6 +81,8 @@ def profile():
db.session.commit() db.session.commit()
logger.info('AUTH | profile_update | user_id=%s username=%s email=%s', logger.info('AUTH | profile_update | user_id=%s username=%s email=%s',
current_user.id, current_user.username, current_user.email) 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') flash('Profile updated successfully.', 'success')
return redirect(url_for('auth.profile')) return redirect(url_for('auth.profile'))
@@ -134,6 +139,8 @@ def create_user():
db.session.commit() db.session.commit()
logger.info('AUTH | user_create | admin_id=%s admin=%s new_user=%s role=%s', 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) 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') flash(f'User {user.username} created successfully.', 'success')
return redirect(url_for('auth.list_users')) return redirect(url_for('auth.list_users'))
@@ -158,6 +165,8 @@ def edit_user(user_id):
db.session.commit() db.session.commit()
logger.info('AUTH | user_edit | admin_id=%s admin=%s target_user_id=%s target_user=%s', 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) 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') flash(f'User {user.username} updated successfully.', 'success')
return redirect(url_for('auth.list_users')) return redirect(url_for('auth.list_users'))
@@ -186,9 +195,11 @@ def delete_user(user_id):
return redirect(url_for('auth.list_users')) return redirect(url_for('auth.list_users'))
username = user.username username = user.username
user_id = user.id
db.session.delete(user) db.session.delete(user)
db.session.commit() db.session.commit()
logger.info('AUTH | user_delete | admin_id=%s admin=%s deleted_user=%s', logger.info('AUTH | user_delete | admin_id=%s admin=%s deleted_user=%s',
current_user.id, current_user.username, username) current_user.id, current_user.username, username)
log_action(ACTION_DELETE, 'User', user_id, username)
flash(f'User {username} deleted successfully.', 'success') 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 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 import db
from app.models.facility import Facility, Area from app.models.facility import Facility, Area
from app.utils.forms import FacilityForm, AreaForm from app.utils.forms import FacilityForm, AreaForm
from app.utils.decorators import supervisor_required, admin_required 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') bp = Blueprint('facilities', __name__, url_prefix='/facilities')
@@ -30,7 +31,8 @@ def create_facility():
db.session.add(facility) db.session.add(facility)
db.session.commit() 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') flash(f'Facility "{facility.name}" created successfully.', 'success')
return redirect(url_for('facilities.view_facility', facility_id=facility.id)) 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 facility.active = form.active.data
db.session.commit() db.session.commit()
log_action(ACTION_UPDATE, 'Facility', facility.id, facility.name,
f'active={facility.active}')
flash(f'Facility "{facility.name}" updated successfully.', 'success') flash(f'Facility "{facility.name}" updated successfully.', 'success')
return redirect(url_for('facilities.view_facility', facility_id=facility.id)) 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)) return redirect(url_for('facilities.view_facility', facility_id=facility.id))
facility_name = facility.name facility_name = facility.name
facility_id_snap = facility.id
db.session.delete(facility) db.session.delete(facility)
db.session.commit() db.session.commit()
log_action(ACTION_DELETE, 'Facility', facility_id_snap, facility_name)
flash(f'Facility "{facility_name}" has been permanently deleted.', 'success') flash(f'Facility "{facility_name}" has been permanently deleted.', 'success')
return redirect(url_for('facilities.list_facilities')) return redirect(url_for('facilities.list_facilities'))
@@ -100,7 +105,8 @@ def create_area(facility_id):
db.session.add(area) db.session.add(area)
db.session.commit() 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') flash(f'Area "{area.name}" created successfully.', 'success')
return redirect(url_for('facilities.view_facility', facility_id=facility.id)) 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 area.facility_id = form.facility_id.data
db.session.commit() 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') flash(f'Area "{area.name}" updated successfully.', 'success')
return redirect(url_for('facilities.view_facility', facility_id=area.facility_id)) 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)) return redirect(url_for('facilities.view_facility', facility_id=facility_id))
area_name = area.name area_name = area.name
area_id_snap = area.id
db.session.delete(area) db.session.delete(area)
db.session.commit() db.session.commit()
log_action(ACTION_DELETE, 'Area', area_id_snap, area_name)
flash(f'Area "{area_name}" deleted successfully.', 'success') flash(f'Area "{area_name}" deleted successfully.', 'success')
return redirect(url_for('facilities.view_facility', facility_id=facility_id)) 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.pdf_export import generate_inspection_pdf
from app.utils.notifications import notify from app.utils.notifications import notify
from app.models.notification import EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED 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') bp = Blueprint('inspections', __name__, url_prefix='/inspections')
@@ -233,6 +234,9 @@ def start():
) )
db.session.add(inspection) db.session.add(inspection)
db.session.commit() 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') flash('Inspection started. Fill in the form below and submit when complete.', 'info')
return redirect(url_for('inspections.execute', inspection_id=inspection.id)) return redirect(url_for('inspections.execute', inspection_id=inspection.id))
@@ -333,6 +337,9 @@ def execute(inspection_id):
send_email = True, send_email = True,
) )
db.session.commit() # Commit notifications 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') flash('Inspection submitted successfully!', 'success')
return redirect(url_for('inspections.view', inspection_id=inspection_id)) 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', 'PDF export | inspection_id=%s | inspector=%s | by=%s',
inspection.id, inspection.inspector.username, current_user.username 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( return Response(
pdf_bytes, pdf_bytes,
@@ -586,6 +596,9 @@ def delete(inspection_id):
insp_id, facility_name, template_name, insp_id, facility_name, template_name,
insp_date, inspector_name, current_user.username 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( flash(
f'Inspection #{insp_id} ({template_name}{facility_name}, {insp_date}) ' 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.forms import IssueForm, IssueUpdateForm
from app.utils.decorators import supervisor_required from app.utils.decorators import supervisor_required
from app.utils.notifications import notify 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') bp = Blueprint('issues', __name__, url_prefix='/issues')
@@ -237,6 +238,9 @@ def view(issue_id):
) )
db.session.commit() # Commit all notifications 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') flash('Issue updated.', 'success')
return redirect(url_for('issues.view', issue_id=issue_id)) 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 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 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: if issue.assigned_to:
assignee = User.query.get(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.forms import InspectionTemplateForm, ChecklistItemForm
from app.utils.decorators import supervisor_required from app.utils.decorators import supervisor_required
import json import json
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
bp = Blueprint('templates', __name__, url_prefix='/templates') bp = Blueprint('templates', __name__, url_prefix='/templates')
@@ -35,6 +36,8 @@ def create_template():
) )
db.session.add(template) db.session.add(template)
db.session.commit() db.session.commit()
log_action(ACTION_CREATE, 'Template', template.id, template.name,
f'frequency={template.frequency}')
flash(f'Template "{template.name}" created successfully.', 'success') flash(f'Template "{template.name}" created successfully.', 'success')
return redirect(url_for('templates.form_editor', template_id=template.id)) 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.description = form.description.data
template.frequency = form.frequency.data template.frequency = form.frequency.data
db.session.commit() db.session.commit()
log_action(ACTION_UPDATE, 'Template', template.id, template.name,
f'frequency={template.frequency}')
flash(f'Template "{template.name}" updated successfully.', 'success') flash(f'Template "{template.name}" updated successfully.', 'success')
return redirect(url_for('templates.view_template', template_id=template.id)) 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')) return redirect(url_for('templates.index'))
template_name = template.name template_name = template.name
template_id_snap = template.id
db.session.delete(template) db.session.delete(template)
db.session.commit() db.session.commit()
log_action(ACTION_DELETE, 'Template', template_id_snap, template_name)
flash(f'Template "{template_name}" deleted successfully.', 'success') flash(f'Template "{template_name}" deleted successfully.', 'success')
return redirect(url_for('templates.index')) return redirect(url_for('templates.index'))
@@ -159,6 +166,8 @@ def duplicate_template(template_id):
new_tpl.form_schema = src.form_schema new_tpl.form_schema = src.form_schema
db.session.commit() 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') flash(f'Template "{src.name}" duplicated successfully.', 'success')
return redirect(url_for('templates.index')) return redirect(url_for('templates.index'))
+195
View File
@@ -0,0 +1,195 @@
{% extends "base.html" %}
{% block title %}Audit Trail{% endblock %}
{% block content %}
<div class="row mb-3 align-items-center">
<div class="col">
<h2><i class="bi bi-shield-check"></i> Audit Trail</h2>
<p class="text-muted mb-0">Complete, immutable log of all system actions.</p>
</div>
</div>
<!-- ── Filter Bar ──────────────────────────────────────────────────────────── -->
<div class="card shadow-sm mb-4">
<div class="card-body py-3">
<form method="GET" action="{{ url_for('audit.index') }}" class="row g-2 align-items-end">
<div class="col-md-2">
<label class="form-label form-label-sm fw-semibold mb-1">User</label>
<select name="user_id" class="form-select form-select-sm">
<option value="">All Users</option>
{% for u in users %}
<option value="{{ u.id }}" {% if filter_user == u.id|string %}selected{% endif %}>
{{ u.username }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label form-label-sm fw-semibold mb-1">Action</label>
<select name="action" class="form-select form-select-sm">
<option value="">All Actions</option>
{% for a in distinct_actions %}
<option value="{{ a }}" {% if filter_action == a %}selected{% endif %}>{{ a }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label form-label-sm fw-semibold mb-1">Entity Type</label>
<select name="entity_type" class="form-select form-select-sm">
<option value="">All Types</option>
{% for t in distinct_entity_types %}
<option value="{{ t }}" {% if filter_entity_type == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label form-label-sm fw-semibold mb-1">From Date</label>
<input type="date" name="date_from" class="form-control form-control-sm"
value="{{ filter_date_from }}">
</div>
<div class="col-md-2">
<label class="form-label form-label-sm fw-semibold mb-1">To Date</label>
<input type="date" name="date_to" class="form-control form-control-sm"
value="{{ filter_date_to }}">
</div>
<div class="col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm flex-fill">
<i class="bi bi-funnel me-1"></i>Filter
</button>
<a href="{{ url_for('audit.index') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-x-circle"></i>
</a>
</div>
</form>
</div>
</div>
<!-- ── Results ─────────────────────────────────────────────────────────────── -->
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-semibold">
<i class="bi bi-list-ul me-1"></i>
{{ logs.total }} record{{ 's' if logs.total != 1 else '' }}
{% if filter_user or filter_action or filter_entity_type or filter_date_from or filter_date_to %}
<span class="badge bg-info ms-1">Filtered</span>
{% endif %}
</span>
<small class="text-muted">Page {{ logs.page }} of {{ logs.pages }}</small>
</div>
<div class="card-body p-0">
{% if logs.items %}
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th style="width:160px">Timestamp</th>
<th>User</th>
<th>Role</th>
<th>Action</th>
<th>Entity</th>
<th>Label</th>
<th>IP Address</th>
<th style="width:60px"></th>
</tr>
</thead>
<tbody>
{% for entry in logs.items %}
<tr>
<td class="text-nowrap text-muted small">
{{ entry.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
</td>
<td>
<strong>{{ entry.username }}</strong>
</td>
<td>
<span class="badge bg-{% if entry.user_role == 'admin' %}danger{% elif entry.user_role == 'supervisor' %}warning{% else %}info{% endif %} bg-opacity-75">
{{ entry.user_role | title }}
</span>
</td>
<td>
<span class="badge
{% if entry.action == 'CREATE' %}bg-success
{% elif entry.action == 'UPDATE' %}bg-primary
{% elif entry.action == 'DELETE' %}bg-danger
{% elif entry.action == 'LOGIN' %}bg-secondary
{% elif entry.action == 'LOGOUT' %}bg-secondary
{% elif entry.action == 'EXPORT' %}bg-warning text-dark
{% else %}bg-light text-dark{% endif %}">
{{ entry.action }}
</span>
</td>
<td class="text-muted small">{{ entry.entity_type }}</td>
<td class="small">
{{ entry.entity_label or '—' }}
{% if entry.entity_id %}
<span class="text-muted">#{{ entry.entity_id }}</span>
{% endif %}
</td>
<td class="text-muted small font-monospace">
{{ entry.ip_address or '—' }}
</td>
<td>
<a href="{{ url_for('audit.view', log_id=entry.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-shield-check fs-2 d-block mb-2"></i>
No audit records match the current filters.
</div>
{% endif %}
</div>
<!-- Pagination -->
{% if logs.pages > 1 %}
<div class="card-footer bg-light d-flex justify-content-center">
<nav>
<ul class="pagination pagination-sm mb-0">
{% if logs.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('audit.index', page=logs.prev_num,
user_id=filter_user, action=filter_action,
entity_type=filter_entity_type,
date_from=filter_date_from, date_to=filter_date_to) }}">
&laquo; Prev
</a>
</li>
{% endif %}
{% for p in logs.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}
<li class="page-item {% if p == logs.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('audit.index', page=p,
user_id=filter_user, action=filter_action,
entity_type=filter_entity_type,
date_from=filter_date_from, date_to=filter_date_to) }}">
{{ p }}
</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
{% if logs.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('audit.index', page=logs.next_num,
user_id=filter_user, action=filter_action,
entity_type=filter_entity_type,
date_from=filter_date_from, date_to=filter_date_to) }}">
Next &raquo;
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div>
{% endblock %}
+111
View File
@@ -0,0 +1,111 @@
{% extends "base.html" %}
{% block title %}Audit Entry #{{ entry.id }}{% endblock %}
{% block content %}
<div class="row mb-4 align-items-center">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1">
<li class="breadcrumb-item">
<a href="{{ url_for('audit.index') }}">Audit Trail</a>
</li>
<li class="breadcrumb-item active">Entry #{{ entry.id }}</li>
</ol>
</nav>
<h2 class="mb-0"><i class="bi bi-shield-check"></i> Audit Entry #{{ entry.id }}</h2>
</div>
<div class="col-auto">
<a href="{{ url_for('audit.index') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Audit Trail
</a>
</div>
</div>
<div class="row g-4">
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-info-circle me-1"></i>Event Details
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4 text-muted">Action</dt>
<dd class="col-sm-8">
<span class="badge fs-6
{% if entry.action == 'CREATE' %}bg-success
{% elif entry.action == 'UPDATE' %}bg-primary
{% elif entry.action == 'DELETE' %}bg-danger
{% elif entry.action in ('LOGIN','LOGOUT') %}bg-secondary
{% elif entry.action == 'EXPORT' %}bg-warning text-dark
{% else %}bg-light text-dark{% endif %}">
{{ entry.action }}
</span>
</dd>
<dt class="col-sm-4 text-muted">Entity Type</dt>
<dd class="col-sm-8">{{ entry.entity_type }}</dd>
<dt class="col-sm-4 text-muted">Entity ID</dt>
<dd class="col-sm-8">
{% if entry.entity_id %}#{{ entry.entity_id }}{% else %}<span class="text-muted"></span>{% endif %}
</dd>
<dt class="col-sm-4 text-muted">Label</dt>
<dd class="col-sm-8">{{ entry.entity_label or '—' }}</dd>
<dt class="col-sm-4 text-muted">Details</dt>
<dd class="col-sm-8">
{% if entry.details %}
<code class="text-break">{{ entry.details }}</code>
{% else %}
<span class="text-muted"></span>
{% endif %}
</dd>
</dl>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-person-circle me-1"></i>Actor &amp; Context
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4 text-muted">Username</dt>
<dd class="col-sm-8">
<strong>{{ entry.username }}</strong>
{% if entry.user_id %}
<span class="text-muted small">(ID #{{ entry.user_id }})</span>
{% else %}
<span class="badge bg-secondary ms-1">Deleted</span>
{% endif %}
</dd>
<dt class="col-sm-4 text-muted">Role at Time</dt>
<dd class="col-sm-8">
<span class="badge bg-{% if entry.user_role == 'admin' %}danger{% elif entry.user_role == 'supervisor' %}warning{% else %}info{% endif %}">
{{ entry.user_role | title }}
</span>
</dd>
<dt class="col-sm-4 text-muted">Timestamp</dt>
<dd class="col-sm-8">
<span class="font-monospace">
{{ entry.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
</span>
<small class="text-muted ms-1">Eastern Time</small>
</dd>
<dt class="col-sm-4 text-muted">IP Address</dt>
<dd class="col-sm-8">
<code>{{ entry.ip_address or '—' }}</code>
</dd>
</dl>
</div>
</div>
</div>
</div>
{% endblock %}
+3
View File
@@ -79,6 +79,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.list_users') }}">Users</a> <a class="nav-link" href="{{ url_for('auth.list_users') }}">Users</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('audit.index') }}">Audit Trail</a>
</li>
{% endif %} {% endif %}
</ul> </ul>
<ul class="navbar-nav align-items-center"> <ul class="navbar-nav align-items-center">
+93
View File
@@ -0,0 +1,93 @@
"""
audit.py
--------
Centralised helper for writing AuditLog entries.
Usage (inside any route after db.session.commit()):
from app.utils.audit import log_action
log_action(
action = 'CREATE',
entity_type = 'Facility',
entity_id = facility.id,
entity_label = facility.name,
details = f'address={facility.address}',
)
``action`` should be one of the ACTION_* constants defined below.
``entity_type`` should match the model class name for consistency.
"""
import logging
from flask import request
from flask_login import current_user
from app import db
from app.models.audit import AuditLog
logger = logging.getLogger(__name__)
# ── Canonical action constants ────────────────────────────────────────────────
ACTION_CREATE = 'CREATE'
ACTION_UPDATE = 'UPDATE'
ACTION_DELETE = 'DELETE'
ACTION_LOGIN = 'LOGIN'
ACTION_LOGOUT = 'LOGOUT'
ACTION_EXPORT = 'EXPORT'
def log_action(action: str,
entity_type: str,
entity_id: int | None = None,
entity_label: str | None = None,
details: str | None = None) -> None:
"""
Write a single AuditLog row. Safe to call from any request context.
Parameters
----------
action : One of the ACTION_* constants (or a custom string 50 chars).
entity_type : Model name 'User', 'Facility', 'Area', 'Template',
'Inspection', 'Issue', etc.
entity_id : Primary key of the affected record (optional).
entity_label : Human-readable name / description snapshot (optional).
details : Extra context string, e.g. 'status=open→resolved' (optional).
"""
try:
# Resolve actor — fall back gracefully if called outside request context
if current_user and current_user.is_authenticated:
uid = current_user.id
uname = current_user.username
urole = current_user.role
else:
uid, uname, urole = None, 'system', 'system'
# Best-effort IP extraction; respects X-Forwarded-For from Nginx
ip = None
try:
ip = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
or request.remote_addr)
except RuntimeError:
pass # outside request context
entry = AuditLog(
user_id = uid,
username = uname,
user_role = urole,
action = action[:50],
entity_type = entity_type[:50],
entity_id = entity_id,
entity_label = (entity_label or '')[:255],
details = details,
ip_address = (ip or '')[:45],
)
db.session.add(entry)
db.session.commit()
except Exception as exc:
# Audit logging must never break the primary request flow
logger.error('AuditLog write failed: %s', exc, exc_info=True)
try:
db.session.rollback()
except Exception:
pass