diff --git a/auditlog/__init__.py b/auditlog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auditlog/diff.py b/auditlog/diff.py new file mode 100644 index 0000000..069c9e2 --- /dev/null +++ b/auditlog/diff.py @@ -0,0 +1,35 @@ +from django.db.models import Model + + +def model_instance_diff(old, new): + """ + Calculate the differences between two model instances. One of the instances may be None (i.e., a newly + created model or deleted model). This will cause all fields with a value to have changed (from None). + """ + if not(old is None or isinstance(old, Model)): + raise TypeError('The supplied old instance is not a valid model instance.') + if not(new is None or isinstance(new, Model)): + raise TypeError('The supplied new instance is not a valid model instance.') + + diff = {} + + if old is not None and new is not None: + fields = set(old._meta.fields + new._meta.fields) + elif old is not None: + fields = set(old._meta.fields) + elif new is not None: + fields = set(new._meta.fields) + else: + fields = set() + + for field in fields: + old_value = getattr(old, field.name, None) + new_value = getattr(new, field.name, None) + + if old_value != new_value: + diff[field.name] = (old_value, new_value) + + if len(diff) == 0: + diff = None + + return diff diff --git a/auditlog/middleware.py b/auditlog/middleware.py new file mode 100644 index 0000000..e44c1dd --- /dev/null +++ b/auditlog/middleware.py @@ -0,0 +1,27 @@ +from django.db.models.signals import pre_save +from django.utils.functional import curry +from auditlog.models import LogEntry + + +class AuditLogMiddleware(object): + """ + Middleware to couple the request's user to log items. This is accomplished by currying the signal receiver with the + user from the request (or None if the user is not authenticated). + """ + + def process_request(self, request): + if hasattr(request, 'user') and hasattr(request.user, 'is_authenticated') and request.user.is_authenticated(): + user = request.user + else: + user = None + + insert_user = curry(self.insert_user, user) + pre_save.connect(insert_user, sender=LogEntry, dispatch_uid=(self.__class__, request), weak=False) + + def process_response(self, request, response): + pre_save.disconnect(dispatch_uid=(self.__class__, request)) + return response + + def insert_user(self, user, sender, instance, **kwargs): + if sender == LogEntry and isinstance(instance, sender): + instance.actor = user diff --git a/auditlog/models.py b/auditlog/models.py new file mode 100644 index 0000000..5d02360 --- /dev/null +++ b/auditlog/models.py @@ -0,0 +1,82 @@ +from django.contrib.contenttypes import generic +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.utils.translation import ugettext_lazy as _ + + +class LogEntryManager(models.Manager): + """ + Custom manager for the LogEntry model. + """ + + def log_create(self, instance, **kwargs): + """ + Helper method to create a new log entry. This method automatically fills in some data when it is left out. It + was created to keep things DRY. + """ + if not 'content_type' in kwargs: + kwargs['content_type'] = ContentType.objects.get_for_model(instance) + if not 'object_pk' in kwargs: + kwargs['object_pk'] = instance.pk + if not 'object_id' in kwargs: + pk_field = instance._meta.pk.name + pk = getattr(instance, pk_field, None) + if isinstance(pk, int): + kwargs['object_id'] = pk + + self.create(**kwargs) + + +class LogEntry(models.Model): + """ + Represents an entry in the audit log, containing data. + """ + + class Action: + CREATE = 0 + UPDATE = 1 + DELETE = 2 + + choices = ( + (CREATE, _("create")), + (UPDATE, _("update")), + (DELETE, _("delete")), + ) + + content_type = models.ForeignKey('contenttypes.ContentType', on_delete=models.CASCADE, related_name='+', verbose_name=_("content type")) + object_pk = models.TextField(verbose_name=_("object pk")) + object_id = models.PositiveIntegerField(blank=True, db_index=True, null=True, verbose_name=_("object id")) + object_repr = models.TextField(verbose_name=_("object representation")) + action = models.PositiveSmallIntegerField(choices=Action.choices, verbose_name=_("action")) + changes = models.TextField(blank=True, verbose_name=_("change message")) + actor = models.ForeignKey('auth.User', blank=True, null=True, on_delete=models.SET_NULL, related_name='+', verbose_name=_("actor")) + timestamp = models.DateTimeField(auto_now_add=True, verbose_name=_("timestamp")) + + class Meta: + get_latest_by = 'timestamp' + ordering = ['-timestamp'] + verbose_name = _("log entry") + verbose_name_plural = _("log entries") + + def __unicode__(self): + if self.action == self.Action.CREATE: + return _("Created {repr:s}").format(self.object_repr) + elif self.action == self.Action.UPDATE: + return _("Updated {repr:s}").format(self.object_repr) + elif self.action == self.Action.DELETE: + return _("Deleted {repr:s}").format(self.object_repr) + else: + return u'{verbose_name:s} #{id:s}'.format(verbose_name=self._meta.verbose_name.capitalize(), id=self.id) + + +class AuditLogHistoryField(generic.GenericRelation): + """ + A subclass of django.contrib.contenttypes.generic.GenericRelation that sets some default variables. This makes it + easier to implement the audit log in models, and makes future changes easier. + """ + + def __init__(self, **kwargs): + kwargs['to'] = LogEntry + kwargs['object_id_field'] = 'object_id' + kwargs['content_type_field'] = 'content_type' + super(AuditLogHistoryField, self).__init__(**kwargs) diff --git a/auditlog/receivers.py b/auditlog/receivers.py new file mode 100644 index 0000000..aa5773c --- /dev/null +++ b/auditlog/receivers.py @@ -0,0 +1,57 @@ +import json +from auditlog.diff import model_instance_diff +from auditlog.models import LogEntry + + +def log_create(sender, instance, created, **kwargs): + """ + Signal receiver that creates a log entry when a model instance is first saved to the database. + + Direct use is discouraged, connect your model through auditlog.registry.registry instead. + """ + if created: + changes = model_instance_diff(None, instance) + + log_entry = LogEntry.objects.log_create( + instance, + action=LogEntry.Action.CREATE, + changes=json.dumps(changes), + ) + log_entry.save() + + +def log_update(sender, instance, **kwargs): + """ + Signal receiver that creates a log entry when a model instance is changed and saved to the database. + + Direct use is discouraged, connect your model through auditlog.registry.registry instead. + """ + if instance.pk is not None: + old = sender.objects.get(pk=instance.pk) + new = instance + + changes = model_instance_diff(old, new) + + log_entry = LogEntry.objects.log_create( + instance, + action=LogEntry.Action.UPDATE, + changes=json.dumps(changes), + ) + log_entry.save() + + +def log_delete(sender, instance, **kwargs): + """ + Signal receiver that creates a log entry when a model instance is deleted from the database. + + Direct use is discouraged, connect your model through auditlog.registry.registry instead. + """ + if instance.pk is not None: + changes = model_instance_diff(instance, None) + + log_entry = LogEntry.objects.log_create( + instance, + action=LogEntry.Action.DELETE, + changes=json.dumps(changes), + ) + log_entry.save() diff --git a/auditlog/registry.py b/auditlog/registry.py new file mode 100644 index 0000000..99832b1 --- /dev/null +++ b/auditlog/registry.py @@ -0,0 +1,73 @@ +from django.db.models.signals import pre_save, post_save, post_delete +from django.db.models import Model +from auditlog.receivers import log_create, log_update, log_delete + + +class AuditLogModelRegistry(object): + """ + A registry that keeps track of the models that use auditlog. + """ + + def __init__(self, create=True, update=True, delete=True, custom=None): + self._registry = [] + self._signals = {} + + if create: + self._signals[post_save] = log_create + if update: + self._signals[pre_save] = log_update + if delete: + self._signals[post_delete] = log_delete + + if custom is not None: + self._signals.update(custom) + + def register(self, model): + """ + Register a model with auditlog. Auditlog will then track mutations on this model's instances. + """ + if issubclass(model, Model): + self._registry.append(model) + self._connect_signals(model) + else: + raise TypeError('Supplied model is not a valid model.') + + def contains(self, model): + """ + Check if a model is registered with auditlog. + """ + return model in self._registry + + def unregister(self, model): + """ + Unregister a model with auditlog. This will not affect the database. + """ + try: + self._registry.pop(model) + except KeyError: + pass + else: + self._disconnect_signals(model) + + def _connect_signals(self, model): + """ + Connect signals for the model. + """ + for signal, receiver in self._signals: + signal.connect(receiver, sender=model, dispatch_uid=self._dispatch_uid(signal, model)) + + def _disconnect_signals(self, model): + """ + Disconnect signals for the model. + """ + for signal, receiver in self._signals: + signal.disconnect(dispatch_uid=self._dispatch_uid(signal, model)) + + def _dispatch_uid(self, signal, model): + """ + Generate a dispatch_uid. + """ + return (self.__class__, model, signal) + + +registry = AuditLogModelRegistry()