diff --git a/auditlog/__init__.py b/src/auditlog/__init__.py similarity index 100% rename from auditlog/__init__.py rename to src/auditlog/__init__.py diff --git a/auditlog/diff.py b/src/auditlog/diff.py similarity index 90% rename from auditlog/diff.py rename to src/auditlog/diff.py index 069c9e2..e593872 100644 --- a/auditlog/diff.py +++ b/src/auditlog/diff.py @@ -23,8 +23,8 @@ def model_instance_diff(old, new): fields = set() for field in fields: - old_value = getattr(old, field.name, None) - new_value = getattr(new, field.name, None) + old_value = str(getattr(old, field.name, None)) + new_value = str(getattr(new, field.name, None)) if old_value != new_value: diff[field.name] = (old_value, new_value) diff --git a/auditlog/middleware.py b/src/auditlog/middleware.py similarity index 100% rename from auditlog/middleware.py rename to src/auditlog/middleware.py diff --git a/auditlog/models.py b/src/auditlog/models.py similarity index 72% rename from auditlog/models.py rename to src/auditlog/models.py index 1adab0d..52dcc8a 100644 --- a/auditlog/models.py +++ b/src/auditlog/models.py @@ -19,13 +19,23 @@ class LogEntryManager(models.Manager): kwargs['content_type'] = ContentType.objects.get_for_model(instance) if not 'object_pk' in kwargs: kwargs['object_pk'] = instance.pk + if not 'object_repr' in kwargs: + kwargs['object_repr'] = str(instance) 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) + # Delete log entries with the same pk as a newly created model. This should only happen when all records were + # deleted / the table was truncated. + if kwargs.get('action', None) is LogEntry.Action.CREATE: + if kwargs.get('object_id', None) is not None and self.exists(object_id=kwargs.get('object_id')): + self.filter(object_id=kwargs.get('object_id')).delete() + else: + self.filter(object_pk=kwargs.get('object_pk', '')).delete() + + return self.create(**kwargs) class LogEntry(models.Model): @@ -53,6 +63,8 @@ class LogEntry(models.Model): actor = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL, related_name='+', verbose_name=_("actor")) timestamp = models.DateTimeField(auto_now_add=True, verbose_name=_("timestamp")) + objects = LogEntryManager() + class Meta: get_latest_by = 'timestamp' ordering = ['-timestamp'] @@ -61,13 +73,15 @@ class LogEntry(models.Model): def __unicode__(self): if self.action == self.Action.CREATE: - return _("Created {repr:s}").format(self.object_repr) + fstring = _("Created {repr:s}") elif self.action == self.Action.UPDATE: - return _("Updated {repr:s}").format(self.object_repr) + fstring = _("Updated {repr:s}") elif self.action == self.Action.DELETE: - return _("Deleted {repr:s}").format(self.object_repr) + fstring = _("Deleted {repr:s}") else: - return u'{verbose_name:s} #{id:s}'.format(verbose_name=self._meta.verbose_name.capitalize(), id=self.id) + fstring = _("Logged {repr:s}") + + return fstring.format(repr=self.object_repr) class AuditLogHistoryField(generic.GenericRelation): @@ -76,8 +90,13 @@ class AuditLogHistoryField(generic.GenericRelation): easier to implement the audit log in models, and makes future changes easier. """ - def __init__(self, **kwargs): + def __init__(self, pk_indexable=True, **kwargs): kwargs['to'] = LogEntry - kwargs['object_id_field'] = 'object_id' + + if pk_indexable: + kwargs['object_id_field'] = 'object_id' + else: + kwargs['object_id_field'] = 'object_pk' + kwargs['content_type_field'] = 'content_type' super(AuditLogHistoryField, self).__init__(**kwargs) diff --git a/auditlog/receivers.py b/src/auditlog/receivers.py similarity index 100% rename from auditlog/receivers.py rename to src/auditlog/receivers.py diff --git a/auditlog/registry.py b/src/auditlog/registry.py similarity index 94% rename from auditlog/registry.py rename to src/auditlog/registry.py index 99832b1..dace2eb 100644 --- a/auditlog/registry.py +++ b/src/auditlog/registry.py @@ -53,7 +53,8 @@ class AuditLogModelRegistry(object): """ Connect signals for the model. """ - for signal, receiver in self._signals: + for signal in self._signals: + receiver = self._signals[signal] signal.connect(receiver, sender=model, dispatch_uid=self._dispatch_uid(signal, model)) def _disconnect_signals(self, model): @@ -70,4 +71,4 @@ class AuditLogModelRegistry(object): return (self.__class__, model, signal) -registry = AuditLogModelRegistry() +auditlog = AuditLogModelRegistry() diff --git a/src/manage.py b/src/manage.py new file mode 100644 index 0000000..0fc36a3 --- /dev/null +++ b/src/manage.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/src/test_project/__init__.py b/src/test_project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/test_project/settings.py b/src/test_project/settings.py new file mode 100644 index 0000000..91c44b3 --- /dev/null +++ b/src/test_project/settings.py @@ -0,0 +1,68 @@ +# Django settings for test_project project. +import os + +BASEDIR = os.path.dirname(os.path.realpath(__file__)) + +DEBUG = True +TEMPLATE_DEBUG = DEBUG + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASEDIR, 'test.db'), + } +} + +# Make this unique, and don't share it with anybody. +SECRET_KEY = 'j6qdljfn(m$w-4r5*wx_m!!o4-z0ehe09y%k8s@kf)zmyc366*' + +# List of callables that know how to import templates from various sources. +TEMPLATE_LOADERS = ( + 'django.template.loaders.app_directories.Loader', +) + +MIDDLEWARE_CLASSES = ( + 'django.middleware.common.CommonMiddleware', + 'auditlog.middleware.AuditLogMiddleware', +) + +ROOT_URLCONF = 'test_project.urls' + +# Python dotted path to the WSGI application used by Django's runserver. +WSGI_APPLICATION = 'test_project.wsgi.application' + +INSTALLED_APPS = ( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'auditlog', + 'testapp', +) + +# A sample logging configuration. The only tangible logging +# performed by this configuration is to send an email to +# the site admins on every HTTP 500 error when DEBUG=False. +# See http://docs.djangoproject.com/en/dev/topics/logging for +# more details on how to customize your logging configuration. +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'filters': { + 'require_debug_false': { + '()': 'django.utils.log.RequireDebugFalse' + } + }, + 'handlers': { + 'mail_admins': { + 'level': 'ERROR', + 'filters': ['require_debug_false'], + 'class': 'django.utils.log.AdminEmailHandler' + } + }, + 'loggers': { + 'django.request': { + 'handlers': ['mail_admins'], + 'level': 'ERROR', + 'propagate': True, + }, + } +} diff --git a/src/test_project/urls.py b/src/test_project/urls.py new file mode 100644 index 0000000..cc4356b --- /dev/null +++ b/src/test_project/urls.py @@ -0,0 +1,17 @@ +from django.conf.urls import patterns, include, url + +# Uncomment the next two lines to enable the admin: +# from django.contrib import admin +# admin.autodiscover() + +urlpatterns = patterns('', + # Examples: + # url(r'^$', 'test_project.views.home', name='home'), + # url(r'^test_project/', include('test_project.foo.urls')), + + # Uncomment the admin/doc line below to enable admin documentation: + # url(r'^admin/doc/', include('django.contrib.admindocs.urls')), + + # Uncomment the next line to enable the admin: + # url(r'^admin/', include(admin.site.urls)), +) diff --git a/src/test_project/wsgi.py b/src/test_project/wsgi.py new file mode 100644 index 0000000..43ff066 --- /dev/null +++ b/src/test_project/wsgi.py @@ -0,0 +1,32 @@ +""" +WSGI config for test_project project. + +This module contains the WSGI application used by Django's development server +and any production WSGI deployments. It should expose a module-level variable +named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover +this application via the ``WSGI_APPLICATION`` setting. + +Usually you will have the standard Django WSGI application here, but it also +might make sense to replace the whole Django WSGI application with a custom one +that later delegates to the Django one. For example, you could introduce WSGI +middleware here, or combine a Django application with an application of another +framework. + +""" +import os + +# We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks +# if running multiple sites in the same mod_wsgi process. To fix this, use +# mod_wsgi daemon mode with each site in its own daemon process, or use +# os.environ["DJANGO_SETTINGS_MODULE"] = "test_project.settings" +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") + +# This application object is used by any WSGI server configured to use this +# file. This includes Django's development server, if the WSGI_APPLICATION +# setting points here. +from django.core.wsgi import get_wsgi_application +application = get_wsgi_application() + +# Apply WSGI middleware here. +# from helloworld.wsgi import HelloWorldApplication +# application = HelloWorldApplication(application) diff --git a/src/testapp/__init__.py b/src/testapp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/testapp/models.py b/src/testapp/models.py new file mode 100644 index 0000000..12e2256 --- /dev/null +++ b/src/testapp/models.py @@ -0,0 +1,59 @@ +from django.db import models +from auditlog.models import AuditLogHistoryField +from auditlog.registry import auditlog + + +class SimpleModel(models.Model): + """ + A simple model with no special things going on. + """ + + text = models.TextField(blank=True) + boolean = models.BooleanField(default=False) + integer = models.IntegerField(blank=True, null=True) + datetime = models.DateTimeField(auto_now=True) + + history = AuditLogHistoryField() + + +class AltPrimaryKeyModel(models.Model): + """ + A model with a non-standard primary key. + """ + + key = models.CharField(max_length=100, primary_key=True) + value = models.DecimalField(decimal_places=4, max_digits=12) + + history = AuditLogHistoryField() + + +class ProxyModel(SimpleModel): + """ + A model that is a proxy for another model. + """ + + class Meta: + proxy = True + + +class RelatedModel(models.Model): + """ + A model with a foreign key. + """ + + related = models.ForeignKey('self') + + history = AuditLogHistoryField() + + +class ManyRelatedModel(models.Model): + """ + A model with a many to many relation. + """ + + related = models.ManyToManyField('self') + + history = AuditLogHistoryField() + + +auditlog.register(SimpleModel) diff --git a/src/testapp/tests.py b/src/testapp/tests.py new file mode 100644 index 0000000..acbf4ac --- /dev/null +++ b/src/testapp/tests.py @@ -0,0 +1,16 @@ +from django.test import TestCase +from testapp.models import SimpleModel + + +class ModelTest(TestCase): + def setUp(self): + self.obj_simple = SimpleModel.objects.create(text='I am not difficult.') + + def test_simple_create(self): + """Mutations on simple models are logged correctly.""" + # Create the object to work with + obj = self.obj_simple + obj.save() + + # Check for log entries + self.assertTrue(obj.history)