Changes between Initial Version and Version 1 of AuditTrail


Ignore:
Timestamp:
Aug 14, 2007, 8:03:51 PM (17 years ago)
Author:
Marty Alchin <gulopine@…>
Comment:

Initial writeup

Legend:

Unmodified
Added
Removed
Modified
  • AuditTrail

    v1 v1  
     1= Automating an audit trail =
     2
     3As raised in [http://groups.google.com/group/django-developers/browse_thread/thread/ab7649e4abd4e589 a recent discussion on django-developers], this code is one solution for creating an audit trail for a given model. '''This is very much incomplete, and is intended to be used as a base to work from. See [#Caveats Caveats] below for more information.'''
     4
     5== Usage ==
     6
     7Copy the code at the bottom of this article into a location of your choice. It's just a one-file utility, so it doesn't require an app directory or anything. The examples below assume it's called `audit.py` and is somewhere on your PYTHONPATH.
     8
     9In your models file, there are only a couple things to do. First, obviously you'll need to import your audit file, or possibly just get `AuditTrail` from within it. Then, add an `AuditTrail` to the model of your choice, assigning it to whatever name you like. That's the only thing necessary to set up the audit trail and get Python-level acecss to it. If you need to view the audit information in the admin interface, simply add `show_in_admin=True` as an argument to `AuditTrail`.
     10
     11{{{
     12#!python
     13from django.db import models
     14import audit
     15
     16class Person(models.Model):
     17    first_name = models.CharField(maxlength=255)
     18    last_name = models.CharField(maxlength=255)
     19    salary = models.PositiveIntegerField()
     20
     21    history = audit.AuditTrail()
     22
     23    def __str__(self):
     24        return "%s %s" % (self.first_name, self.last_name)
     25}}}
     26
     27This simple addition will do the rest, allowing you to run `syncdb` and install the audit model. Once it's installed, the following code will work as shown below. As you will see, `Person.history` becomes a manager that's used to access the audit trail for a particular object.
     28
     29{{{
     30#!python
     31>>> from myapp.models import Person
     32>>> person = Person.objects.create(first_name='John', last_name='Public', salary=50000)
     33>>>
     34<Person: John Public>
     35>>> person.history.count()
     361
     37>>> person.salary = 65000
     38>>> person.save()
     39>>> person.history.count()
     402
     41>>> for item in person.history.all():
     42...     print "%s: %s" % (item, item.salary)
     43John Public as of 2007-08-14 20:31:21.852000: 65000
     44John Public as of 2007-08-14 20:30:58.959000: 50000
     45}}}
     46
     47As you can see, the audit trail is listed with the most recent state first. Each entry also inclues a timestamp when the edit took place.
     48
     49== Caveats ==
     50
     51For one thing, only `post_save` is used right now, so if you have need for `post_delete`, you'll need to put that in whatever way you'd like.
     52
     53Also, in order to copy the fields from the original model to the audit model, it uses some hackery I'm not particularly proud of. It seems to work for all the cases I would have hoped it would, but it relies on the arguments passed to the Field class being named the same as the attributes stored on the Field object after it's created. If there's ever a time that's not the case, it will fail completely on that Field type.
     54
     55Speaking of which, it fails completely on `ForeignKey`s and `ManyToManyField`s, something I've yet to remedy. That's definitely a must-have, but I haven't worked out the best way to go about it. And since this whole things isn't something I'm particularly interested in, I'm probably going to leave that up to somebody else to work out.
     56
     57It currently copies and overrides the model's `__str__` method, so that it can helpfully describe each entry in the audit history. This means, however, that if your `__str__` method relies on any ''other'' methods (such as `get_full_name` or similar), it won't work and will need to be adjusted.
     58
     59== Code ==
     60
     61Hopefully there are enough comments to make sense of what's going on. More information can be found [http://gulopine.gamemusic.org/2007/08/dynamic-models-in-real-world.html here].
     62
     63{{{
     64#!python
     65import re
     66
     67from django.dispatch import dispatcher
     68from django.db import models
     69from django.core.exceptions import ImproperlyConfigured
     70
     71value_error_re = re.compile("^.+'(.+)'$")
     72
     73class AuditTrail(object):
     74    def __init__(self, show_in_admin=False):
     75        self.show_in_admin = show_in_admin
     76
     77    def contribute_to_class(self, cls, name):
     78        # This should only get added once the class is otherwise complete
     79
     80        def _contribute(sender):
     81            model = create_audit_model(sender, self.show_in_admin)
     82            descriptor = AuditTrailDescriptor(model._default_manager, sender._meta.pk.attname)
     83            setattr(sender, name, descriptor)
     84
     85            def _audit(sender, instance):
     86                # Write model changes to the audit model
     87                kwargs = {}
     88                for field in sender._meta.fields:
     89                    kwargs[field.attname] = getattr(instance, field.attname)
     90                model._default_manager.create(**kwargs)
     91            dispatcher.connect(_audit, signal=models.signals.post_save, sender=cls, weak=False)
     92
     93        dispatcher.connect(_contribute, signal=models.signals.class_prepared, sender=cls, weak=False)
     94
     95class AuditTrailDescriptor(object):
     96    def __init__(self, manager, pk_attribute):
     97        self.manager = manager
     98        self.pk_attribute = pk_attribute
     99
     100    def __get__(self, instance=None, owner=None):
     101        if instance == None:
     102            raise AttributeError, "Audit trail is only accessible via %s instances." % type.__name__
     103        return create_audit_manager(self.manager, self.pk_attribute, instance._get_pk_val())
     104
     105    def __set__(self, instance, value):
     106        raise AttributeError, "Audit trail may not be edited in this manner."
     107
     108def create_audit_manager(manager, pk_attribute, pk):
     109    """Create an audit trail manager based on the current object"""
     110    class AuditTrailManager(manager.__class__):
     111        def __init__(self):
     112            self.model = manager.model
     113
     114        def get_query_set(self):
     115            return super(AuditTrailManager, self).get_query_set().filter(**{pk_attribute: pk})
     116    return AuditTrailManager()
     117
     118def create_audit_model(cls, show_in_admin):
     119    """Create an audit model for the specific class"""
     120    name = cls.__name__ + 'Audit'
     121
     122    class Meta:
     123        db_table = '%s_audit' % cls._meta.db_table
     124        verbose_name_plural = '%s audit trail' % cls._meta.verbose_name
     125        ordering = ['-_audit_timestamp']
     126
     127    # Set up a dictionary to simulate declarations within a class
     128    attrs = {
     129        '__module__': cls.__module__,
     130        'Meta': Meta,
     131        '_audit_id': models.AutoField(primary_key=True),
     132        '_audit_timestamp': models.DateTimeField(auto_now_add=True),
     133        '_audit__str__': cls.__str__.im_func,
     134        '__str__': lambda self: '%s as of %s' % (self._audit__str__(), self._audit_timestamp),
     135    }
     136
     137    if show_in_admin:
     138        # Enable admin integration
     139        class Admin:
     140            pass
     141        attrs['Admin'] = Admin
     142
     143    # Copy the fields from the existing model to the audit model
     144    for field in cls._meta.fields:
     145        if field.attname in attrs:
     146            raise ImproperlyConfigured, "%s cannot use %s as it is needed by AuditTrail." % (cls.__name__, field.attname)
     147        attrs[field.attname] = copy_field(field)
     148
     149    return type(name, (models.Model,), attrs)
     150
     151def copy_field(field):
     152    """Copy an instantiated field to a new instantiated field"""
     153    if isinstance(field, models.AutoField):
     154        # Audit models have a separate AutoField
     155        return models.IntegerField(db_index=True, editable=False)
     156
     157    copied_field = None
     158    cls = field.__class__
     159
     160    # Use the field's attributes to start with
     161    kwargs = field.__dict__.copy()
     162
     163    # Swap primary keys for ordinary indexes
     164    if field.primary_key:
     165        kwargs['db_index'] = True
     166        del kwargs['primary_key']
     167
     168    # Some hackery to copy the field
     169    while copied_field is None:
     170        try:
     171            copied_field = cls(**kwargs)
     172        except (TypeError, ValueError), e:
     173            # Some attributes, like creation_counter, aren't valid arguments
     174            # So try to remove that argument so the field can try again
     175            try:
     176                del kwargs[value_error_re.match(str(e)).group(1)]
     177            except:
     178                # The attribute was already removed, and something's still going wrong
     179                raise e
     180
     181    return copied_field
     182}}}
Back to Top