Mar 02 2026: implement audit trail
This commit is contained in:
@@ -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 %}
|
||||
Reference in New Issue
Block a user