History and Auditing
Pengin-Pi-3 tracks edits with a paired-model pattern: each tracked model gets a companion History model that stores a full JSON snapshot of the object's field values as they stood before each change, with user attribution and a timestamp.
It is a revert log, not a diff log. Each entry holds the complete prior state, so restoring an object means reading one row.
The two pieces
HistoryMixin goes on the tracked model and provides one method,
save_history(user).
AbstractHistory is the base for the companion model. It provides the
snapshot JSONField, the changed_at timestamp, default ordering, and
get_snapshot().
Neither knows about the other by class. They find each other by field name, which makes two names load-bearing:
- The companion's FK to the tracked model must be called
objectand userelated_name="history" save_history()resolves the companion viaself._meta.get_field('history').related_model
Name them anything else and it breaks at runtime, not import time.
Adding it to a model
The full pattern. Both classes, every time:
import uuid
from django.db import models
from django.conf import settings
from main.models.mixins import HistoryMixin, AbstractHistory
class Report(HistoryMixin, models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
title = models.CharField(max_length=200)
body = models.TextField(blank=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.title
class ReportHistory(AbstractHistory):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
object = models.ForeignKey(
Report,
on_delete=models.CASCADE,
related_name="history",
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="+",
)
class Meta(AbstractHistory.Meta):
verbose_name_plural = "Report Histories"
def __str__(self):
return f"Report {self.object_id} @ {self.changed_at}"
Things that matter in that block:
HistoryMixincomes first in the bases, beforemodels.Model.class Meta(AbstractHistory.Meta)— inherit it, don't redeclare. That is whereordering = ['-changed_at']lives, and a bareclass Metathrows it away.related_name="+"on the user FK. Every history model points at the user model. Without"+", they collide on the reverse accessor.on_delete=models.CASCADEonobject— history dies with the object.on_delete=models.SET_NULLonuser— deleting a user must not erase the history of what they did.- The
idfield is redeclared becauseAbstractHistorydoesn't define one and the project uses UUID keys throughout.
If your model lives outside main/models/, add app_label = 'main' to the
Meta — UserHistory does this. Everything shares the one app label and the
one migrations directory.
Then make the migration as normal:
python manage.py makemigrations
python manage.py migrate
Calling it
Call save_history() before you save the change.
def post(self, request, pk):
report = get_object_or_404(Report, pk=pk)
form = ReportForm(request.POST, instance=report)
if form.is_valid():
report.save_history(user=request.user) # ← before
report = form.save(commit=False)
report.save()
return redirect("report_edit", pk=report.pk)
The method snapshots the pre-change state. Calling it after the save records the new values as history, which silently makes the log useless — every entry matches the current object.
Why it re-fetches from the database
save_history() does not read self. It re-queries the row by primary key
and snapshots that.
This is deliberate and it protects you from a subtle Django behavior. When a
ModelForm validates, _post_clean() applies cleaned_data onto the bound
instance — during is_valid(), well before .save() runs. So by the
time most call sites reach save_history(), self already holds the new
values. Trusting self would snapshot the change you're trying to record the
state before.
Re-fetching makes the call correct wherever it lands relative to form
processing, as long as it's before the real .save().
If the object isn't persisted yet, there's nothing to read back and it falls through to the in-memory instance. Creates generally shouldn't call it at all — there is no prior state.
The user argument is required
It is the user making the change, not the object's owner. In a view that's
request.user. Some flows pass the affected user instead — self-service
password reset does user.save_history(user=user) — which is correct there
because the user is acting on themselves.
What gets snapshotted
save_history() walks _meta.fields and stores every field except:
- the primary key — nothing meaningful to revert
- any field with
editable=False— which coversauto_nowandauto_now_addtimestamps - many-to-many fields, which
_meta.fieldsexcludes automatically
That last one is worth reading twice. M2M relationships are not
tracked. An Event's roles assignments, a user's groups — none of it
appears in a snapshot, and a revert built from one will not restore them. If
your model's M2M relationships matter for auditing, you need to handle them
yourself.
Foreign keys are stored as raw id values, not objects. UUIDs serialize
through UUIDEncoder on the JSONField.
File and image fields store the stored name or key as a string —
FieldFile isn't JSON-serializable. The snapshot records where the file was,
not the file itself. Reverting restores the reference; if the underlying file
was replaced or deleted, the reference points at nothing.
Encrypted fields
The core has no encrypted-field implementation — there's nothing in it worth encrypting — but the history layer handles one correctly if an app branch adds one.
The contract is duck-typed, not inherited. Any field class defining both of these methods is treated as encrypted-at-rest:
encrypt_for_snapshot(value) # plaintext → ciphertext-safe form
decrypt_from_snapshot(value) # the reverse
No base class to import, no isinstance check. Forcing an app to import from
core just to satisfy a type check would be backwards for a CMS whose apps
extend core rather than the other way round.
Why the re-encryption step exists
The ORM fetch inside save_history() runs each field's from_db_value(), so
by the time the snapshot loop sees the value it is decrypted plaintext —
a real SSN, say. Writing that straight into the JSON snapshot would park
plaintext PII in the history table forever, defeating the point of encrypting
the column. So it re-encrypts before storing.
get_snapshot(), not .snapshot
Always read history values through get_snapshot().
entry = report.history.first()
values = entry.get_snapshot() # ← correct
values = entry.snapshot # ← raw; encrypted fields are ciphertext
.snapshot is the raw JSON. For an encrypted field it holds ciphertext.
Assigning that to the live field and saving re-encrypts it, producing
double-encrypted garbage — a "revert" that silently destroys the value
rather than restoring it.
get_snapshot() returns the same dict with encrypted fields decrypted back
to real values. On a model with no encrypted fields the two are identical,
which is exactly why the habit matters: code written against .snapshot
works fine until someone adds an encrypted field, then corrupts data.
Reading and reverting
History is available through the reverse accessor, newest first by default ordering:
report.history.all()
report.history.first() # most recent prior state
report.history.count()
A revert, using the safe reader:
def revert(report, entry, user):
report.save_history(user=user) # the revert is itself a change
for name, value in entry.get_snapshot().items():
setattr(report, name, value)
report.save()
Two notes. Snapshotting before reverting means the revert is undoable.
And FK fields come back as ids — assign to field_id, or let Django's
attribute handling take the id, depending on how your field names line up.
There is no revert UI in the core. History is recorded and readable; restoring is something you build.
Admin integration
Two parts: an inline to display history, and a save_model() override to
record it.
class ReportHistoryInline(admin.TabularInline):
model = ReportHistory
fk_name = "object"
extra = 0
readonly_fields = ("changed_at", "user", "snapshot")
can_delete = False
ordering = ("-changed_at",)
@admin.register(Report)
class ReportAdmin(admin.ModelAdmin):
inlines = [ReportHistoryInline]
def save_model(self, request, obj, form, change):
if change:
obj.save_history(user=request.user)
super().save_model(request, obj, form, change)
fk_name = "object" is needed whenever the history model has two FKs the
inline could bind to — which is always, since user is also a FK. The
if change: guard skips creates, which have no prior state.
UserAdmin and SiteAdmin in main/admin.py are the reference
implementations.
Models that ship with it
User, Slug, Site, Event, Subscription, TeamRole, and
TeamUserRole.
Known gaps
Worth knowing before you rely on the log.
EventAdmin calls save_history() after super().save_model(). It
records post-change values, unlike UserAdmin and SiteAdmin which call it
first. Event history from the admin is currently wrong.
SlugAdmin doesn't call it at all. Slug edits through the staff editor
are tracked; slug edits through Django admin are not. Same for
TeamRoleAdmin and TeamUserRoleAdmin — the history models exist and the
inline is registered, but nothing writes entries from admin.
Deletes aren't recorded. save_history() runs on edit paths only, and
the history rows cascade away with the object anyway. There is no tombstone.
M2M changes are invisible, as above.
No diffing. Comparing two entries to see what actually changed is left to the caller. With full snapshots it's straightforward to write, but nothing ships.
Every tracked edit writes a full snapshot. There's a TODO in the source
about an opt-in lightweight mode — object, user, and timestamp with no
snapshot — for high-churn or high-cardinality tables where a full copy per
edit is overkill. Not built yet. Consider table growth before adding the
mixin to something that changes constantly.
UUIDEncoder is marked deprecated in util/utils.py with a TODO saying
it isn't used, but AbstractHistory.snapshot does use it. Don't remove it on
the strength of that comment.
Checklist
- [ ]
HistoryMixinfirst in the model's bases - [ ] Companion model subclasses
AbstractHistory - [ ] FK named
object,related_name="history",CASCADE - [ ] User FK
SET_NULLwithrelated_name="+" - [ ]
class Meta(AbstractHistory.Meta), inherited - [ ]
save_history(user=...)called before the save - [ ] Reads go through
get_snapshot(), never.snapshot - [ ] Admin: inline with
fk_name="object", plussave_model()withif change: - [ ] M2M fields you care about handled separately
Next
- Architecture — where auditing sits in the system
- RBAC — the permission model, itself history-tracked
- Contributing — model conventions
Contents
- History and Auditing
- The two pieces
- Adding it to a model
- Calling it
- Why it re-fetches from the database
- The `user` argument is required
- What gets snapshotted
- Encrypted fields
- Why the re-encryption step exists
- `get_snapshot()`, not `.snapshot`
- Reading and reverting
- Admin integration
- Models that ship with it
- Known gaps
- Checklist
- Next
Pages Here
No sub-pages yet.
Page Info
Wiki: Docs
Created on Sep 21, 2026 by Tobu Pengin, L.L.C.
Maintainers
| Editor | Last Activity |
|---|---|
| Tobu Pengin, L.L.C. creator | Sep 21, 2026 |