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 issues # Phase 3
from app.routes import notifications # Notification system
from app.routes import audit # Audit Trail
app.register_blueprint(auth.bp)
app.register_blueprint(dashboard.bp)
@@ -105,6 +106,7 @@ def create_app(config_name='default'):
app.register_blueprint(facilities.bp)
app.register_blueprint(issues.bp)
app.register_blueprint(notifications.bp)
app.register_blueprint(audit.bp)
# ── Error handler: 413 Request Entity Too Large ───────────────────────
# 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.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'))
+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'))
+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">
<a class="nav-link" href="{{ url_for('auth.list_users') }}">Users</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('audit.index') }}">Audit Trail</a>
</li>
{% endif %}
</ul>
<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