diff --git a/.travis.yml b/.travis.yml index 580bb20..e0ef2d8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,4 +8,5 @@ env: install: - "pip install -r requirements.txt" - "pip install Django==$DJANGO_VERSION" -script: "python src/manage.py test testapp" +script: "python src/runtests.py" +sudo: false diff --git a/README.md b/README.md index e9b0b3e..45d0089 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ django-auditlog =============== +[![Build Status](https://travis-ci.org/jjkester/django-auditlog.svg?branch=master)](https://travis-ci.org/jjkester/django-auditlog) + **Please remember that this app is still in development and not yet suitable for production environments.** ```django-auditlog``` (Auditlog) is a reusable app for Django that makes logging object changes a breeze. Auditlog tries to use as much as Python and Django’s built in functionality to keep the list of dependencies as short as possible. Also, Auditlog aims to be fast and simple to use. diff --git a/docs/source/usage.rst b/docs/source/usage.rst index bf4c6b5..da10e8b 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -44,7 +44,8 @@ Actors ------ When using automatic logging, the actor is empty by default. However, auditlog can set the actor from the current -request automatically. This does not need any custom code, adding a middleware class is enough. +request automatically. This does not need any custom code, adding a middleware class is enough. When an actor is logged +the remote address of that actor will be logged as well. To enable the automatic logging of the actors, simply add the following to your ``MIDDLEWARE_CLASSES`` setting in your project's configuration file:: diff --git a/requirements.txt b/requirements.txt index e38f742..e601b92 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ Django>=1.7 +django-jsonfield>=0.9.13 diff --git a/setup.py b/setup.py index bb34691..dc25a3c 100644 --- a/setup.py +++ b/setup.py @@ -11,6 +11,7 @@ setup( author_email='janjelle@jjkester.nl', description='Audit log app for Django', install_requires=[ - 'Django>=1.7' + 'Django>=1.7', + 'django-jsonfield>=0.9.13', ] ) diff --git a/src/auditlog/diff.py b/src/auditlog/diff.py index e47262f..e01bc18 100644 --- a/src/auditlog/diff.py +++ b/src/auditlog/diff.py @@ -1,10 +1,53 @@ from __future__ import unicode_literals from django.core.exceptions import ObjectDoesNotExist -from django.db.models import Model +from django.db.models import Model, NOT_PROVIDED from django.utils.encoding import smart_text +def track_field(field): + """ + Returns whether the given field should be tracked by Auditlog. + + Untracked fields are many-to-many relations and relations to the Auditlog LogEntry model. + + :param field: The field to check. + :type field: Field + :return: Whether the given field should be tracked. + :rtype: bool + """ + from auditlog.models import LogEntry + # Do not track many to many relations + if field.many_to_many: + return False + + # Do not track relations to LogEntry + if getattr(field, 'rel', None) is not None and field.rel.to == LogEntry: + return False + + return True + + +def get_fields_in_model(instance): + """ + Returns the list of fields in the given model instance. Checks whether to use the official _meta API or use the raw + data. This method excludes many to many fields. + + :param instance: The model instance to get the fields for + :type instance: Model + :return: The list of fields for the given model (instance) + :rtype: list + """ + assert isinstance(instance, Model) + + # Check if the Django 1.8 _meta API is available + use_api = hasattr(instance._meta, 'get_fields') and callable(instance._meta.get_fields) + + if use_api: + return [f for f in instance._meta.get_fields() if track_field(f)] + return instance._meta.fields + + def model_instance_diff(old, new): """ Calculates the differences between two model instances. One of the instances may be ``None`` (i.e., a newly @@ -31,10 +74,10 @@ def model_instance_diff(old, new): fields = set(old._meta.fields + new._meta.fields) model_fields = auditlog.get_model_fields(new._meta.model) elif old is not None: - fields = set(old._meta.fields) + fields = set(get_fields_in_model(old)) model_fields = auditlog.get_model_fields(old._meta.model) elif new is not None: - fields = set(new._meta.fields) + fields = set(get_fields_in_model(new)) model_fields = auditlog.get_model_fields(new._meta.model) else: fields = set() @@ -57,7 +100,7 @@ def model_instance_diff(old, new): try: old_value = smart_text(getattr(old, field.name, None)) except ObjectDoesNotExist: - old_value = None + old_value = field.default if field.default is not NOT_PROVIDED else None try: new_value = smart_text(getattr(new, field.name, None)) @@ -65,7 +108,7 @@ def model_instance_diff(old, new): new_value = None if old_value != new_value: - diff[field.name] = (old_value, new_value) + diff[field.name] = (smart_text(old_value), smart_text(new_value)) if len(diff) == 0: diff = None diff --git a/src/auditlog/middleware.py b/src/auditlog/middleware.py index b62a9f4..14263cb 100644 --- a/src/auditlog/middleware.py +++ b/src/auditlog/middleware.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import threading import time from django.conf import settings @@ -9,6 +10,9 @@ from django.db.models.loading import get_model from auditlog.models import LogEntry +threadlocal = threading.local() + + class AuditlogMiddleware(object): """ Middleware to couple the request's user to log items. This is accomplished by currying the signal receiver with the @@ -20,19 +24,27 @@ class AuditlogMiddleware(object): Gets the current user from the request and prepares and connects a signal receiver with the user already attached to it. """ + # Initialize thread local storage + threadlocal.auditlog = { + 'signal_duid': (self.__class__, time.time()), + 'remote_addr': request.META.get('REMOTE_ADDR'), + } + + # In case of proxy, set 'original' address + if request.META.get('HTTP_X_FORWARDED_FOR'): + threadlocal.auditlog['remote_addr'] = request.META.get('HTTP_X_FORWARDED_FOR').split(',')[0] + + # Connect signal for automatic logging if hasattr(request, 'user') and hasattr(request.user, 'is_authenticated') and request.user.is_authenticated(): - user = request.user - request.auditlog_ts = time.time() - set_actor = curry(self.set_actor, user) - pre_save.connect(set_actor, sender=LogEntry, dispatch_uid=(self.__class__, request.auditlog_ts), weak=False) + set_actor = curry(self.set_actor, request.user) + pre_save.connect(set_actor, sender=LogEntry, dispatch_uid=threadlocal.auditlog['signal_duid'], weak=False) def process_response(self, request, response): """ Disconnects the signal receiver to prevent it from staying active. """ - # Disconnecting the signal receiver is required because it will not be garbage collected (non-weak reference) - if hasattr(request, 'auditlog_ts'): - pre_save.disconnect(sender=LogEntry, dispatch_uid=(self.__class__, request.auditlog_ts)) + if hasattr(threadlocal, 'auditlog'): + pre_save.disconnect(sender=LogEntry, dispatch_uid=threadlocal.auditlog['signal_duid']) return response @@ -40,8 +52,8 @@ class AuditlogMiddleware(object): """ Disconnects the signal receiver to prevent it from staying active in case of an exception. """ - if hasattr(request, 'auditlog_ts'): - pre_save.disconnect(sender=LogEntry, dispatch_uid=(self.__class__, request.auditlog_ts)) + if hasattr(threadlocal, 'auditlog'): + pre_save.disconnect(sender=LogEntry, dispatch_uid=threadlocal.auditlog['signal_duid']) return None @@ -58,3 +70,5 @@ class AuditlogMiddleware(object): auth_user_model = get_model('auth', 'user') if sender == LogEntry and isinstance(user, auth_user_model) and instance.actor is None: instance.actor = user + if hasattr(threadlocal, 'auditlog'): + instance.remote_addr = threading.local().auditlog['remote_addr'] diff --git a/src/auditlog/migrations/0003_logentry_remote_addr.py b/src/auditlog/migrations/0003_logentry_remote_addr.py new file mode 100644 index 0000000..948f63c --- /dev/null +++ b/src/auditlog/migrations/0003_logentry_remote_addr.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('auditlog', '0002_auto_support_long_primary_keys'), + ] + + operations = [ + migrations.AddField( + model_name='logentry', + name='remote_addr', + field=models.GenericIPAddressField(null=True, verbose_name='remote address', blank=True), + ), + ] diff --git a/src/auditlog/migrations/0004_logentry_detailed_object_repr.py b/src/auditlog/migrations/0004_logentry_detailed_object_repr.py new file mode 100644 index 0000000..2b9af2c --- /dev/null +++ b/src/auditlog/migrations/0004_logentry_detailed_object_repr.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import jsonfield.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('auditlog', '0003_logentry_remote_addr'), + ] + + operations = [ + migrations.AddField( + model_name='logentry', + name='additional_data', + field=jsonfield.fields.JSONField(null=True, blank=True), + ), + ] diff --git a/src/auditlog/models.py b/src/auditlog/models.py index 9f73e26..13c15b2 100644 --- a/src/auditlog/models.py +++ b/src/auditlog/models.py @@ -6,10 +6,13 @@ from django.conf import settings from django.contrib.contenttypes import generic from django.contrib.contenttypes.models import ContentType from django.db import models +from django.db.models import QuerySet, Q from django.utils.encoding import python_2_unicode_compatible, smart_text from django.utils.six import iteritems, integer_types from django.utils.translation import ugettext_lazy as _ +from jsonfield import JSONField + class LogEntryManager(models.Manager): """ @@ -38,6 +41,10 @@ class LogEntryManager(models.Manager): if isinstance(pk, integer_types): kwargs.setdefault('object_id', pk) + get_additional_data = getattr(instance, 'get_additional_data', None) + if callable(get_additional_data): + kwargs.setdefault('additional_data', get_additional_data()) + # Delete log entries with the same pk as a newly created model. This should only be necessary when an pk is # used twice. if kwargs.get('action', None) is LogEntry.Action.CREATE: @@ -70,6 +77,23 @@ class LogEntryManager(models.Manager): else: return self.filter(content_type=content_type, object_pk=pk) + def get_for_objects(self, queryset): + """ + Get log entries for the objects in the specified queryset. + + :param queryset: The queryset to get the log entries for. + :type queryset: QuerySet + :return: The LogEntry objects for the objects in the given queryset. + :rtype: QuerySet + """ + if not isinstance(queryset, QuerySet) or queryset.count() == 0: + return self.none() + + content_type = ContentType.objects.get_for_model(queryset.model) + primary_keys = queryset.values_list(queryset.model._meta.pk.name, flat=True) + + return self.filter(content_type=content_type).filter(Q(object_id__in=primary_keys) | Q(object_pk__in=primary_keys)).distinct() + def get_for_model(self, model): """ Get log entries for all objects of a specified type. @@ -142,7 +166,9 @@ class LogEntry(models.Model): action = models.PositiveSmallIntegerField(choices=Action.choices, verbose_name=_("action")) changes = models.TextField(blank=True, verbose_name=_("change message")) actor = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL, related_name='+', verbose_name=_("actor")) + remote_addr = models.GenericIPAddressField(blank=True, null=True, verbose_name=_("remote address")) timestamp = models.DateTimeField(auto_now_add=True, verbose_name=_("timestamp")) + additional_data = JSONField(blank=True, null=True, verbose_name=_("additional data")) objects = LogEntryManager() diff --git a/src/test_project/__init__.py b/src/auditlog_tests/__init__.py similarity index 100% rename from src/test_project/__init__.py rename to src/auditlog_tests/__init__.py diff --git a/src/testapp/models.py b/src/auditlog_tests/models.py similarity index 64% rename from src/testapp/models.py rename to src/auditlog_tests/models.py index 9a05d26..d301cf2 100644 --- a/src/testapp/models.py +++ b/src/auditlog_tests/models.py @@ -82,8 +82,36 @@ class SimpleExcludeModel(models.Model): history = AuditlogHistoryField() +class AdditionalDataIncludedModel(models.Model): + """ + A model where get_additional_data is defined which allows for logging extra + information about the model in JSON + """ + + label = models.CharField(max_length=100) + text = models.TextField(blank=True) + related = models.ForeignKey(SimpleModel) + + history = AuditlogHistoryField() + + def get_additional_data(self): + """ + Returns JSON that captures a snapshot of additional details of the + model instance. This method, if defined, is accessed by auditlog + manager and added to each logentry instance on creation. + """ + object_details = { + 'related_model_id': self.related.id, + 'related_model_text': self.related.text + } + return object_details + auditlog.register(SimpleModel) auditlog.register(AltPrimaryKeyModel) auditlog.register(ProxyModel) -auditlog.register(SimpleIncludeModel, include_fields=['label', ]) -auditlog.register(SimpleExcludeModel, exclude_fields=['text', ]) +auditlog.register(RelatedModel) +auditlog.register(ManyRelatedModel) +auditlog.register(ManyRelatedModel.related.through) +auditlog.register(SimpleIncludeModel, include_fields=['label']) +auditlog.register(SimpleExcludeModel, exclude_fields=['text']) +auditlog.register(AdditionalDataIncludedModel) diff --git a/src/auditlog_tests/test_settings.py b/src/auditlog_tests/test_settings.py new file mode 100644 index 0000000..fa2826f --- /dev/null +++ b/src/auditlog_tests/test_settings.py @@ -0,0 +1,24 @@ +""" +Settings file for the Auditlog test suite. +""" + +SECRET_KEY = 'test' + +INSTALLED_APPS = [ + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'auditlog', + 'auditlog_tests', +] + +MIDDLEWARE_CLASSES = ( + 'django.middleware.common.CommonMiddleware', + 'auditlog.middleware.AuditlogMiddleware', +) + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': 'auditlog_tests.db', + } +} diff --git a/src/testapp/tests.py b/src/auditlog_tests/tests.py similarity index 75% rename from src/testapp/tests.py rename to src/auditlog_tests/tests.py index 80e36dd..46b768d 100644 --- a/src/testapp/tests.py +++ b/src/auditlog_tests/tests.py @@ -6,8 +6,8 @@ from django.http import HttpResponse from django.test import TestCase, RequestFactory from auditlog.middleware import AuditlogMiddleware from auditlog.models import LogEntry -from testapp.models import SimpleModel, AltPrimaryKeyModel, ProxyModel, \ - SimpleIncludeModel, SimpleExcludeModel +from auditlog_tests.models import SimpleModel, AltPrimaryKeyModel, ProxyModel, \ + SimpleIncludeModel, SimpleExcludeModel, RelatedModel, ManyRelatedModel, AdditionalDataIncludedModel class SimpleModelTest(TestCase): @@ -75,6 +75,20 @@ class ProxyModelTest(SimpleModelTest): self.obj = ProxyModel.objects.create(text='I am not what you think.') +class ManyRelatedModelTest(TestCase): + """ + Test the behaviour of a many-to-many relationship. + """ + def setUp(self): + self.obj = ManyRelatedModel.objects.create() + self.rel_obj = ManyRelatedModel.objects.create() + self.obj.related.add(self.rel_obj) + + def test_related(self): + self.assertEqual(LogEntry.objects.get_for_objects(self.obj.related.all()).count(), self.rel_obj.history.count()) + self.assertEqual(LogEntry.objects.get_for_objects(self.obj.related.all()).first(), self.rel_obj.history.first()) + + class MiddlewareTest(TestCase): """ Test the middleware responsible for connecting and disconnecting the signals used in automatic logging. @@ -178,3 +192,28 @@ class SimpeExcludeModelTest(TestCase): sem.text = 'Short text' sem.save() self.assertTrue(sem.history.count() == 2, msg="There are two log entries") + + +class AdditionalDataModelTest(TestCase): + """Log additional data if get_additional_data is defined in the model""" + + def test_model_without_additional_data(self): + obj_wo_additional_data = SimpleModel.objects.create(text='No additional ' + 'data') + obj_log_entry = obj_wo_additional_data.history.get() + self.assertIsNone(obj_log_entry.additional_data) + + def test_model_with_additional_data(self): + related_model = SimpleModel.objects.create(text='Log my reference') + obj_with_additional_data = AdditionalDataIncludedModel( + label='Additional data to log entries', related=related_model) + obj_with_additional_data.save() + self.assertTrue(obj_with_additional_data.history.count() == 1, + msg="There is 1 log entry") + log_entry = obj_with_additional_data.history.get() + self.assertIsNotNone(log_entry.additional_data) + extra_data = log_entry.additional_data + self.assertTrue(extra_data['related_model_text'] == related_model.text, + msg="Related model's text is logged") + self.assertTrue(extra_data['related_model_id'] == related_model.id, + msg="Related model's id is logged") diff --git a/src/manage.py b/src/manage.py deleted file mode 100644 index 0fc36a3..0000000 --- a/src/manage.py +++ /dev/null @@ -1,10 +0,0 @@ -#!/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/runtests.py b/src/runtests.py new file mode 100644 index 0000000..b7df405 --- /dev/null +++ b/src/runtests.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +import os +import sys + +import django +from django.conf import settings +from django.test.utils import get_runner + +if __name__ == "__main__": + os.environ['DJANGO_SETTINGS_MODULE'] = 'auditlog_tests.test_settings' + django.setup() + TestRunner = get_runner(settings) + test_runner = TestRunner() + failures = test_runner.run_tests(["auditlog_tests"]) + sys.exit(bool(failures)) diff --git a/src/test_project/settings.py b/src/test_project/settings.py deleted file mode 100644 index 9666e46..0000000 --- a/src/test_project/settings.py +++ /dev/null @@ -1,68 +0,0 @@ -# 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 deleted file mode 100644 index cc4356b..0000000 --- a/src/test_project/urls.py +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 43ff066..0000000 --- a/src/test_project/wsgi.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -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 deleted file mode 100644 index e69de29..0000000