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
+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 %}