Skip to content

Audit Trail

The audit trail records every create, update, and delete operation on key domain models. It provides accountability (who changed what, and when), aids debugging of rota-generation issues, and supports compliance requirements.

AuditLog Model

Defined in config/models.py. The audit_trail app itself does not define models -- it provides the signal handlers and middleware that populate AuditLog.

Field Type Description
entity_type CharField(50) Model name that changed (e.g. Shift, Clinician)
entity_id CharField(255) Primary key of the changed instance (string)
action CharField(20, choices) CREATE, UPDATE, or DELETE
user_id CharField(255, nullable) ID of the user who made the change
timestamp DateTimeField (auto) When the change occurred
previous_state JSONField Snapshot before the change (empty for CREATE)
new_state JSONField Snapshot after the change (empty for DELETE)
reason TextField (blank) Optional free-text reason

Records are ordered newest-first (ordering = ["-timestamp"]).

Actions

The AuditAction enum (in config/models.py) defines three values:

Action When previous_state new_state
CREATE After post_save with created=True {} Full instance snapshot
UPDATE After post_save with created=False {} (not captured) Full instance snapshot
DELETE After post_delete Full instance snapshot {}

Note: For UPDATE actions, previous_state is currently empty. The signal handler runs after the save completes, so the old field values are no longer available on the instance. To add field-level diff tracking, a pre-save hook or a package like django-simple-history would be needed.

How It Works

The system uses two mechanisms working together: Django signals for change detection, and middleware for user identification.

Architecture

HTTP Request
  |
  v
AuditMiddleware  -->  stores request.user in thread-local storage
  |
  v
View / Celery task modifies a tracked model
  |
  v
post_save / post_delete signal  -->  reads user from thread-local
  |                                  serializes instance
  |                                  creates AuditLog row
  v
Response

Audited Models

A whitelist in audit_trail/signals.py (AUDITED_MODELS) controls which models are tracked:

  • Clinician
  • WorkingTerm
  • Shift
  • LeaveRequest
  • LocumBooking
  • ManualAdjustment
  • Alert
  • CustomRule

Models not in this list are ignored by the signal handlers.

User Identification

AuditMiddleware (registered in MIDDLEWARE in rota/settings/base.py) stores the authenticated user in thread-local storage at the start of each request. Signal handlers call get_current_user() to retrieve it. When no user is available (e.g. management commands, Celery tasks), user_id is stored as None.

For code that runs outside the request-response cycle, call set_audit_user(user) explicitly before performing operations:

from audit_trail.signals import set_audit_user

set_audit_user(request.user)

Instance Serialization

serialize_instance() converts a model instance to a dictionary using Django's model_to_dict. For certain models, additional denormalized data is included for audit readability:

Model Extra fields
Shift clinician_name, clinician_id
WorkingTerm clinician_name
LeaveRequest clinician_name, type_display
Alert type_display

Before storage, _serialize_state_for_json() converts non-JSON-safe types (dates, datetimes, Decimals, Enums) to strings and floats.

Bulk Deletion

Django's QuerySet.delete() does not fire post_delete signals for individual objects. The log_bulk_deletion() function handles this by manually creating audit entries before the bulk delete runs. It accepts a source parameter (BULK, MANUAL, CLINICIAN, ROTA_GENERATION) and an optional reason string, both stored in the audit record.

Querying

Django Admin

The AuditLogAdmin class (in config/admin.py) provides a read-only admin interface at /django-admin/config/auditlog/. Available filters:

  • Action -- filter by CREATE / UPDATE / DELETE
  • Entity type -- filter by model name
  • Timestamp -- filter by date

Search works on entity_id and user_id. All fields are read-only; audit records cannot be edited through the admin.

Front-end Audit Log View

A user-facing view is available at /admin/audit-log/ (defined in frontend/views/admin/reports.py). This provides:

  • Paginated display (50 entries per page)
  • Quick-filter toggles to hide noisy entity types (Alerts, Shifts)
  • Filter by entity type, action, and user ID
  • Resolved usernames (looks up user_id against the User table)
  • Enriched entity details (e.g. clinician names)

Direct Model Queries

from config.models import AuditLog

# All changes to a specific shift
AuditLog.objects.filter(entity_type="Shift", entity_id="42")

# All actions by a user
AuditLog.objects.filter(user_id="3")

# All deletes in the last 24 hours
from django.utils import timezone
from datetime import timedelta
since = timezone.now() - timedelta(hours=24)
AuditLog.objects.filter(action="DELETE", timestamp__gte=since)

Management Commands

clear_audit_log

Removes all entries from the audit log.

uv run python manage.py clear_audit_log

Prompts for confirmation before deleting. Use --no-confirm to skip the prompt (useful in scripts):

uv run python manage.py clear_audit_log --no-confirm

Reports the number of entries deleted, or warns if the log is already empty.

Configuration

The audit trail has no dedicated settings dictionary. Configuration is split across two locations:

Setting Location Purpose
audit_trail.signals.AuditMiddleware MIDDLEWARE in rota/settings/base.py Captures the current user per request
AUDITED_MODELS list audit_trail/signals.py Whitelist of model class names to track
audit_trail INSTALLED_APPS Registers the app so signals are connected

To add a new model to the audit trail, append its class name to the AUDITED_MODELS list in audit_trail/signals.py.