from django.db import models from django.utils import timezone from django.dispatch import receiver from django.db.models.signals import pre_save, post_save, post_delete from django.forms.models import model_to_dict class AuditLog(models.Model): ACTION_CREATE = "create" ACTION_UPDATE = "update" ACTION_DELETE = "delete" ACTIONS = [ (ACTION_CREATE, "Created"), (ACTION_UPDATE, "Updated"), (ACTION_DELETE, "Deleted"), ] entry_id = models.IntegerField(db_index=True) action = models.CharField(max_length=10, choices=ACTIONS, db_index=True) username = models.CharField(max_length=255, blank=True, default="") timestamp = models.DateTimeField(default=timezone.now, db_index=True) # For updates: {"field": ["old","new"], ...} # For creates: {"__created__": {: , ...}} # For deletes: {"__deleted__": {: , ...}} changes = models.JSONField(null=True, blank=True) class Meta: ordering = ["-timestamp"] indexes = [ models.Index(fields=["timestamp"]), models.Index(fields=["action"]), models.Index(fields=["entry_id"]), ] def __str__(self): return f"{self.timestamp:%Y-%m-%d %H:%M:%S} {self.action} #{self.entry_id} by {self.username or '-'}" # ---- helpers for diffs (lazy: derive from sender) ---- def _tracked_field_names(sender): """Return concrete, editable, non-PK field names for the given model.""" names = [] for f in sender._meta.get_fields(): if getattr(f, "many_to_many", False): continue if not getattr(f, "concrete", False): continue if getattr(f, "primary_key", False): continue if getattr(f, "editable", True) is False: continue names.append(f.name) return names def _serialize_instance(sender, obj): """Serialize only tracked fields; stringify values for safety.""" fields = _tracked_field_names(sender) d = model_to_dict(obj, fields=fields) for k, v in d.items(): d[k] = "" if v is None else str(v) return d def _is_core_entry(sender): """True iff the signal sender is core.Entry (no import-time lookups).""" return getattr(sender, "_meta", None) and sender._meta.label_lower == "core.entry" # ---- current user from middleware ---- from .middleware import get_current_username # ---- signals (note: no sender=... at decorator time) ---- @receiver(pre_save) def _audit_entry_pre_save(sender, instance, **kwargs): if not _is_core_entry(sender): return # For updates, stash old snapshot on the instance if instance.pk: try: existing = sender.objects.get(pk=instance.pk) instance.__audit_old__ = _serialize_instance(sender, existing) except sender.DoesNotExist: instance.__audit_old__ = None else: instance.__audit_old__ = None @receiver(post_save) def _audit_entry_save(sender, instance, created, **kwargs): if not _is_core_entry(sender): return from .models_audit import AuditLog # local import to avoid circulars if created: AuditLog.objects.create( entry_id=instance.id, action=AuditLog.ACTION_CREATE, username=get_current_username(), changes={"__created__": _serialize_instance(sender, instance)}, ) else: old = getattr(instance, "__audit_old__", None) new = _serialize_instance(sender, instance) if old is None: changes = {"__created__": new} action = AuditLog.ACTION_CREATE else: diffs = {} for field in _tracked_field_names(sender): ov = old.get(field, "") nv = new.get(field, "") if ov != nv: diffs[field] = [ov, nv] changes = diffs or None action = AuditLog.ACTION_UPDATE AuditLog.objects.create( entry_id=instance.id, action=action, username=get_current_username(), changes=changes, ) @receiver(post_delete) def _audit_entry_delete(sender, instance, **kwargs): if not _is_core_entry(sender): return from .models_audit import AuditLog AuditLog.objects.create( entry_id=instance.id, action=AuditLog.ACTION_DELETE, username=get_current_username(), changes={"__deleted__": _serialize_instance(sender, instance)}, )