Audit history (django-pghistory)¶
Model changes are recorded with django-pghistory. A tracked model gets
Postgres triggers that copy each insert, update or delete into an event table, together with a context
saying who made the change. Because the triggers run in the database, every write path is recorded, including
QuerySet.update(), bulk_create(), the Django admin and the shell, not just Model.save().
How it is wired up¶
pghistoryandpgtriggerare inINSTALLED_APPS(config/settings/base.py).CustomPGHistoryMiddleware(commcare_connect/utils/middleware.py) opens a context for every request and adds the user'susernameanduser_emailto it. They are stored as text so the record still names the user after the user is deleted. Read them fromevent.pgh_context.metadata.- Outside a request there is no context, so events are recorded with no actor. Wherever a change is made on someone's behalf outside a request (Celery tasks, form processing, management commands), wrap it:
with pghistory.context(username=user.username, user_email=user.email):
work_area.save(update_fields=["status"])
Extra keys are allowed and are stored alongside, e.g. reason= in microplanning/helpers.py.
What is tracked¶
| Model | What is recorded | Event model | Relation on the record |
|---|---|---|---|
Organization |
Every field except the BaseModel audit fields; insert, update, delete | OrganizationEvent |
events |
Contact |
Every field except the BaseModel audit fields; insert, update, delete | ContactEvent |
events |
OrganizationCountry, OrganizationPrimarySector (through models) |
Rows added and removed | OrganizationCountryEvent, OrganizationPrimarySectorEvent |
events |
Program |
funder |
ProgramFunderEvent |
funder_events |
ProgramWatcher (through model) |
Rows added and removed | ProgramWatcherEvent |
events |
Opportunity |
active; supervising_organization; api_key |
OpportunityActiveEvent; OpportunitySupervisingOrganizationEvent; OpportunityApiKeyEvent |
active_events; supervising_organization_events; api_key_events |
AssignedTask |
due_date |
AssignedTaskDueDateEvent |
due_date_events |
PaymentInvoice |
status |
PaymentInvoiceStatusEvent |
status_events |
WorkArea |
Status, assignment and expected-visit fields | WorkAreaExpectedVisitCountWorkAreaGroupStatusOpportunityAccessExcludedReasonEvent |
expected_visit_count_work_area_group_status_opportunity_access_excluded_reason_events |
The decorators on the models are the source of truth; check them before relying on this table.
Opportunities created before the api_key tracker was added have no api_key_insert baseline event, so their first rotation's event doesn't record which key it replaced.
Adding tracking to a model¶
- Choose what to record.
- To answer "who changed this, and when" for one or two fields, track only those fields:
@pghistory.track(fields=["status"]). - For a full history of a record, snapshot the whole row but leave out BaseModel's audit fields, which the
event already records as its own timestamp and context:
@pghistory.track(pghistory.InsertEvent(), pghistory.UpdateEvent(), pghistory.DeleteEvent(), exclude=UNTRACKED_MODEL_FIELDS)(seeorganization/models.py). - An update event fires only when a tracked field changes, so leaving out noisy fields keeps the history clean.
- Add
pghistory.DeleteEvent()when the record can be deleted and its final state matters (e.g. organizations removed by a merge). It is not recorded by default. - Mind the names pghistory generates.
- The event model is named after the model and the tracked fields (
OpportunityActiveEvent), and the relation on the record after the fields (active_events). A full snapshot is<Model>Event/events. - So don't add a field to an existing field tracker: it renames the event model and its relation, which code
may refer to. Add a separate tracker instead, as
Opportunitydoes forsupervising_organization. - Tracking several named fields makes a very long name (see
WorkAreaabove). For a new tracker, passmodel_name="…Event"andobj_field=pghistory.ObjForeignKey(related_name="…")to choose short, stable names. - Event labels (
insert,update, …) must be unique among one model's trackers. A second tracker on the same model needs explicit labels, e.g.pghistory.UpdateEvent("supervising_organization_update"). - Run
makemigrations. It creates the event table andAddTriggeroperations. Don't edit the trigger SQL by hand; change the decorator and regenerate. Adding, renaming or removing a field on a tracked model also changes its triggers, so expect them in that migration. - Many-to-many fields need an explicit through model. pghistory can't install triggers on Django's
auto-created through tables. Declare the through model explicitly, point it at the existing table with
Meta.db_table, match the existing unique constraint withunique_together, and track it withpghistory.InsertEvent(), pghistory.DeleteEvent(). Make the migration state-only withSeparateDatabaseAndState, so the existing table and rows are adopted rather than recreated. SeeProgramWatcher(program/migrations/0018_…) andOrganizationCountry(organization/migrations/0024_…). Django admin hides many-to-many fields with an explicit through model, so restore the form field if the admin edits it (OrganizationAdmin.formfield_for_manytomany). - If the event model has a foreign key to
Organization, add it toSKIPPED_RELATIONSinorganization/merge.py.test_merge.pyfails until every relation toOrganizationis classified, and audit rows must keep pointing at the merged-away organization rather than being rewritten. - Expose it in the admin (below) or wherever the history is read.
Reading events¶
- Query through the record's relation (
organization.events,invoice.status_events) or the event model (OrganizationEvent.objects.filter(pgh_obj=organization)), ordered bypgh_idorpgh_created_at. select_related("pgh_context")to show who made each change; the actor is inpgh_context.metadata.- Event foreign keys have no database constraint (
db_constraint=False), so the row an event points at may be gone, which is the point of an audit trail. Don'tselect_related()them: they aren't nullable, so Django uses an INNER JOIN and silently drops every event whose record was deleted. Useprefetch_related(), and expect a missing related object (ObjectDoesNotExist) when displaying one. - Each event is a snapshot of the tracked fields after the change, not a diff. To show what changed, compare an
event with the previous one for the same record (
changed_fields_summaryinutils/admin.py).
Admin¶
- Subclass
AuditEventAdmin(utils/admin.py). It makes events read-only, orders newest first, and addschanged_byandchanged_by_emailcolumns read from the context. - Use
audited_object("pgh_obj", _("…"))for a column that names the record, so a deleted record shows a dash instead of an error. - Load related records with
prefetch_related()inget_queryset(), asOrganizationAuditEventAdmindoes. - Add
ChangedFieldsMixinto a full-snapshot event admin to show a "Changes" field on each event's page.
Testing¶
- Tests run the real migrations, so the triggers exist and events are written as in production.
- Assert on the record's relation, e.g.
organization.events.latest("pgh_id").pgh_label == "update". - To test the actor, wrap the change in
pghistory.context(username=..., user_email=...), or go through a view so the middleware sets it.