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:
ClinicianWorkingTermShiftLeaveRequestLocumBookingManualAdjustmentAlertCustomRule
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:
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_idagainst 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.
Prompts for confirmation before deleting. Use --no-confirm to skip the prompt (useful in scripts):
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.