Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes #93 - Add 'changes_display_dict' property to 'LogEntry' model to display diff in a more human readable format #94

Merged
merged 31 commits into from
Sep 13, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a2f57b1
Add coverage
audiolion Jan 29, 2017
94ba93e
Fix bug where integer fields and others would be able to be parsed by…
audiolion Feb 20, 2017
b80b876
Add 'reverse_mapping_fields' to registry. This is a simple reversal
audiolion Feb 20, 2017
23cadae
Revert .coveragerc addition
audiolion Sep 6, 2017
8cc8f52
Revert Pull Request Guideliens in README
audiolion Sep 6, 2017
1e8a7b4
Revert changing test database name
audiolion Sep 6, 2017
fd5069e
Revert added newline
audiolion Sep 6, 2017
26283fc
Add django-multiselectfield test dep, remove other req file
audiolion Sep 6, 2017
cf66145
Add psycopg2 test dep
audiolion Sep 6, 2017
609ec28
Add postgres testing database and router
audiolion Sep 6, 2017
a746302
Add test for Postgres's ArrayField
audiolion Sep 6, 2017
5885953
Add basefield required arg to arrayfield
audiolion Sep 6, 2017
37b134a
Add postgres support to travis builds
audiolion Sep 6, 2017
17411f6
Add history field to PostgresArrayFieldModel
audiolion Sep 6, 2017
1aca807
Add support for multiple databases. LogEntry saves to same database o…
audiolion Sep 6, 2017
6c23385
If any literal evals fail default to None
audiolion Sep 6, 2017
29f1431
Add support for Postgres ArrayField in changes_display_dict
audiolion Sep 6, 2017
23236ce
Add tests for ArrayField
audiolion Sep 6, 2017
89e1868
Add comments to changes_display_dict function
audiolion Sep 6, 2017
0a8fdf9
Add more tests to increase coverage
audiolion Sep 6, 2017
61da565
Remove unneeded if check, the default argument to get provides the sa…
audiolion Sep 6, 2017
8fc05e4
Simplify date handling logic and format based upon localization or Dj…
audiolion Sep 11, 2017
9893d66
Update tests to tests against django settings
audiolion Sep 11, 2017
6578780
Add tests for when USE_L10N = True
audiolion Sep 11, 2017
591f596
Revert modifying the diff output and instead use mapping_fields in th…
audiolion Sep 11, 2017
26b3665
Remove reverse_mapping_fields now that we aren't changing the field n…
audiolion Sep 11, 2017
1930fb7
Update tests to test fallback on field.verbose_name
audiolion Sep 11, 2017
380e144
travis fix
audiolion Sep 11, 2017
5fb6197
Revert to old travis image while they are fixing issues with it
audiolion Sep 11, 2017
55795e1
Update usage.rst
audiolion Sep 13, 2017
27ae05e
Update usage.rst
audiolion Sep 13, 2017
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ dist: trusty
sudo: required
language: python
python: 3.6
group: deprecated-2017Q3
services:
- postgresql

env:
- TOX_ENV=py36-django-18
Expand Down
62 changes: 62 additions & 0 deletions docs/source/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,32 @@ For example, to exclude the field ``last_updated``, use::

Excluding fields

**Mapping fields**

If you have field names on your models that aren't intuitive or user friendly you can include a dictionary of field mappings
during the `register()` call.

.. code-block:: python

class MyModel(modelsModel):
sku = models.CharField(max_length=20)
version = models.CharField(max_length=5)
product = models.CharField(max_length=50, verbose_name='Product Name')
history = AuditLogHistoryField()

auditlog.register(MyModel, mapping_fields={'sku': 'Product No.', 'version': 'Product Revision'})

.. code-block:: python

log = MyModel.objects.first().history.latest()
log.changes_display_dict
// retrieves changes with keys Product No. Product Revision, and Product Name
// If you don't map a field it will fall back on the verbose_name

.. versionadded:: 0.5.0

You do not need to map all the fields of the model, any fields not mapped will fall back on their ``verbose_name``. Django provides a default ``verbose_name`` which is a "munged camel case version" so ``product_name`` would become ``Product Name`` by default.

Actors
------

Expand Down Expand Up @@ -95,6 +121,42 @@ your models is equally easy as any other field::
``False``, this defaults to ``True``. If your model has a custom primary key that is not an integer value,
:py:attr:`pk_indexable` needs to be set to ``False``. Keep in mind that this might slow down queries.

The :py:class:`AuditlogHistoryField` provides easy access to :py:class:`LogEntry` instances related to the model instance. Here is an example of how to use it:

.. code-block:: html

<div class="table-responsive">
<table class="table table-striped table-bordered">
<thead>
<tr>
<th>Field</th>
<th>From</th>
<th>To</th>
</tr>
</thead>
<tbody>
{% for key, value in mymodel.history.latest.changes_dict.iteritems %}
<tr>
<td>{{ key }}</td>
<td>{{ value.0|default:"None" }}</td>
<td>{{ value.1|default:"None" }}</td>
</tr>
{% empty %}
<p>No history for this item has been logged yet.</p>
{% endfor %}
</tbody>
</table>
</div>

