Skip to content

Commit

Permalink
Merge pull request #1 from incuna/it-begins
Browse files Browse the repository at this point in the history
Add model_logging module and configuration
  • Loading branch information
Kévin Etienne committed Oct 6, 2015
2 parents ca91e6c + d82c77f commit 26be183
Show file tree
Hide file tree
Showing 23 changed files with 821 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[run]
source = model_logging

[report]
show_missing = True
13 changes: 13 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
100 changes: 100 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,101 @@
# django-model-logging

Logging for changes made to Django model instances.

## Installation

`pip install django-model-logging`

Add `model_logging` to your `INSTALLED_APPS`.

This library uses [`django-pgcrypto-fields`](https://github.com/incuna/django-pgcrypto-fields),
which means you will need PGP public and private keys. See the `settings.configure()`
block in `model_logging/tests/run.py` for an (extremely insecure) example.

## Usage

#### Low-level use

```python
from model_logging.models import LogEntry

LogEntry.objects.log(
log_entry_creator,
operation,
model,
user,
json_data,
)
```

The parameters are as follows:

* `log_entry_creator`: The user who made this change.
* `operation`: One of `LogEntry.OPERATION_ADDED`, `LogEntry.OPERATION_REMOVED`,
or `LogEntry.OPERATION_MODIFIED`.
* `model`: The path to the model being logged (e.g. 'users.models.User').
* `user`: The user to which the model instance belongs.
* `json_data`: A full or partial JSON representation of data on the model instance.

#### Medium-level use

To add methods to a view(set) that can be used to straightforwardly log changes:

```python
from model_logging.views import LoggingMethodMixin

class AViewOrViewset(LoggingMethodMixin, ModelViewSet):
def _get_logging_user(self):
# Override this method to return a suitable
# value for the `user` parameter above.
return self.instance.user # or similar

def extra_data(self):
# Overriding this isn't mandatory, it's just a hook
return {'any additional data': 'you wish to log'}
```

The class now has access to the following:

```python
def log(self, operation, data):
# A simplified version of LogEntry.objects.log,
# with some parameters pre-filled. The return
# value of extra_data() will be added to the
# supplied data.

def _log_on_create(self, serializer):
# Log a LogEntry.OPERATION_ADDED change, using
# the log() method above.

def _log_on_update(self, serializer):
# Log a LogEntry.OPERATION_MODIFIED change, using
# the log() method above.

def _log_on_destroy(self, instance):
# Log a LogEntry.OPERATION_DELETED change, using
# the log() method above.
```

More abstract still:

#### High-level use

A viewset can log its own changes!

```python
from model_logging.views import LoggingViewSetMixin

class AVeryShinyViewSet(LoggingViewSetMixin, ModelViewSet):
def _get_logging_user(self):
# Override this method to return a suitable
# value for the `user` parameter above.
return self.instance.user # or similar

def extra_data(self):
# Overriding this isn't mandatory, it's just a hook
return {'any additional data': 'you wish to log'}
```

This mixin is a wrapper around `LoggingMethodMixin` that calls the appropriate logging
methods during `perform_create`, `perform_update` and `perform_destroy`.
5 changes: 5 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Changelog

### v0.1.0

* Add LogEntry model, together with a supporting serializer and view mixins.
Empty file added model_logging/__init__.py
Empty file.
32 changes: 32 additions & 0 deletions model_logging/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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',),
},
),
]
Empty file.
79 changes: 79 additions & 0 deletions model_logging/models.py
Original file line number Diff line number Diff line change
@@ -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',)
27 changes: 27 additions & 0 deletions model_logging/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import json

from rest_framework.serializers import (
CharField,
Field,
ModelSerializer,
)

from . import models


class JSONField(Field):
def to_representation(self, obj):
return json.loads(obj)


class LogEntrySerializer(ModelSerializer):
data = JSONField()
creator = CharField(source='creator.email')
operation_label = CharField(source='get_operation_display', read_only=True)

