| 1 | = Automating an audit trail = |
| 2 | |
| 3 | As 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 | |
| 7 | Copy 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 | |
| 9 | In 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 |
| 13 | from django.db import models |
| 14 | import audit |
| 15 | |
| 16 | class 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 | |
| 27 | This 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() |
| 36 | 1 |
| 37 | >>> person.salary = 65000 |
| 38 | >>> person.save() |
| 39 | >>> person.history.count() |
| 40 | 2 |
| 41 | >>> for item in person.history.all(): |
| 42 | ... print "%s: %s" % (item, item.salary) |
| 43 | John Public as of 2007-08-14 20:31:21.852000: 65000 |
| 44 | John Public as of 2007-08-14 20:30:58.959000: 50000 |
| 45 | }}} |
| 46 | |
| 47 | As 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 | |
| 51 | For 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 | |
| 53 | Also, 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 | |
| 55 | Speaking 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 | |
| 57 | It 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 | |
| 61 | Hopefully 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 |
| 65 | import re |
| 66 | |
| 67 | from django.dispatch import dispatcher |
| 68 | from django.db import models |
| 69 | from django.core.exceptions import ImproperlyConfigured |
| 70 | |
| 71 | value_error_re = re.compile("^.+'(.+)'$") |
| 72 | |
| 73 | class 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 | |
| 95 | class 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 | |
| 108 | def 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 | |
| 118 | def 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 | |
| 151 | def 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 | }}} |