If you want to display the changes in a more human readable format use the :py:class:`LogEntry`'s :py:attr:`changes_display_dict` instead. The :py:attr:`changes_display_dict` will make a few cosmetic changes to the data.

- Mapping Fields property will be used to display field names, falling back on ``verbose_name`` if no mapping field is present
- Fields with a value whose length is greater than 140 will be truncated with an ellipsis appended
- Date, Time, and DateTime fields will follow ``L10N`` formatting. If ``USE_L10N=False`` in your settings it will fall back on the settings defaults defined for ``DATE_FORMAT``, ``TIME_FORMAT``, and ``DATETIME_FORMAT``
- Fields with ``choices`` will be translated into their human readable form, this feature also supports choices defined on ``django-multiselectfield`` and Postgres's native ``ArrayField``

Check out the internals for the full list of attributes you can use to get associated :py:class:`LogEntry` instances.

Many-to-many relationships
--------------------------

Expand Down
2 changes: 2 additions & 0 deletions requirements-test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
coverage==4.3.4
tox>=1.7.0
codecov>=2.0.0
django-multiselectfield==0.1.8
psycopg2
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
django-jsonfield>=1.0.0
python-dateutil==2.6.0
65 changes: 63 additions & 2 deletions src/auditlog/models.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
from __future__ import unicode_literals

import json
import ast

from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist
from django.db import models
from django.db.models import QuerySet, Q
from django.utils import formats
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.fields import JSONField
from dateutil import parser


class LogEntryManager(models.Manager):
Expand Down Expand Up @@ -52,8 +56,9 @@ def log_create(self, instance, **kwargs):
self.filter(content_type=kwargs.get('content_type'), object_id=kwargs.get('object_id')).delete()
else:
self.filter(content_type=kwargs.get('content_type'), object_pk=kwargs.get('object_pk', '')).delete()

return self.create(**kwargs)
# save LogEntry to same database instance is using
db = instance._state.db
return self.create(**kwargs) if db is None or db == '' else self.using(db).create(**kwargs)
return None

def get_for_object(self, instance):
Expand Down Expand Up @@ -232,6 +237,62 @@ def changes_str(self, colon=': ', arrow=smart_text(' \u2192 '), separator='; '):

return separator.join(substrings)

@property
def changes_display_dict(self):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you inline-comment this? Than it is better understandable what it exactly does.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will do

"""
:return: The changes recorded in this log entry intended for display to users as a dictionary object.
"""
# Get the model and model_fields
from auditlog.registry import auditlog
model = self.content_type.model_class()
model_fields = auditlog.get_model_fields(model._meta.model)
changes_display_dict = {}
# grab the changes_dict and iterate through
for field_name, values in iteritems(self.changes_dict):
# try to get the field attribute on the model
try:
field = model._meta.get_field(field_name)
except FieldDoesNotExist:
changes_display_dict[field_name] = values
continue
values_display = []
# handle choices fields and Postgres ArrayField to get human readable version
if field.choices or hasattr(field, 'base_field') and getattr(field.base_field, 'choices', False):
choices_dict = dict(field.choices or field.base_field.choices)
for value in values:
try:
value = ast.literal_eval(value)
if type(value) is [].__class__:
values_display.append(', '.join([choices_dict.get(val, 'None') for val in value]))
else:
values_display.append(choices_dict.get(value, 'None'))
except ValueError:
values_display.append(choices_dict.get(value, 'None'))
except:
values_display.append(choices_dict.get(value, 'None'))
else:
field_type = field.get_internal_type()
for value in values:
# handle case where field is a datetime, date, or time type
if field_type in ["DateTimeField", "DateField", "TimeField"]:
try:
value = parser.parse(value)
if field_type == "DateField":
value = value.date()
elif field_type == "TimeField":
value = value.time()
value = formats.localize(value)
except ValueError:
pass
# check if length is longer than 140 and truncate with ellipsis
if len(value) > 140:
value = "{}...".format(value[:140])

values_display.append(value)
verbose_name = model_fields['mapping_fields'].get(field.name, field.verbose_name)
changes_display_dict[verbose_name] = values_display
return changes_display_dict


class AuditlogHistoryField(GenericRelation):
"""
Expand Down
6 changes: 4 additions & 2 deletions src/auditlog/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from django.db.models.signals import pre_save, post_save, post_delete
from django.db.models import Model
from django.utils.six import iteritems


class AuditlogModelRegistry(object):
Expand All @@ -24,7 +25,7 @@ def __init__(self, create=True, update=True, delete=True, custom=None):
if custom is not None:
self._signals.update(custom)

def register(self, model=None, include_fields=[], exclude_fields=[]):
def register(self, model=None, include_fields=[], exclude_fields=[], mapping_fields={}):
"""
Register a model with auditlog. Auditlog will then track mutations on this model's instances.

Expand All @@ -40,10 +41,10 @@ def registrar(cls):
if not issubclass(cls, Model):
raise TypeError("Supplied model is not a valid model.")

