From 350d7105a22d0369647f326948354473dd69d98d Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Thu, 13 Nov 2025 08:08:48 +0100 Subject: [PATCH] feat: Add comprehensive audit trail/history tracking system Implement a complete audit logging system to track all changes made to tracked entities, providing full compliance and accountability capabilities. Features: - Automatic tracking of create, update, and delete operations on 25+ models - Detailed field-level change tracking with old/new value comparison - User attribution with IP address, user agent, and request path logging - Web UI for viewing and filtering audit logs with pagination - REST API endpoints for programmatic access - Entity-specific history views - Comprehensive test coverage (unit, model, route, and smoke tests) Core Components: - AuditLog model with JSON-encoded value storage and decoding helpers - SQLAlchemy event listeners for automatic change detection - Audit utility module with defensive programming for table existence checks - Blueprint routes for audit log viewing and API access - Jinja2 templates for audit log list, detail, and entity history views - Database migration (044) creating audit_logs table with proper indexes Technical Implementation: - Uses SQLAlchemy 'after_flush' event listener to capture changes - Tracks 25+ models including Projects, Tasks, TimeEntries, Invoices, Clients, Users, etc. - Excludes sensitive fields (passwords) and system fields (id, timestamps) - Implements lazy import pattern to avoid circular dependencies - Graceful error handling to prevent audit logging from breaking core functionality - Transaction-safe logging that integrates with main application transactions Fixes: - Resolved login errors caused by premature transaction commits - Fixed circular import issues with lazy model loading - Added table existence checks to prevent errors before migrations - Improved error handling with debug-level logging for non-critical failures UI/UX: - Added "Audit Logs" link to admin dropdown menu - Organized admin menu into logical sections for better usability - Filterable audit log views by entity type, user, action, and date range - Color-coded action badges and side-by-side old/new value display - Pagination support for large audit log datasets Documentation: - Added comprehensive feature documentation - Included troubleshooting guide and data examples - Created diagnostic scripts for verifying audit log setup Testing: - Unit tests for AuditLog model and value encoding/decoding - Route tests for all audit log endpoints - Integration tests for audit logging functionality - Smoke tests for end-to-end audit trail verification This implementation provides a robust foundation for compliance tracking and change accountability without impacting application performance or requiring code changes in existing routes/models. --- app/__init__.py | 13 + app/models/__init__.py | 2 + app/models/audit_log.py | 228 ++++++++++++ app/routes/audit_logs.py | 261 ++++++++++++++ app/templates/audit_logs/entity_history.html | 127 +++++++ app/templates/audit_logs/list.html | 180 ++++++++++ app/templates/audit_logs/view.html | 139 ++++++++ app/templates/base.html | 40 ++- app/utils/audit.py | 336 ++++++++++++++++++ .../versions/044_add_audit_logs_table.py | 65 ++++ scripts/check_audit_logs.py | 77 ++++ scripts/test_audit_routes.py | 63 ++++ scripts/verify_audit_setup.py | 127 +++++++ tests/test_audit_log_model.py | 230 ++++++++++++ tests/test_audit_log_routes.py | 178 ++++++++++ tests/test_audit_logging.py | 143 ++++++++ tests/test_audit_trail_smoke.py | 188 ++++++++++ 17 files changed, 2388 insertions(+), 9 deletions(-) create mode 100644 app/models/audit_log.py create mode 100644 app/routes/audit_logs.py create mode 100644 app/templates/audit_logs/entity_history.html create mode 100644 app/templates/audit_logs/list.html create mode 100644 app/templates/audit_logs/view.html create mode 100644 app/utils/audit.py create mode 100644 migrations/versions/044_add_audit_logs_table.py create mode 100644 scripts/check_audit_logs.py create mode 100644 scripts/test_audit_routes.py create mode 100644 scripts/verify_audit_setup.py create mode 100644 tests/test_audit_log_model.py create mode 100644 tests/test_audit_log_routes.py create mode 100644 tests/test_audit_logging.py create mode 100644 tests/test_audit_trail_smoke.py diff --git a/app/__init__.py b/app/__init__.py index 4e4ae22..4cc2220 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -743,6 +743,9 @@ def create_app(config=None): pass return resp + # Initialize audit logging (import to register event listeners) + from app.utils import audit # noqa: F401 + # Register blueprints from app.routes.auth import auth_bp from app.routes.main import main_bp @@ -775,6 +778,15 @@ def create_app(config=None): from app.routes.per_diem import per_diem_bp from app.routes.budget_alerts import budget_alerts_bp from app.routes.import_export import import_export_bp + try: + from app.routes.audit_logs import audit_logs_bp + app.register_blueprint(audit_logs_bp) + except Exception as e: + # Log error but don't fail app startup + import logging + logger = logging.getLogger(__name__) + logger.warning(f"Could not register audit_logs blueprint: {e}") + # Try to continue without audit logs if there's an issue app.register_blueprint(auth_bp) app.register_blueprint(main_bp) @@ -808,6 +820,7 @@ def create_app(config=None): app.register_blueprint(per_diem_bp) app.register_blueprint(budget_alerts_bp) app.register_blueprint(import_export_bp) + # audit_logs_bp is registered above with error handling # Exempt API blueprints from CSRF protection (JSON API uses token authentication, not CSRF tokens) # Only if CSRF is enabled diff --git a/app/models/__init__.py b/app/models/__init__.py index 23dac80..a1d1c54 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -35,6 +35,7 @@ from .calendar_event import CalendarEvent from .budget_alert import BudgetAlert from .import_export import DataImport, DataExport from .invoice_pdf_template import InvoicePDFTemplate +from .audit_log import AuditLog __all__ = [ "User", @@ -77,4 +78,5 @@ __all__ = [ "DataExport", "InvoicePDFTemplate", "ClientPrepaidConsumption", + "AuditLog", ] diff --git a/app/models/audit_log.py b/app/models/audit_log.py new file mode 100644 index 0000000..c27e4b6 --- /dev/null +++ b/app/models/audit_log.py @@ -0,0 +1,228 @@ +from datetime import datetime +from app import db +from app.utils.timezone import now_in_app_timezone +import json + + +class AuditLog(db.Model): + """Audit log model for tracking detailed changes to entities + + Provides comprehensive audit trail tracking: + - Who made the change (user_id) + - What entity was changed (entity_type, entity_id) + - When the change occurred (created_at) + - What changed (field_name, old_value, new_value) + - Action type (created, updated, deleted) + - Additional context (ip_address, user_agent, request_path) + """ + + __tablename__ = 'audit_logs' + + id = db.Column(db.Integer, primary_key=True) + + # User who made the change + user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True) + + # Entity being changed + entity_type = db.Column(db.String(50), nullable=False, index=True) # 'project', 'task', 'time_entry', 'invoice', 'client', 'user', etc. + entity_id = db.Column(db.Integer, nullable=False, index=True) + entity_name = db.Column(db.String(500), nullable=True) # Cached name for display + + # Action details + action = db.Column(db.String(20), nullable=False, index=True) # 'created', 'updated', 'deleted' + field_name = db.Column(db.String(100), nullable=True, index=True) # Name of the field that changed (None for create/delete) + + # Change values (stored as JSON for flexibility) + old_value = db.Column(db.Text, nullable=True) # JSON-encoded old value + new_value = db.Column(db.Text, nullable=True) # JSON-encoded new value + + # Human-readable change description + change_description = db.Column(db.Text, nullable=True) + + # Additional context + ip_address = db.Column(db.String(45), nullable=True) + user_agent = db.Column(db.Text, nullable=True) + request_path = db.Column(db.String(500), nullable=True) + + # Timestamp + created_at = db.Column(db.DateTime, default=now_in_app_timezone, nullable=False, index=True) + + # Relationships + user = db.relationship('User', backref='audit_logs') + + # Indexes for common queries + __table_args__ = ( + db.Index('ix_audit_logs_entity', 'entity_type', 'entity_id'), + db.Index('ix_audit_logs_user_created', 'user_id', 'created_at'), + db.Index('ix_audit_logs_created_at', 'created_at'), + db.Index('ix_audit_logs_action', 'action'), + ) + + def __repr__(self): + return f'' + + @classmethod + def log_change(cls, user_id, action, entity_type, entity_id, field_name=None, + old_value=None, new_value=None, entity_name=None, + change_description=None, ip_address=None, user_agent=None, + request_path=None): + """Log a change to the audit trail + + Args: + user_id: ID of the user making the change (None for system actions) + action: 'created', 'updated', or 'deleted' + entity_type: Type of entity (e.g., 'project', 'task', 'time_entry') + entity_id: ID of the entity being changed + field_name: Name of the field that changed (None for create/delete actions) + old_value: Previous value (will be JSON-encoded) + new_value: New value (will be JSON-encoded) + entity_name: Cached name of the entity for display + change_description: Human-readable description of the change + ip_address: IP address of the request + user_agent: User agent string + request_path: Path of the request that triggered the change + """ + # Encode values as JSON if they're not already strings + old_val_str = cls._encode_value(old_value) + new_val_str = cls._encode_value(new_value) + + audit_log = cls( + user_id=user_id, + action=action, + entity_type=entity_type, + entity_id=entity_id, + field_name=field_name, + old_value=old_val_str, + new_value=new_val_str, + entity_name=entity_name, + change_description=change_description, + ip_address=ip_address, + user_agent=user_agent, + request_path=request_path + ) + + try: + # Add to session - don't commit here as we're likely in the middle of a transaction + # The main transaction will commit everything together + db.session.add(audit_log) + # Flush to ensure the audit log is part of the current transaction + # but don't commit - let the main transaction handle that + db.session.flush() + except Exception as e: + # Don't rollback - that would rollback the entire transaction including the main operation! + # Just remove the audit log from the session and continue + try: + db.session.expunge(audit_log) + except Exception: + pass + # Don't let audit logging break the main flow + # Use debug level to avoid cluttering logs with expected errors + # (e.g., when audit_logs table doesn't exist yet) + import logging + logger = logging.getLogger(__name__) + logger.debug(f"Failed to log audit change (non-critical): {e}") + + @staticmethod + def _encode_value(value): + """Encode a value as JSON string, handling None and special types""" + if value is None: + return None + + # Handle datetime objects + if isinstance(value, datetime): + return value.isoformat() + + # Handle Decimal and other types that aren't JSON serializable + try: + return json.dumps(value, default=str) + except (TypeError, ValueError): + return str(value) + + @staticmethod + def _decode_value(value_str): + """Decode a JSON string back to a Python value""" + if value_str is None: + return None + + try: + return json.loads(value_str) + except (json.JSONDecodeError, TypeError): + # If it's not valid JSON, return as string + return value_str + + def get_old_value(self): + """Get the decoded old value""" + return self._decode_value(self.old_value) + + def get_new_value(self): + """Get the decoded new value""" + return self._decode_value(self.new_value) + + @classmethod + def get_for_entity(cls, entity_type, entity_id, limit=100): + """Get audit logs for a specific entity""" + return cls.query.filter_by( + entity_type=entity_type, + entity_id=entity_id + ).order_by(cls.created_at.desc()).limit(limit).all() + + @classmethod + def get_for_user(cls, user_id, limit=100): + """Get audit logs for actions by a specific user""" + return cls.query.filter_by(user_id=user_id).order_by(cls.created_at.desc()).limit(limit).all() + + @classmethod + def get_recent(cls, limit=100, entity_type=None, user_id=None, action=None): + """Get recent audit logs with optional filters""" + query = cls.query + + if entity_type: + query = query.filter_by(entity_type=entity_type) + + if user_id: + query = query.filter_by(user_id=user_id) + + if action: + query = query.filter_by(action=action) + + return query.order_by(cls.created_at.desc()).limit(limit).all() + + def to_dict(self): + """Convert to dictionary for API responses""" + return { + 'id': self.id, + 'user_id': self.user_id, + 'username': self.user.username if self.user else None, + 'display_name': self.user.display_name if self.user else None, + 'entity_type': self.entity_type, + 'entity_id': self.entity_id, + 'entity_name': self.entity_name, + 'action': self.action, + 'field_name': self.field_name, + 'old_value': self.get_old_value(), + 'new_value': self.get_new_value(), + 'change_description': self.change_description, + 'ip_address': self.ip_address, + 'user_agent': self.user_agent, + 'request_path': self.request_path, + 'created_at': self.created_at.isoformat() if self.created_at else None, + } + + def get_icon(self): + """Get icon class for this audit log action""" + icons = { + 'created': 'fas fa-plus-circle text-green-500', + 'updated': 'fas fa-edit text-blue-500', + 'deleted': 'fas fa-trash text-red-500', + } + return icons.get(self.action, 'fas fa-circle text-gray-500') + + def get_color(self): + """Get color class for this audit log action""" + colors = { + 'created': 'green', + 'updated': 'blue', + 'deleted': 'red', + } + return colors.get(self.action, 'gray') + diff --git a/app/routes/audit_logs.py b/app/routes/audit_logs.py new file mode 100644 index 0000000..402f1d6 --- /dev/null +++ b/app/routes/audit_logs.py @@ -0,0 +1,261 @@ +from flask import Blueprint, render_template, request, jsonify, abort +from flask_babel import gettext as _ +from flask_login import login_required, current_user +from app import db +from app.models.audit_log import AuditLog +from app.models import User +from app.utils.permissions import admin_or_permission_required +from app.utils.audit import check_audit_table_exists, reset_audit_table_cache +from sqlalchemy import inspect as sqlalchemy_inspect +from datetime import datetime, timedelta + +audit_logs_bp = Blueprint('audit_logs', __name__) + + +@audit_logs_bp.route('/audit-logs') +@login_required +@admin_or_permission_required('view_audit_logs') +def list_audit_logs(): + """List audit logs with filtering options""" + # Check if table exists first + reset_audit_table_cache() + if not check_audit_table_exists(force_check=True): + from flask import flash + flash(_('Audit logs table does not exist. Please run: flask db upgrade'), 'warning') + return render_template('audit_logs/list.html', + audit_logs=[], + pagination=None, + entity_type='', + entity_id=None, + user_id=None, + action='', + days=30, + entity_types=[], + users=[], + ) + + page = request.args.get('page', 1, type=int) + entity_type = request.args.get('entity_type', '').strip() + entity_id = request.args.get('entity_id', type=int) + user_id = request.args.get('user_id', type=int) + action = request.args.get('action', '').strip() + days = request.args.get('days', 30, type=int) + + # Build query + query = AuditLog.query + + # Filter by entity type + if entity_type: + query = query.filter_by(entity_type=entity_type) + + # Filter by entity ID + if entity_id: + query = query.filter_by(entity_id=entity_id) + + # Filter by user + if user_id: + query = query.filter_by(user_id=user_id) + + # Filter by action + if action: + query = query.filter_by(action=action) + + # Filter by date range + if days: + cutoff_date = datetime.utcnow() - timedelta(days=days) + query = query.filter(AuditLog.created_at >= cutoff_date) + + # Order by most recent first + query = query.order_by(AuditLog.created_at.desc()) + + # Paginate + pagination = query.paginate( + page=page, + per_page=50, + error_out=False + ) + + # Get unique entity types for filter dropdown + try: + entity_types = db.session.query(AuditLog.entity_type).distinct().all() + entity_types = [et[0] for et in entity_types] + entity_types.sort() + except Exception: + # Table might not exist yet + entity_types = [] + + # Get users for filter dropdown + try: + users_with_logs = db.session.query(User).join(AuditLog).distinct().all() + except Exception: + # Table might not exist yet or no logs yet + users_with_logs = [] + + return render_template( + 'audit_logs/list.html', + audit_logs=pagination.items, + pagination=pagination, + entity_type=entity_type, + entity_id=entity_id, + user_id=user_id, + action=action, + days=days, + entity_types=entity_types, + users=users_with_logs, + ) + + +@audit_logs_bp.route('/audit-logs/') +@login_required +@admin_or_permission_required('view_audit_logs') +def view_audit_log(log_id): + """View details of a specific audit log entry""" + audit_log = AuditLog.query.get_or_404(log_id) + + return render_template( + 'audit_logs/view.html', + audit_log=audit_log, + ) + + +@audit_logs_bp.route('/audit-logs/entity//') +@login_required +@admin_or_permission_required('view_audit_logs') +def entity_history(entity_type, entity_id): + """View audit history for a specific entity""" + page = request.args.get('page', 1, type=int) + + # Get audit logs for this entity + query = AuditLog.query.filter_by( + entity_type=entity_type, + entity_id=entity_id + ).order_by(AuditLog.created_at.desc()) + + pagination = query.paginate( + page=page, + per_page=50, + error_out=False + ) + + # Try to get the entity name + entity_name = None + try: + # Import models dynamically + from app.models import ( + Project, Task, TimeEntry, Invoice, Client, User, Expense, + Payment, Comment, ProjectCost, KanbanColumn, TimeEntryTemplate, + ClientNote, WeeklyTimeGoal, CalendarEvent, BudgetAlert + ) + + model_map = { + 'Project': Project, + 'Task': Task, + 'TimeEntry': TimeEntry, + 'Invoice': Invoice, + 'Client': Client, + 'User': User, + 'Expense': Expense, + 'Payment': Payment, + 'Comment': Comment, + 'ProjectCost': ProjectCost, + 'KanbanColumn': KanbanColumn, + 'TimeEntryTemplate': TimeEntryTemplate, + 'ClientNote': ClientNote, + 'WeeklyTimeGoal': WeeklyTimeGoal, + 'CalendarEvent': CalendarEvent, + 'BudgetAlert': BudgetAlert, + } + + model_class = model_map.get(entity_type) + if model_class: + entity = model_class.query.get(entity_id) + if entity: + entity_name = getattr(entity, 'name', None) or \ + getattr(entity, 'title', None) or \ + getattr(entity, 'username', None) or \ + str(entity) + except Exception: + pass + + return render_template( + 'audit_logs/entity_history.html', + audit_logs=pagination.items, + pagination=pagination, + entity_type=entity_type, + entity_id=entity_id, + entity_name=entity_name, + ) + + +@audit_logs_bp.route('/api/audit-logs') +@login_required +@admin_or_permission_required('view_audit_logs') +def api_audit_logs(): + """API endpoint for audit logs (JSON)""" + page = request.args.get('page', 1, type=int) + entity_type = request.args.get('entity_type', '').strip() + entity_id = request.args.get('entity_id', type=int) + user_id = request.args.get('user_id', type=int) + action = request.args.get('action', '').strip() + limit = request.args.get('limit', 100, type=int) + + query = AuditLog.query + + if entity_type: + query = query.filter_by(entity_type=entity_type) + if entity_id: + query = query.filter_by(entity_id=entity_id) + if user_id: + query = query.filter_by(user_id=user_id) + if action: + query = query.filter_by(action=action) + + query = query.order_by(AuditLog.created_at.desc()).limit(limit) + + audit_logs = query.all() + + return jsonify({ + 'audit_logs': [log.to_dict() for log in audit_logs], + 'count': len(audit_logs) + }) + + +@audit_logs_bp.route('/api/audit-logs/status') +@login_required +@admin_or_permission_required('view_audit_logs') +def audit_logs_status(): + """Check audit logs table status and reset cache if needed""" + try: + # Force check table existence + reset_audit_table_cache() + table_exists = check_audit_table_exists(force_check=True) + + status = { + 'table_exists': table_exists, + 'enabled': table_exists + } + + if table_exists: + try: + count = AuditLog.query.count() + status['total_logs'] = count + + # Check recent activity + recent = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(5).all() + status['recent_logs'] = [log.to_dict() for log in recent] + except Exception as e: + status['error'] = str(e) + else: + # Check what tables do exist + try: + inspector = sqlalchemy_inspect(db.engine) + tables = inspector.get_table_names() + status['available_tables'] = sorted(tables) + status['message'] = 'audit_logs table does not exist. Run: flask db upgrade' + except Exception as e: + status['error'] = f"Could not check tables: {e}" + + return jsonify(status) + except Exception as e: + return jsonify({'error': str(e)}), 500 + diff --git a/app/templates/audit_logs/entity_history.html b/app/templates/audit_logs/entity_history.html new file mode 100644 index 0000000..7d92876 --- /dev/null +++ b/app/templates/audit_logs/entity_history.html @@ -0,0 +1,127 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header, breadcrumb_nav, badge %} + +{% block title %}History: {{ entity_type }}#{{ entity_id }} - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +{% set breadcrumbs = [ + {'text': 'Audit Logs', 'url': url_for('audit_logs.list_audit_logs')}, + {'text': entity_type + '#' + entity_id|string} +] %} + +{{ page_header( + icon_class='fas fa-history', + title_text='Change History', + subtitle_text=entity_name or (entity_type + ' #' + entity_id|string), + breadcrumbs=breadcrumbs +) }} + +
+ {% if audit_logs %} +
+ + + + + + + + + + + + + {% for log in audit_logs %} + + + + + + + + + {% endfor %} + +
TimestampUserActionFieldChangeActions
+ {{ log.created_at|user_datetime('%Y-%m-%d %H:%M') }} + + {% if log.user %} + {{ log.user.display_name }} + {% else %} + System + {% endif %} + + {{ badge(log.action, log.get_color()) }} + + {% if log.field_name %} + {{ log.field_name }} + {% else %} + + {% endif %} + + {% if log.field_name %} +
+ {% if log.old_value %} +
+ - + {{ log.get_old_value() }} +
+ {% endif %} + {% if log.new_value %} +
+ + + {{ log.get_new_value() }} +
+ {% endif %} +
+ {% else %} + {{ log.change_description or '—' }} + {% endif %} +
+ + View + +
+
+ + + {% if pagination.pages > 1 %} +
+
+ Showing {{ (pagination.page - 1) * pagination.per_page + 1 }} to + {{ pagination.page * pagination.per_page if pagination.page * pagination.per_page < pagination.total else pagination.total }} + of {{ pagination.total }} results +
+
+ {% if pagination.has_prev %} + + Previous + + {% endif %} + {% if pagination.has_next %} + + Next + + {% endif %} +
+
+ {% endif %} + {% else %} +
+ +

