Mar 02 2026: implement audit trail
This commit is contained in:
@@ -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
|
||||
+12
-1
@@ -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'))
|
||||
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'))
|
||||
|
||||
Reference in New Issue
Block a user