# Register the model and signals.
self._registry[cls] = {
'include_fields': include_fields,
'exclude_fields': exclude_fields,
'mapping_fields': mapping_fields,
}
self._connect_signals(cls)

Expand Down Expand Up @@ -110,6 +111,7 @@ def get_model_fields(self, model):
return {
'include_fields': self._registry[model]['include_fields'],
'exclude_fields': self._registry[model]['exclude_fields'],
'mapping_fields': self._registry[model]['mapping_fields'],
}


Expand Down
75 changes: 75 additions & 0 deletions src/auditlog_tests/models.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import uuid

from django.contrib.postgres.fields import ArrayField
from django.db import models
from auditlog.models import AuditlogHistoryField
from auditlog.registry import auditlog

from multiselectfield import MultiSelectField


@auditlog.register()
class SimpleModel(models.Model):
Expand Down Expand Up @@ -101,6 +104,18 @@ class SimpleExcludeModel(models.Model):
history = AuditlogHistoryField()


class SimpleMappingModel(models.Model):
"""
A simple model used for register's mapping_fields kwarg
"""

sku = models.CharField(max_length=100)
vtxt = models.CharField(verbose_name='Version', max_length=100)
not_mapped = models.CharField(max_length=100)

history = AuditlogHistoryField()


class AdditionalDataIncludedModel(models.Model):
"""
A model where get_additional_data is defined which allows for logging extra
Expand Down Expand Up @@ -133,6 +148,62 @@ class DateTimeFieldModel(models.Model):
"""
label = models.CharField(max_length=100)
timestamp = models.DateTimeField()
date = models.DateField()
time = models.TimeField()

history = AuditlogHistoryField()


class ChoicesFieldModel(models.Model):
"""
A model with a CharField restricted to a set of choices.
This model is used to test the changes_display_dict method.
"""
RED = 'r'
YELLOW = 'y'
GREEN = 'g'

STATUS_CHOICES = (
(RED, 'Red'),
(YELLOW, 'Yellow'),
(GREEN, 'Green'),
)

status = models.CharField(max_length=1, choices=STATUS_CHOICES)
multiselect = MultiSelectField(max_length=3, choices=STATUS_CHOICES, max_choices=3)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are you explicitly testing with this third party library?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I use this library and wanted auditlog to be compatible with it

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. If this is added to the tests I would also want the built-in PostgreSQL equivalent/replacement of it (https://docs.djangoproject.com/en/1.10/ref/contrib/postgres/fields/#arrayfield) since it is likely that people store lists that way as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah I am stuck on 1.8 so using it, I will add arrayfield support though, good idea!

multiplechoice = models.CharField(max_length=3, choices=STATUS_CHOICES)

history = AuditlogHistoryField()


class CharfieldTextfieldModel(models.Model):
"""
A model with a max length CharField and a Textfield.
This model is used to test the changes_display_dict
method's ability to truncate long text.
"""

longchar = models.CharField(max_length=255)
longtextfield = models.TextField()

history = AuditlogHistoryField()


class PostgresArrayFieldModel(models.Model):
"""
Test auditlog with Postgres's ArrayField
"""
RED = 'r'
YELLOW = 'y'
GREEN = 'g'

STATUS_CHOICES = (
(RED, 'Red'),
(YELLOW, 'Yellow'),
(GREEN, 'Green'),
)

arrayfield = ArrayField(models.CharField(max_length=1, choices=STATUS_CHOICES), size=3)

history = AuditlogHistoryField()

Expand All @@ -144,5 +215,9 @@ class DateTimeFieldModel(models.Model):
auditlog.register(ManyRelatedModel)
auditlog.register(ManyRelatedModel.related.through)
auditlog.register(SimpleExcludeModel, exclude_fields=['text'])
auditlog.register(SimpleMappingModel, mapping_fields={'sku': 'Product No.'})
auditlog.register(AdditionalDataIncludedModel)
auditlog.register(DateTimeFieldModel)
auditlog.register(ChoicesFieldModel)
auditlog.register(CharfieldTextfieldModel)
auditlog.register(PostgresArrayFieldModel)
38 changes: 38 additions & 0 deletions src/auditlog_tests/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
class PostgresRouter(object):
"""
A router to control all database operations on models for use with tests
pertaining to the postgres test database.
"""
def db_for_read(self, model, **hints):
"""
Attempts to read postgres models go to postgres db.
"""
if model._meta.model_name.startswith('postgres'):
return 'postgres'
return None

def db_for_write(self, model, **hints):
"""
Attempts to write postgres models go to postgres db.
"""
if model._meta.model_name.startswith('postgres'):
return 'postgres'
return None

def allow_relation(self, obj1, obj2, **hints):
"""
Allow relations if a model in the postgres app is involved.
"""
if obj1._meta.model_name.startswith('postgres') or \
obj2._meta.model_name.startswith('postgres'):
return True
return None

def allow_migrate(self, db, app_label, model_name=None, **hints):
"""
Make sure the postgres app only appears in the 'postgres db'
database.
"""
if model_name.startswith('postgres'):
return db == 'postgres'
return None
Loading