diff --git a/app/__init__.py b/app/__init__.py index 78ebbbd..e2d1847 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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 diff --git a/app/models/audit.py b/app/models/audit.py new file mode 100644 index 0000000..2c3570e --- /dev/null +++ b/app/models/audit.py @@ -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'') diff --git a/app/routes/audit.py b/app/routes/audit.py new file mode 100644 index 0000000..d2cd11e --- /dev/null +++ b/app/routes/audit.py @@ -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('/') +@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 diff --git a/app/routes/auth.py b/app/routes/auth.py index 8d617d7..8542e22 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -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')) \ No newline at end of file + return redirect(url_for('auth.list_users')) diff --git a/app/routes/facilities.py b/app/routes/facilities.py index ba73c82..b9e2f6e 100644 --- a/app/routes/facilities.py +++ b/app/routes/facilities.py @@ -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)) \ No newline at end of file diff --git a/app/routes/inspections.py b/app/routes/inspections.py index fbc7c1d..dbade61 100644 --- a/app/routes/inspections.py +++ b/app/routes/inspections.py @@ -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}) ' diff --git a/app/routes/issues.py b/app/routes/issues.py index 2676ff1..1c13ad6 100644 --- a/app/routes/issues.py +++ b/app/routes/issues.py @@ -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) diff --git a/app/routes/templates.py b/app/routes/templates.py index 5abcc58..86917ee 100644 --- a/app/routes/templates.py +++ b/app/routes/templates.py @@ -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')) diff --git a/app/templates/audit/index.html b/app/templates/audit/index.html new file mode 100644 index 0000000..9f4d45e --- /dev/null +++ b/app/templates/audit/index.html @@ -0,0 +1,195 @@ +{% extends "base.html" %} + +{% block title %}Audit Trail{% endblock %} + +{% block content %} +
+
+

Audit Trail

+

Complete, immutable log of all system actions.

+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+
+
+
+ + +
+
+ + + {{ 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 %} + Filtered + {% endif %} + + Page {{ logs.page }} of {{ logs.pages }} +
+
+ {% if logs.items %} +
+ + + + + + + + + + + + + + + {% for entry in logs.items %} + + + + + + + + + + + {% endfor %} + +
TimestampUserRoleActionEntityLabelIP Address
+ {{ entry.created_at.strftime('%Y-%m-%d %H:%M:%S') }} + + {{ entry.username }} + + + {{ entry.user_role | title }} + + + + {{ entry.action }} + + {{ entry.entity_type }} + {{ entry.entity_label or '—' }} + {% if entry.entity_id %} + #{{ entry.entity_id }} + {% endif %} + + {{ entry.ip_address or '—' }} + + + + +
+
+ {% else %} +
+ + No audit records match the current filters. +
+ {% endif %} +
+ + + {% if logs.pages > 1 %} + + {% endif %} +
+{% endblock %} diff --git a/app/templates/audit/view.html b/app/templates/audit/view.html new file mode 100644 index 0000000..5553a33 --- /dev/null +++ b/app/templates/audit/view.html @@ -0,0 +1,111 @@ +{% extends "base.html" %} + +{% block title %}Audit Entry #{{ entry.id }}{% endblock %} + +{% block content %} +
+
+ +

Audit Entry #{{ entry.id }}

+
+ +
+ +
+
+
+
+ Event Details +
+
+
+
Action
+
+ + {{ entry.action }} + +
+ +
Entity Type
+
{{ entry.entity_type }}
+ +
Entity ID
+
+ {% if entry.entity_id %}#{{ entry.entity_id }}{% else %}{% endif %} +
+ +
Label
+
{{ entry.entity_label or '—' }}
+ +
Details
+
+ {% if entry.details %} + {{ entry.details }} + {% else %} + + {% endif %} +
+
+
+
+
+ +
+
+
+ Actor & Context +
+
+
+
Username
+
+ {{ entry.username }} + {% if entry.user_id %} + (ID #{{ entry.user_id }}) + {% else %} + Deleted + {% endif %} +
+ +
Role at Time
+
+ + {{ entry.user_role | title }} + +
+ +
Timestamp
+
+ + {{ entry.created_at.strftime('%Y-%m-%d %H:%M:%S') }} + + Eastern Time +
+ +
IP Address
+
+ {{ entry.ip_address or '—' }} +
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index e94c67e..ba930b4 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -79,6 +79,9 @@ + {% endif %}