No change history found for this entity.

+
+ {% endif %} +
+ + +{% endblock %} + diff --git a/app/templates/audit_logs/list.html b/app/templates/audit_logs/list.html new file mode 100644 index 0000000..ff39d45 --- /dev/null +++ b/app/templates/audit_logs/list.html @@ -0,0 +1,180 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header, breadcrumb_nav, badge %} + +{% block title %}Audit Logs - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +{% set breadcrumbs = [ + {'text': 'Audit Logs'} +] %} + +{{ page_header( + icon_class='fas fa-history', + title_text='Audit Logs', + subtitle_text='Track who changed what and when', + breadcrumbs=breadcrumbs +) }} + +
+

Filters

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ Clear + +
+
+
+ +
+ {% if audit_logs %} +
+ + + + + + + + + + + + + + {% for log in audit_logs %} + + + + + + + + + + {% endfor %} + +
TimestampUserActionEntityFieldChangeActions
+ {{ log.created_at|user_datetime('%Y-%m-%d %H:%M') }} + + {% if log.user %} + {{ log.user.display_name }} + {% else %} + System + {% endif %} + + {{ badge(log.action, log.get_color()) }} + + + {{ log.entity_type }}#{{ log.entity_id }} + + {% if log.entity_name %} +
{{ log.entity_name }} + {% endif %} +
+ {% if log.field_name %} + {{ log.field_name }} + {% else %} + + {% endif %} + + {% if log.field_name %} +
+ {% if log.old_value %} +
+ - + {{ log.get_old_value() }} +
+ {% endif %} + {% if log.new_value %} +
+ + + {{ log.get_new_value() }} +
+ {% endif %} +
+ {% else %} + {{ log.change_description or '—' }} + {% endif %} +
+ + View + +
+
+ + + {% if pagination and pagination.pages > 1 %} +
+
+ Showing {{ (pagination.page - 1) * pagination.per_page + 1 }} to + {{ pagination.page * pagination.per_page if pagination.page * pagination.per_page < pagination.total else pagination.total }} + of {{ pagination.total }} results +
+
+ {% if pagination.has_prev %} + + Previous + + {% endif %} + {% if pagination.has_next %} + + Next + + {% endif %} +
+
+ {% endif %} + {% else %} +
+ +

