from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models, transaction
from .middleware import get_current_user
class AuditLogEntry(models.Model):
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.CharField(max_length=64)
target = GenericForeignKey("content_type", "object_id")
actor_id = models.IntegerField(null=True, blank=True)
changes = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [models.Index(fields=["content_type", "object_id"])]
ordering = ["-created_at"]
class AuditableModel(models.Model):
TRACKED_FIELDS = ()
class Meta:
abstract = True
@classmethod
def from_db(cls, db, field_names, values):
instance = super().from_db(db, field_names, values)
instance._loaded_values = dict(zip(field_names, values))
return instance
def _diff_tracked_fields(self):
loaded = getattr(self, "_loaded_values", {})
changes = {}
for field in self.TRACKED_FIELDS:
old = loaded.get(field)
new = getattr(self, field)
if old != new:
changes[field] = {"from": old, "to": new}
return changes
def save(self, *args, **kwargs):
changes = self._diff_tracked_fields()
with transaction.atomic():
super().save(*args, **kwargs)
if changes:
actor = get_current_user()
AuditLogEntry.objects.create(
content_type=ContentType.objects.get_for_model(self),
object_id=str(self.pk),
actor_id=getattr(actor, "id", None),
changes=changes,
)
self._loaded_values = {
field: getattr(self, field) for field in self.TRACKED_FIELDS
}
import threading
_state = threading.local()
def get_current_user():
return getattr(_state, "user", None)
class AuditContextMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
user = getattr(request, "user", None)
_state.user = user if getattr(user, "is_authenticated", False) else None
try:
return self.get_response(request)
finally:
_state.user = None
from django.db import models
from .models import AuditableModel
class Order(AuditableModel):
TRACKED_FIELDS = ("status", "total_cents", "shipping_address")
STATUS_CHOICES = [
("pending", "Pending"),
("paid", "Paid"),
("shipped", "Shipped"),
("cancelled", "Cancelled"),
]
reference = models.CharField(max_length=20, unique=True)
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default="pending")
total_cents = models.PositiveIntegerField(default=0)
shipping_address = models.TextField(blank=True)
updated_at = models.DateTimeField(auto_now=True)
def mark_paid(self):
self.status = "paid"
self.save(update_fields=["status", "updated_at"])
def __str__(self):
return self.reference
Audit logging answers a recurring question in line-of-business apps: who changed what, and when? This snippet captures per-field changes for a Django model at the moment it is persisted, writing one durable AuditLogEntry row per save that mutated tracked fields. The approach keeps the audit trail close to the write path rather than reconstructing it after the fact, so the recorded diff reflects exactly the transition that hit the database.
In models.py, AuditLogEntry is a plain model that stores a JSON changes payload and points at the audited object through a GenericForeignKey, so a single table can audit many model types. The AuditableModel abstract base adds the mechanics: TRACKED_FIELDS declares which columns matter, and from_db stashes the values loaded from the database in _loaded_values. Snapshotting at load time is the crux — it gives a reliable "before" image without a second query, and it correctly treats brand-new instances (never loaded) as having no prior state.
The overridden save computes changed_fields by comparing _loaded_values against current attribute values, delegates to super().save() inside a transaction.atomic() block, and only then writes the AuditLogEntry. Wrapping both writes in one transaction means the audit row and the data change commit or roll back together, so the log can never claim a change that didn't happen. After saving, _loaded_values is refreshed so a second save on the same in-memory instance diffs against the new baseline.
Capturing the acting user is handled in middleware.py. Because model code has no request context, AuditContextMiddleware stashes the current user in a thread-local and exposes it via get_current_user, which save reads to populate actor_id. This is a common pragmatic pattern, though it relies on thread-per-request semantics and should be used cautiously under async or thread-pooled workers.
Finally, orders.py shows a concrete Order subclass listing its tracked fields. The trade-offs: from_db snapshots add a little memory per instance, update() querysets bypass this entirely, and the JSON diff intentionally omits fields not listed. For fine-grained, save-time audit trails on a handful of important models, this is a focused and testable alternative to heavier signal-based frameworks.
Related snips
class Product(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField(blank=True)
price = models.DecimalField(max_digits=10, decimal_places=2)
cost = models.DecimalField(max_digits=10, decimal_places=2)
margin = models.DecimalField(max_digits=5, decimal_places=2, blank=True)
Django model signals vs overriding save
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
Django URL namespacing and reverse lookups
import graphene
from graphene_django import DjangoObjectType
from blog.models import Post, Comment
class PostType(DjangoObjectType):
Django GraphQL with Graphene
class AddSettingsToAccounts < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
add_column :accounts, :settings, :jsonb, null: false, default: {}
Postgres JSONB Partial Index for Feature Flags
Share this code
Here's the card — post it anywhere.