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 object and use related_name="history"
  • save_history() resolves the companion via self._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:

  • HistoryMixin comes first in the bases, before models.Model.
  • class Meta(AbstractHistory.Meta) — inherit it, don't redeclare. That is where ordering = ['-changed_at'] lives, and a bare class Meta throws 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.CASCADE on object — history dies with the object.
  • on_delete=models.SET_NULL on user — deleting a user must not erase the history of what they did.
  • The id field is redeclared because AbstractHistory doesn'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 covers auto_now and auto_now_add timestamps
  • many-to-many fields, which _meta.fields excludes 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

  • [ ] HistoryMixin first in the model's bases
  • [ ] Companion model subclasses AbstractHistory
  • [ ] FK named object, related_name="history", CASCADE
  • [ ] User FK SET_NULL with related_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", plus save_model() with if 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