No audit logs found matching your filters.

+
+ {% endif %} +
+{% endblock %} + diff --git a/app/templates/audit_logs/view.html b/app/templates/audit_logs/view.html new file mode 100644 index 0000000..097222c --- /dev/null +++ b/app/templates/audit_logs/view.html @@ -0,0 +1,139 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header, breadcrumb_nav, badge %} + +{% block title %}Audit Log Details - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +{% set breadcrumbs = [ + {'text': 'Audit Logs', 'url': url_for('audit_logs.list_audit_logs')}, + {'text': 'Details'} +] %} + +{{ page_header( + icon_class='fas fa-history', + title_text='Audit Log Details', + subtitle_text='Detailed information about this change', + breadcrumbs=breadcrumbs +) }} + +
+ +
+

Change Information

+
+
+
Timestamp
+
+ {{ audit_log.created_at|user_datetime('%Y-%m-%d %H:%M:%S') }} +
+
+
+
User
+
+ {% if audit_log.user %} + {{ audit_log.user.display_name }} ({{ audit_log.user.username }}) + {% else %} + System + {% endif %} +
+
+
+
Action
+
+ {{ badge(audit_log.action, audit_log.get_color()) }} +
+
+
+
Entity
+
+ + {{ audit_log.entity_type }}#{{ audit_log.entity_id }} + + {% if audit_log.entity_name %} +
{{ audit_log.entity_name }} + {% endif %} +
+
+ {% if audit_log.field_name %} +
+
Field
+
+ {{ audit_log.field_name }} +
+
+ {% endif %} + {% if audit_log.change_description %} +
+
Description
+
+ {{ audit_log.change_description }} +
+
+ {% endif %} +
+
+ + + {% if audit_log.field_name %} +
+

