Mar 02 2026: implement audit trail
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}>')
|
||||
@@ -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
|
||||
@@ -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'))
|
||||
@@ -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))
|
||||
@@ -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}) '
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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) }}">
|
||||
« 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 »
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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 & 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 %}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user