Merge branch 'master' into release-0.3.0

Conflicts:
	src/auditlog/diff.py
This commit is contained in:
Jan-Jelle Kester 2015-07-22 01:06:32 +02:00
commit dab0342cf6
20 changed files with 255 additions and 148 deletions

View file

@ -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

View file

@ -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 Djangos built in functionality to keep the list of dependencies as short as possible. Also, Auditlog aims to be fast and simple to use.

View file

@ -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::

View file

@ -1 +1,2 @@
Django>=1.7
django-jsonfield>=0.9.13

View file

@ -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',
]
)

View file

@ -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

View file

@ -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']

View file

@ -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),
),
]

View file

@ -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),
),
]

View file

@ -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()

View file

@ -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)

View file

@ -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',
}
}

View file

@ -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")

View file

@ -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)

15
src/runtests.py Normal file
View file

@ -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))

View file

@ -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,
},
}
}

View file

@ -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)),
)

View file

@ -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)