Change Details

+
+ {% if audit_log.old_value %} +
+
Old Value
+
+
{{ audit_log.get_old_value() }}
+
+
+ {% endif %} + {% if audit_log.new_value %} +
+
New Value
+
+
{{ audit_log.get_new_value() }}
+
+
+ {% endif %} +
+
+ {% endif %} + + +
+

Request Information

+
+ {% if audit_log.ip_address %} +
+
IP Address
+
{{ audit_log.ip_address }}
+
+ {% endif %} + {% if audit_log.request_path %} +
+
Request Path
+
{{ audit_log.request_path }}
+
+ {% endif %} + {% if audit_log.user_agent %} +
+
User Agent
+
{{ audit_log.user_agent }}
+
+ {% endif %} +
+
+
+ + +{% endblock %} + diff --git a/app/templates/base.html b/app/templates/base.html index e582c64..2034795 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -165,7 +165,7 @@ {% set finance_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('payments.') or ep.startswith('expenses.') or ep.startswith('budget_alerts.') or ep.startswith('mileage.') or (ep.startswith('per_diem.') and not ep.startswith('per_diem.list_rates')) %} {% set analytics_open = ep.startswith('analytics.') %} {% set tools_open = ep.startswith('import_export.') or ep.startswith('saved_filters.') %} - {% set admin_open = ep.startswith('admin.') or ep.startswith('permissions.') or (ep.startswith('expense_categories.') and current_user.is_admin) or (ep.startswith('per_diem.list_rates') and current_user.is_admin) or ep.startswith('time_entry_templates.') %} + {% set admin_open = ep.startswith('admin.') or ep.startswith('permissions.') or (ep.startswith('expense_categories.') and current_user.is_admin) or (ep.startswith('per_diem.list_rates') and current_user.is_admin) or ep.startswith('time_entry_templates.') or ep.startswith('audit_logs.') %}