Files
TimeTracker/app/models/audit_log.py
Dries Peeters 9112a696dd feat: Enhance audit logging with improved error handling and diagnostic tools
- Improve audit logging error messages to distinguish table missing errors from other failures

- Add warning-level logging for audit_logs table missing scenarios with migration guidance

- Update audit event listener with better error detection and logging

- Add comprehensive diagnostic script for checking audit logging setup

- Update UI templates (base.html, admin forms, user settings, profile pages)

- Extend audit logging support across routes (admin, api, permissions, reports, timer, user)

- Add extensive test coverage for admin user management functionality

- Update time tracking service and user model with audit logging integration
2025-12-01 13:30:18 +01:00

252 lines
9.2 KiB
Python

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
) # 'created', 'updated', 'deleted' - index defined in __table_args__
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 defined in __table_args__
# 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"<AuditLog {self.action} {self.entity_type}#{self.entity_id} by user#{self.user_id}>"
@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
# Log at warning level so it's visible if there's a real issue
import logging
logger = logging.getLogger(__name__)
# Check if it's a table doesn't exist error
error_str = str(e).lower()
if "does not exist" in error_str or "no such table" in error_str or "relation" in error_str and "does not exist" in error_str:
logger.warning(f"audit_logs table does not exist - run migration 044_add_audit_logs_table.py. Error: {e}")
else:
logger.warning(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")