From 75752c8dfa80b9553c7080c7d04a99bf694b7555 Mon Sep 17 00:00:00 2001 From: Adam Thomas Date: Tue, 6 Oct 2015 11:42:19 +0100 Subject: [PATCH] [ci skip] Initial commit. Currently somewhat broken. --- .coveragerc | 5 ++ .travis.yml | 13 ++++ Makefile | 12 ++++ model_logging/__init__.py | 0 model_logging/migrations/0001_initial.py | 32 +++++++++ model_logging/migrations/__init__.py | 0 model_logging/models.py | 79 ++++++++++++++++++++++ model_logging/serializers.py | 26 +++++++ model_logging/tests/__init__.py | 0 model_logging/tests/factories.py | 24 +++++++ model_logging/tests/run.py | 49 ++++++++++++++ model_logging/tests/test_models.py | 64 ++++++++++++++++++ model_logging/tests/test_serializers.py | 27 ++++++++ model_logging/tests/test_views.py | 86 ++++++++++++++++++++++++ model_logging/tests/utils.py | 20 ++++++ model_logging/views.py | 77 +++++++++++++++++++++ requirements.txt | 15 +++++ setup.cfg | 2 + setup.py | 30 +++++++++ 19 files changed, 561 insertions(+) create mode 100644 .coveragerc create mode 100644 .travis.yml create mode 100644 Makefile create mode 100644 model_logging/__init__.py create mode 100644 model_logging/migrations/0001_initial.py create mode 100644 model_logging/migrations/__init__.py create mode 100644 model_logging/models.py create mode 100644 model_logging/serializers.py create mode 100644 model_logging/tests/__init__.py create mode 100644 model_logging/tests/factories.py create mode 100644 model_logging/tests/run.py create mode 100644 model_logging/tests/test_models.py create mode 100644 model_logging/tests/test_serializers.py create mode 100644 model_logging/tests/test_views.py create mode 100644 model_logging/tests/utils.py create mode 100644 model_logging/views.py create mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100644 setup.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..109f7f3 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,5 @@ +[run] +source = model_logging + +[report] +show_missing = True diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..ba8a5d2 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,13 @@ +language: python +sudo: false +python: + - 3.4 +cache: + directories: + - ~/.cache/pip +script: + make test +notifications: + email: false +install: + pip install -r requirements.txt diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bfc2da2 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +SHELL := /bin/bash + +help: + @echo "Usage:" + @echo " make release | Release to pypi." + +release: + @python setup.py register sdist bdist_wheel upload + +test: + @coverage run ./model_logging/tests/run.py + @coverage report diff --git a/model_logging/__init__.py b/model_logging/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/model_logging/migrations/0001_initial.py b/model_logging/migrations/0001_initial.py new file mode 100644 index 0000000..06c8352 --- /dev/null +++ b/model_logging/migrations/0001_initial.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import pgcrypto.fields +from django.conf import settings +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='LogEntry', + fields=[ + ('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)), + ('date_created', models.DateTimeField(default=django.utils.timezone.now)), + ('operation', models.CharField(max_length=255, choices=[('added', 'Added'), ('removed', 'Removed'), ('modified', 'Modified')])), + ('model_path', models.CharField(max_length=255)), + ('data', pgcrypto.fields.TextPGPPublicKeyField(default='')), + ('creator', models.ForeignKey(null=True, related_name='log_entries_created', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(null=True, related_name='log_entries', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('date_created',), + }, + ), + ] diff --git a/model_logging/migrations/__init__.py b/model_logging/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/model_logging/models.py b/model_logging/models.py new file mode 100644 index 0000000..9938e87 --- /dev/null +++ b/model_logging/models.py @@ -0,0 +1,79 @@ +from django.conf import settings +from django.db import models +from django.utils import timezone +from django.utils.translation import ugettext_lazy as _ +from pgcrypto.fields import TextPGPPublicKeyField + + +def get_model_path(model): + """Return the qualified path to a model class: 'model_logging.models.LogEntry'.""" + return '{}.{}'.format(model.__module__, model.__qualname__) + + +class LogEntryManager(models.Manager): + def log(self, log_entry_creator, operation, model, user, json_data): + """ + Create a log entry representing: + + `log_entry_creator` has performed `operation` on an instance of `model` belonging + to `user`. The instance's data is `json_data`. + """ + self.create( + creator=log_entry_creator, + operation=operation, + model_path=get_model_path(model), + user=user, + data=json_data, + ) + + def retrieve_for_user(self, model, user): + """Return all log entries for this user on this model.""" + return self.filter(user=user, model_path=get_model_path(model)) + + def retrieve_for_creator(self, model, creator): + """Return all log entries logged by this user on this model.""" + return self.filter(creator=creator, model_path=get_model_path(model)) + + +class LogEntry(models.Model): + """ + A change that has been made to an instance of another model. + + Stores: + - date_created: The datetime that the change was made. + - creator: Who made the change. + - operation: 'Added', 'Removed' or 'Modified'. + - model_path: The path to the model that's been operated-on. + - data: JSON data representing the model after the operation, or just before it in + the case of a 'Removed' operation. + - user: The user whose model instance it is. + """ + OPERATION_ADDED = 'added' + OPERATION_REMOVED = 'removed' + OPERATION_MODIFIED = 'modified' + OPERATION_CHOICES = ( + (OPERATION_ADDED, _('Added')), + (OPERATION_REMOVED, _('Removed')), + (OPERATION_MODIFIED, _('Modified')), + ) + + date_created = models.DateTimeField(default=timezone.now) + creator = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + related_name='log_entries_created', + ) + + operation = models.CharField(choices=OPERATION_CHOICES, max_length=255) + model_path = models.CharField(max_length=255) + data = TextPGPPublicKeyField(default='') + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + related_name='log_entries', + ) + + objects = LogEntryManager() + + class Meta: + ordering = ('date_created',) diff --git a/model_logging/serializers.py b/model_logging/serializers.py new file mode 100644 index 0000000..5fe8789 --- /dev/null +++ b/model_logging/serializers.py @@ -0,0 +1,26 @@ +import json + +from rest_framework.serializers import ( + CharField, + Field, + HyperlinkedModelSerializer, +) + +from . import models + + +class JSONField(Field): + def to_representation(self, obj): + return json.loads(obj) + + +class LogEntrySerializer(HyperlinkedModelSerializer): + data = JSONField() + creator = CharField(source='creator.name') + operation_label = CharField(source='get_operation_display', read_only=True) + + class Meta: + model = models.LogEntry + fields = ('url', 'date_created', 'creator', 'operation', 'operation_label', 'data') + read_only_fields = fields + view_name = 'medication-history-detail' diff --git a/model_logging/tests/__init__.py b/model_logging/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/model_logging/tests/factories.py b/model_logging/tests/factories.py new file mode 100644 index 0000000..155b1eb --- /dev/null +++ b/model_logging/tests/factories.py @@ -0,0 +1,24 @@ +import json + +import factory +from django.contrib.auth.models import User + +from model_logging import models + + +class UserFactory(factory.DjangoModelFactory): + username = factory.Sequence('User {}'.format) + + class Meta: + model = User + + +class LogEntryFactory(factory.DjangoModelFactory): + user = factory.SubFactory(UserFactory) + creator = factory.SubFactory(UserFactory) + operation = models.LogEntry.OPERATION_ADDED + model_path = models.get_model_path(models.LogEntry) + data = json.dumps({'item': 42}) + + class Meta: + model = models.LogEntry diff --git a/model_logging/tests/run.py b/model_logging/tests/run.py new file mode 100644 index 0000000..d065072 --- /dev/null +++ b/model_logging/tests/run.py @@ -0,0 +1,49 @@ +#! /usr/bin/env python +"""From http://stackoverflow.com/a/12260597/400691.""" +import sys + +import dj_database_url +import django +from colour_runner.django_runner import ColourRunnerMixin +from django.conf import settings +from django.test.runner import DiscoverRunner + + +settings.configure( + DATABASES={'default': dj_database_url.config( + default='postgres://localhost/model_logging', + )}, + DEFAULT_FILE_STORAGE='inmemorystorage.InMemoryStorage', + MIDDLEWARE_CLASSES=(), + PASSWORD_HASHERS=('django.contrib.auth.hashers.MD5PasswordHasher',), + SITE_ID=1, + + INSTALLED_APPS=( + 'model_logging', + + 'django', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + ), + + # Dummy pgcrypto configuration + PGCRYPTO_KEY='so_secure_you_guys', + PUBLIC_PGP_KEY='as_is_this', + PRIVATE_PGP_KEY='ssssshhhh', +) + + +django.setup() + + +class TestRunner(ColourRunnerMixin, DiscoverRunner): + """Enable coloured output for tests.""" + + +test_runner = TestRunner(verbosity=1) +failures = test_runner.run_tests(['model_logging']) +if failures: + sys.exit(1) diff --git a/model_logging/tests/test_models.py b/model_logging/tests/test_models.py new file mode 100644 index 0000000..1423ecc --- /dev/null +++ b/model_logging/tests/test_models.py @@ -0,0 +1,64 @@ +import json + +from django.test import TestCase + +from . import factories +from model_logging import models + + +class TestLogEntry(TestCase): + model = models.LogEntry + + def test_fields(self): + expected = [ + 'id', + 'date_created', + 'creator', + 'creator_id', + 'operation', + 'data', + 'user', + 'user_id', + 'model_path', + ] + + fields = self.model._meta.get_all_field_names() + self.assertCountEqual(fields, expected) + + +class TestLogEntryManager(TestCase): + manager = models.LogEntry.objects + + def test_log(self): + """Assert log() creates a LogEntry object correctly.""" + user = factories.UserFactory.create() + data = { + 'log_entry_creator': user, + 'operation': models.LogEntry.OPERATION_ADDED, + 'model': models.LogEntry, + 'user': user, + 'json_data': json.dumps({'item': 42}), + } + + self.manager.log(**data) + + entry = self.manager.get() + self.assertEqual(entry.creator, data['log_entry_creator']) + self.assertEqual(entry.operation, data['operation']) + self.assertEqual(entry.model_path, 'model_logging.models.LogEntry') + self.assertEqual(entry.user, data['user']) + self.assertEqual(entry.data, data['json_data']) + + def test_retrieve_for_user(self): + entry, _ = factories.LogEntryFactory.create_batch(2) + model = models.LogEntry + + retrieved = self.manager.retrieve_for_user(model=model, user=entry.user) + self.assertEqual(list(retrieved), [entry]) + + def test_retrieve_for_creator(self): + entry, _ = factories.LogEntryFactory.create_batch(2) + model = models.LogEntry + + retrieved = self.manager.retrieve_for_creator(model=model, creator=entry.creator) + self.assertEqual(list(retrieved), [entry]) diff --git a/model_logging/tests/test_serializers.py b/model_logging/tests/test_serializers.py new file mode 100644 index 0000000..ae96d72 --- /dev/null +++ b/model_logging/tests/test_serializers.py @@ -0,0 +1,27 @@ +from incuna_test_utils.testcases.api_request import BaseAPIRequestTestCase + +from . import factories +from .utils import get_log_entry_data +from model_logging.serializers import LogEntrySerializer + + +class TestLogEntrySerializer(BaseAPIRequestTestCase): + user_factory = factories.UserFactory + + @classmethod + def setUpTestData(cls): + cls.user = factories.UserFactory.create() + cls.entry = factories.LogEntryFactory.create(user=cls.user, creator=cls.user) + + def setUp(self): + self.context = {'request': self.create_request(user=self.user)} + + def test_serialize(self): + serializer = LogEntrySerializer(instance=self.entry, context=self.context) + expected = get_log_entry_data(self.entry) + self.assertEqual(serializer.data, expected) + + def test_serialize_data_json(self): + """Ensure `data` is decoded as `json`.""" + serializer = LogEntrySerializer(instance=self.entry, context=self.context) + self.assertIsInstance(serializer.data['data'], dict) diff --git a/model_logging/tests/test_views.py b/model_logging/tests/test_views.py new file mode 100644 index 0000000..c19cce9 --- /dev/null +++ b/model_logging/tests/test_views.py @@ -0,0 +1,86 @@ +import simplejson +from incuna_test_utils.testcases.api_request import BaseAPIRequestTestCase + +from .factories import UserFactory +from model_logging import models, views + +FAKE_DATA = {'item': 42} + + +class FakeSerializer: + """A stub class for mocking data generated by serializers for logging.""" + data = FAKE_DATA + validated_data = FAKE_DATA + instance = FAKE_DATA # Can be anything - the contents will not be used. + + def __init__(self, **kwargs): + """Ignore any kwargs passed in - we're always presenting dummy data.""" + + +class FakeViewSet(views.LoggingMethodMixin): + """A basic subclass of LoggingMethodMixin with minimal required attributes.""" + model = FakeSerializer # Any class will do here. + serializer_class = FakeSerializer + + def __init__(self, request, patient): + self.request = request + self.patient = patient + + def _get_logging_user(self): + return self.patient + + +class TestLoggingMethodMixin(BaseAPIRequestTestCase): + user_factory = UserFactory + + @classmethod + def setUpTestData(cls): + cls.user = cls.user_factory.create() + + def setUp(self): + self.request = self.create_request(user=self.user) + self.viewset_object = FakeViewSet(self.request, self.user) + + def get_expected_entry_data(self): + return { + 'data': FAKE_DATA, + 'user': self.user, + 'creator': self.user, + 'model_path': 'model_logging.tests.test_views.FakeSerializer', + } + + def test_log_on_create(self): + serializer = FakeSerializer() + self.viewset_object._log_on_create(serializer) + + entry = models.LogEntry.objects.get() + expected_entry = self.get_expected_entry_data() + self.assertEqual(entry.operation, models.LogEntry.OPERATION_ADDED) + self.assertEqual(simplejson.loads(entry.data), expected_entry['data']) + self.assertEqual(entry.user, expected_entry['user']) + self.assertEqual(entry.creator, expected_entry['creator']) + self.assertEqual(entry.model_path, expected_entry['model_path']) + + def test_log_on_update(self): + serializer = FakeSerializer() + self.viewset_object._log_on_update(serializer) + + entry = models.LogEntry.objects.get() + expected_entry = self.get_expected_entry_data() + self.assertEqual(entry.operation, models.LogEntry.OPERATION_MODIFIED) + self.assertEqual(simplejson.loads(entry.data), expected_entry['data']) + self.assertEqual(entry.user, expected_entry['user']) + self.assertEqual(entry.creator, expected_entry['creator']) + self.assertEqual(entry.model_path, expected_entry['model_path']) + + def test_log_on_destroy(self): + instance = FAKE_DATA + self.viewset_object._log_on_destroy(instance) + + entry = models.LogEntry.objects.get() + expected_entry = self.get_expected_entry_data() + self.assertEqual(entry.operation, models.LogEntry.OPERATION_REMOVED) + self.assertEqual(simplejson.loads(entry.data), expected_entry['data']) + self.assertEqual(entry.user, expected_entry['user']) + self.assertEqual(entry.creator, expected_entry['creator']) + self.assertEqual(entry.model_path, expected_entry['model_path']) diff --git a/model_logging/tests/utils.py b/model_logging/tests/utils.py new file mode 100644 index 0000000..362cb76 --- /dev/null +++ b/model_logging/tests/utils.py @@ -0,0 +1,20 @@ +import json + + +def iso_8601(datetime): + if datetime is None: + return datetime + value = datetime.isoformat() + if value.endswith('+00:00'): + value = value[:-6] + 'Z' + return value + + +def get_log_entry_data(entry): + return { + 'date_created': iso_8601(entry.date_created), + 'creator': entry.creator.name, + 'operation': entry.operation, + 'operation_label': entry.get_operation_display(), + 'data': json.loads(entry.data), + } diff --git a/model_logging/views.py b/model_logging/views.py new file mode 100644 index 0000000..ebf85f1 --- /dev/null +++ b/model_logging/views.py @@ -0,0 +1,77 @@ +import simplejson as json + +from .models import get_model_path, LogEntry + + +class LoggingMethodMixin: + """ + Adds methods that log changes made to users' data. + + To use this, subclass it and ModelViewSet, and override _get_logging_user(). Ensure + that the viewset you're mixing this into has `self.model` and `self.serializer_class` + attributes. + """ + + def _get_logging_user(self): + """Return the user of this logged item. Needs overriding in any subclass.""" + raise NotImplementedError + + def extra_data(self, data): + """Hook to append more data.""" + return {} + + def log(self, operation, data): + data.update(self.extra_data(data)) + LogEntry.objects.create( + creator=self.request.user, + data=json.dumps(data, use_decimal=True), + model_path=get_model_path(self.model), + operation=operation, + user=self._get_logging_user(), + ) + + def _log_on_create(self, serializer): + """Log the up-to-date serializer.data.""" + self.log(operation=LogEntry.OPERATION_ADDED, data=serializer.validated_data) + + def _log_on_update(self, serializer): + """Log data from the updated serializer instance.""" + self.log(operation=LogEntry.OPERATION_MODIFIED, data=serializer.data) + + def _log_on_destroy(self, instance): + """Log data from the instance before it gets deleted.""" + data = self.serializer_class( + instance=instance, + context={'request': self.request}, + ).data + self.log(operation=LogEntry.OPERATION_REMOVED, data=data) + + +class LoggingViewSetMixin(LoggingMethodMixin): + """ + A viewset that logs changes made to users' data. + + To use this, subclass it and ModelViewSet, and override _get_logging_user(). Ensure + that the viewset you're mixing this into has `self.model` and `self.serializer_class` + attributes. + + If you modify any of the following methods, be sure to call super() or the + corresponding _log_on_X method: + - perform_create + - perform_update + - perform_destroy + """ + def perform_create(self, serializer): + """Create an object and log its data.""" + super().perform_create(serializer) + self._log_on_create(serializer) + + def perform_update(self, serializer): + """Update the instance and log the updated data.""" + super().perform_update(serializer) + self._log_on_update(serializer) + + def perform_destroy(self, instance): + """Delete the instance and log the deletion.""" + super().perform_destroy(instance) + self._log_on_destroy(instance) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..22854e8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +-e . +colour-runner==0.0.4 +coverage==4.0.0 +dj-database-url==0.3 +django==1.8.5 +django-pgcrypto-fields==1.0.0 +djangorestframework==3.2.4 +factory_boy==2.5.2 +flake8==2.2.3 +flake8-docstrings==0.2.1 +flake8-import-order==0.5.1 +incuna-test-utils==6.3.1 +psycopg2==2.6.1 +simplejson==3.8.0 +wheel==0.24.0 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..0a8df87 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[wheel] +universal = 1 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..fb37010 --- /dev/null +++ b/setup.py @@ -0,0 +1,30 @@ +from setuptools import find_packages, setup + + +version = '0.1.0' + + +setup( + name='django-model-logging', + packages=find_packages(), + include_package_data=True, + version=version, + description='Generic and secure logging for changes to Django model instances', + long_description=open('README.md').read(), + author='Incuna', + author_email='admin@incuna.com', + url='https://github.com/incuna/django-model-logging/', + install_requires=[], + extras_require={}, + zip_safe=False, + license='BSD', + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Framework :: Django', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: BSD License', + 'Natural Language :: English', + 'Programming Language :: Python :: 3.4', + 'Topic :: Software Development :: Testing', + ], +)