class Meta:
model = models.LogEntry
fields = (
'date_created', 'creator', 'operation', 'operation_label', 'data',
)
read_only_fields = fields
Empty file added model_logging/tests/__init__.py
Empty file.
24 changes: 24 additions & 0 deletions model_logging/tests/factories.py
Original file line number Diff line number Diff line change
@@ -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
57 changes: 57 additions & 0 deletions model_logging/tests/keys/private.key
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
-----BEGIN PGP PRIVATE KEY BLOCK-----
Version: GnuPG v1

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
=kJd7
-----END PGP PRIVATE KEY BLOCK-----
30 changes: 30 additions & 0 deletions model_logging/tests/keys/public.key
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
Version: GnuPG v1

mQENBFRJDkwBCADVJDHy7al1P8urXWDkTSMfb6vGivD6vW0N2dDFixxKHDlOej52
OB2F/B/WLFpdXFiKxLSTIWEp/d0AEtJQrNMuMCzUKT08h3YU5ypiBdGIY7fqWwi9
G18ihm29/ygPni1XzG02DCto/pYD++RieIgGPOm8xlLrHwemehxhiOQ1K9eds8wd
d0awXtiKdClaxGi24soyBxBC/tLde2Sgyh5y24RyXsmvZxORwWBM5tRZwOxwsQym
Akt2M7LIGyQVgbLmTe4k5u3Uqv/t5tVK8OdDDbbXVDwJbj4n1Jol68cutHTAahpY
mkOAuqLp7ZWqx/DsYYoJhv/G7dhcpS+CfNs3ABEBAAG0HlRlc3QgS2V5IDxleGFt
cGxlQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCVEkOTAIbAwYLCQgHAwIGFQgCCQoL
BBYCAwECHgECF4AACgkQY3+K26BCfZUZPwgAzM03DFGATxldBcia63gK+vsEoDS+
xCtHaDpNvestHDlCd3bHU13LcLAXUD9E/s4LpHF+lMACCyscdeTriA/x0IJBIuq9
4KXMHJPpsh+eyGn09zhSfcfoMnQqgD682tvtBAD+oPb7Z/q+FNbKH26Pq6Daw1Ap
gDEy9AMabBoGx8db878OFk6eNEZliF38QqRplqr/hsjRq4nwkAAT3qj1zufTTN90
bHhXt1BP7z5bDv0z1fp6duFzPbfyUEjkhbFKhOgJ6p01IOLnnXEn6TGM55sKqW1W
dFX276anyb1gJm79E3gymnuwanC31QNSmlXpSNbiG8rbUfiJ40HfUf/lKrkBDQRU
SQ5MAQgAnztdietX1+rTHqyjK9yDYP+rp5NBL2b98SzTjQpFv68cjevjYy/R7VqI
rRx1qyrit470TYk9iOH2KBYscLLYylNCfpWfv2gIziZfX5Xxb+BxZPXkrq34ux5M
1BHgGvZg8XvtJLSbX5SnkibUHSotCD578MgfWzfTfH4aeVwrZVCKb5BRxZHx0ZA9
q9tNgYWoadhcKNWmT8yn28JW/ME91v1K/nSDJKd8qG9qOEy0XtGcYOZh6qZZcXI3
BRpyqSUN4G2TvzccYeEbw6hHH2VjKCetkt9412+2Qg+IR8nTehwPuY3hOZqYghN/
rT6nuPJSzb/UJ+G6vnb2r0I4To3WUQARAQABiQEfBBgBAgAJBQJUSQ5MAhsMAAoJ
EGN/itugQn2VlBcH/2dRT5yxfS9nTTimfk/wnyXnB+XgqbYOr7H1LFueMulCTSrQ
sIebVUyIKY2Txzm8UxswMBxRzoIMy8g6NxjwuUzm3/w2evdHdO1mwqNMsDNBskPD
CzJE4TLftOeVq7Jh268yLFiAxaAbULyBzr7yLVAHrGBDhjediCoy9KHijIHdM04v
nhgQkV6UpLez7Am5B0Pqd+0kFcoe3IW5cYvphhWVJ4O+w95jujrsvVB/+hg4VPgi
KMgIdXIAmJQuflAU97ilYDIXXmdQV2yUOsnaMW8JBsu2hsxvk3n+M9BNS6GBk40w
HGCOLDrMD1U4Zjwsu58qQGp4nQ9rR54y5El2iJU=
=c2SM
-----END PGP PUBLIC KEY BLOCK-----
Loading

0 comments on commit 26be183

Please sign in to comment.