From a2f57b16c7feb80611a3f039a3c4a89289dcf7a2 Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Sun, 29 Jan 2017 11:36:59 -0500 Subject: [PATCH 01/31] Add coverage Fixes #93 - Add 'changes_display_dict' property to 'LogEntry' model to display diff in a more human readable format 'changes_display_dict' currently handles fields with choices, long textfields and charfields, and datetime fields. Fields with choices display in the diff with their human readable text. Textfields and Charfields longer than 140 characters are truncated with an ellipsis appended. Datetime fields are rendered with the format 'Aug. 21, 2017, 02:25pm' A new kwarg was added to 'AuditlogModelRegistry' called 'mapping_fields'. The kwarg allows the user to map the fields in the model to a more human readable or intuitive name. If a field isn't mapped it will default to the name as defined in the model. Partial mapping is supported, all fields do not need to be mapped to use the feature. Tests added for 'changes_display_dict' Tests added for 'mapping_fields' property Added python 3.5 support Updated Documentation Update .travis.yml to use requirements_test.txt as well in build --- .coveragerc | 13 ++++ .gitignore | 1 + README.md | 13 ++++ docs/source/usage.rst | 44 ++++++++++++ requirements.txt | 1 + requirements_test.txt | 2 + src/auditlog/diff.py | 5 +- src/auditlog/models.py | 41 +++++++++++ src/auditlog/registry.py | 4 +- src/auditlog_tests/models.py | 52 ++++++++++++++ src/auditlog_tests/test_settings.py | 3 +- src/auditlog_tests/tests.py | 104 +++++++++++++++++++++++++++- 12 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 .coveragerc create mode 100644 requirements_test.txt diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..b5e57620 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,13 @@ +[run] +branch = true + +[report] +omit = + *site-packages* + *auditlog_tests* + *auditlog/migrations* + src/runtests.py +show_missing = True +exclude_lines = + pragma: no cover + raise NotImplementedError diff --git a/.gitignore b/.gitignore index e6d60c81..05b95e16 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ venv/ .tox/ local_settings.py +.coverage diff --git a/README.md b/README.md index ed51710c..d79d187c 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,16 @@ Contribute ---------- If you have great ideas for Auditlog, or if you like to improve something, feel free to fork this repository and/or create a pull request. I'm open for suggestions. If you like to discuss something with me (about Auditlog), please open an issue. + +Pull Request Guidelines +----------------------- + +Before you submit a pull request, check that it meets these guidelines: + +1. The pull request should include tests. +2. If the pull request adds functionality, the docs should be updated. Put + your new functionality into a function with a docstring, and update relevant + documentation. +3. The pull request should work for Python 2.7, 3.4+, and with Django>=1.8. Check + https://travis-ci.org/jjkester/django-auditlog/pull_requests + and make sure that the tests pass for all supported Python versions. diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 7b659a03..d992aa39 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -50,6 +50,19 @@ 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 + + auditlog.register(MyModel, mapping_fields={'sku': 'Product No.', 'version': 'Product Revision'}) + +.. versionadded:: 0.5.0 + +You do not need to map all the fields of the model, any fields not mapped will be displayed as they are defined in the model. + Actors ------ @@ -95,6 +108,37 @@ 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 + +
+ + + + + + + + + + {% for key, value in mymodel.history.latest.changes_dict.iteritems %} + + + + + + {% empty %} +

No history for this item has been logged yet.

+ {% endfor %} + +
FieldFromTo
{{ key }}{{ value.0|default:"None" }}{{ value.1|default:"None" }}
+
+ +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 translate ``choices`` fields into their human readable form, display timestamps in the form ``Jun. 31, 2017 12:55pm``, and truncate text greater than 140 characters to 140 characters with an ellipsis appended. + +Check out the internals for the full list of attributes you can use to get associated :py:class:`LogEntry` instances. + Many-to-many relationships -------------------------- diff --git a/requirements.txt b/requirements.txt index 30cbe61c..300aef56 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ django-jsonfield>=1.0.0 +python-dateutil==2.6.0 diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 00000000..e3fb5a2b --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,2 @@ +coverage==4.2 +django-multiselectfield==0.1.5 diff --git a/src/auditlog/diff.py b/src/auditlog/diff.py index e73a15a5..c92c18c1 100644 --- a/src/auditlog/diff.py +++ b/src/auditlog/diff.py @@ -135,7 +135,10 @@ def model_instance_diff(old, new): new_value = get_field_value(new, field) if old_value != new_value: - diff[field.name] = (smart_text(old_value), smart_text(new_value)) + if model_fields: + diff[model_fields['mapping_fields'].get(field.name, field.name)] = (smart_text(old_value), smart_text(new_value)) + else: + diff[field.name] = (smart_text(old_value), smart_text(new_value)) if len(diff) == 0: diff = None diff --git a/src/auditlog/models.py b/src/auditlog/models.py index e369a53b..5ea82c57 100644 --- a/src/auditlog/models.py +++ b/src/auditlog/models.py @@ -1,6 +1,8 @@ from __future__ import unicode_literals import json +import ast +from datetime import datetime from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation @@ -12,6 +14,7 @@ from django.utils.translation import ugettext_lazy as _ from jsonfield.fields import JSONField +from dateutil import parser class LogEntryManager(models.Manager): @@ -232,6 +235,43 @@ def changes_str(self, colon=': ', arrow=smart_text(' \u2192 '), separator='; '): return separator.join(substrings) + @property + def changes_display_dict(self): + """ + :return: The changes recorded in this log entry intended for display to users as a dictionary object. + """ + model = self.content_type.model_class() + changes_display_dict = {} + for field_name, values in iteritems(self.changes_dict): + field = model._meta.get_field(field_name) + values_display = [] + if field.choices: + choices_dict = dict(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')) + else: + for value in values: + try: + value = parser.parse(value) + value = value.strftime("%b %d, %Y %I:%M %p") + except ValueError: + pass + + if len(value) > 140: + value = "{}...".format(value[:140]) + + values_display.append(value) + + changes_display_dict[field_name] = values_display + return changes_display_dict + class AuditlogHistoryField(GenericRelation): """ @@ -260,6 +300,7 @@ def __init__(self, pk_indexable=True, **kwargs): kwargs['content_type_field'] = 'content_type' super(AuditlogHistoryField, self).__init__(**kwargs) + # South compatibility for AuditlogHistoryField try: from south.modelsinspector import add_introspection_rules diff --git a/src/auditlog/registry.py b/src/auditlog/registry.py index 3e3efcdc..edc98359 100644 --- a/src/auditlog/registry.py +++ b/src/auditlog/registry.py @@ -24,7 +24,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. @@ -44,6 +44,7 @@ def registrar(cls): self._registry[cls] = { 'include_fields': include_fields, 'exclude_fields': exclude_fields, + 'mapping_fields': mapping_fields, } self._connect_signals(cls) @@ -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'], } diff --git a/src/auditlog_tests/models.py b/src/auditlog_tests/models.py index b7d57495..577e6dd6 100644 --- a/src/auditlog_tests/models.py +++ b/src/auditlog_tests/models.py @@ -4,6 +4,8 @@ from auditlog.models import AuditlogHistoryField from auditlog.registry import auditlog +from multiselectfield import MultiSelectField + @auditlog.register() class SimpleModel(models.Model): @@ -101,6 +103,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(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 @@ -137,6 +151,41 @@ class DateTimeFieldModel(models.Model): 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) + 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() + + auditlog.register(AltPrimaryKeyModel) auditlog.register(UUIDPrimaryKeyModel) auditlog.register(ProxyModel) @@ -144,5 +193,8 @@ 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.', 'vtxt': 'Version'}) auditlog.register(AdditionalDataIncludedModel) auditlog.register(DateTimeFieldModel) +auditlog.register(ChoicesFieldModel) +auditlog.register(CharfieldTextfieldModel) diff --git a/src/auditlog_tests/test_settings.py b/src/auditlog_tests/test_settings.py index 711a844e..6bb99b59 100644 --- a/src/auditlog_tests/test_settings.py +++ b/src/auditlog_tests/test_settings.py @@ -9,6 +9,7 @@ 'django.contrib.contenttypes', 'auditlog', 'auditlog_tests', + 'multiselectfield', ] MIDDLEWARE_CLASSES = ( @@ -19,7 +20,7 @@ DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': 'auditlog_tests.db', + 'NAME': 'auditlog_test.db', } } diff --git a/src/auditlog_tests/tests.py b/src/auditlog_tests/tests.py index 22a9f0c8..a36503d6 100644 --- a/src/auditlog_tests/tests.py +++ b/src/auditlog_tests/tests.py @@ -10,8 +10,9 @@ from auditlog.models import LogEntry from auditlog.registry import auditlog from auditlog_tests.models import SimpleModel, AltPrimaryKeyModel, UUIDPrimaryKeyModel, \ - ProxyModel, SimpleIncludeModel, SimpleExcludeModel, RelatedModel, ManyRelatedModel, \ - AdditionalDataIncludedModel, DateTimeFieldModel + ProxyModel, SimpleIncludeModel, SimpleExcludeModel, SimpleMappingModel, RelatedModel, \ + ManyRelatedModel, AdditionalDataIncludedModel, DateTimeFieldModel, ChoicesFieldModel, \ + CharfieldTextfieldModel class SimpleModelTest(TestCase): @@ -215,6 +216,18 @@ def test_register_exclude_fields(self): self.assertTrue(sem.history.count() == 2, msg="There are two log entries") +class SimpleMappingModelTest(TestCase): + """Diff displays fields as mapped field names where available through mapping_fields""" + + def test_register_mapping_fields(self): + smm = SimpleMappingModel(sku='ASD301301A6', vtxt='2.1.5', not_mapped='Not mapped') + smm.save() + self.assertTrue(smm.history.latest().changes_dict['Product No.'][1] == 'ASD301301A6', + msg="The diff function maps 'sku' as 'Product No.' and can be retrieved.") + self.assertTrue(smm.history.latest().changes_dict['not_mapped'][1] == 'Not mapped', + msg="The diff function does not map 'not_mapped' and can be retrieved.") + + class AdditionalDataModelTest(TestCase): """Log additional data if get_additional_data is defined in the model""" @@ -301,6 +314,20 @@ def test_model_with_different_time_and_timezone(self): # The time should have changed. self.assertTrue(dtm.history.count() == 2, msg="There are two log entries") + def test_changes_display_dict_datetime(self): + timestamp = datetime.datetime(2017, 1, 10, 15, 0, tzinfo=timezone.utc) + dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp) + dtm.save() + self.assertTrue(dtm.history.latest().changes_display_dict["timestamp"][1] == \ + timestamp.strftime("%b %d, %Y %I:%M %p"), + msg="The datetime should be formatted in a human readable way.") + timestamp = timezone.now() + dtm.timestamp = timestamp + dtm.save() + self.assertTrue(dtm.history.latest().changes_display_dict["timestamp"][1] == \ + timestamp.strftime("%b %d, %Y %I:%M %p"), + msg="The datetime should be formatted in a human readable way.") + class UnregisterTest(TestCase): def setUp(self): @@ -341,3 +368,76 @@ def test_unregister_delete(self): # Check for log entries self.assertTrue(LogEntry.objects.count() == 0, msg="There are no log entries") + + +class ChoicesFieldModelTest(TestCase): + + def setUp(self): + self.obj = ChoicesFieldModel.objects.create( + status=ChoicesFieldModel.RED, + multiselect=[ChoicesFieldModel.RED, ChoicesFieldModel.GREEN], + multiplechoice=[ChoicesFieldModel.RED, ChoicesFieldModel.YELLOW, ChoicesFieldModel.GREEN], + ) + + def test_changes_display_dict_single_choice(self): + + self.assertTrue(self.obj.history.latest().changes_display_dict["status"][1] == "Red", + msg="The human readable text 'Red' is displayed.") + self.obj.status = ChoicesFieldModel.GREEN + self.obj.save() + self.assertTrue(self.obj.history.latest().changes_display_dict["status"][1] == "Green", msg="The human readable text 'Green' is displayed.") + + def test_changes_display_dict_multiselect(self): + self.assertTrue(self.obj.history.latest().changes_display_dict["multiselect"][1] == "Red, Green", + msg="The human readable text for the two choices, 'Red, Green' is displayed.") + self.obj.multiselect = ChoicesFieldModel.GREEN + self.obj.save() + self.assertTrue(self.obj.history.latest().changes_display_dict["multiselect"][1] == "Green", + msg="The human readable text 'Green' is displayed.") + self.obj.multiselect = None + self.obj.save() + self.assertTrue(self.obj.history.latest().changes_display_dict["multiselect"][1] == "None", + msg="The human readable text 'None' is displayed.") + self.obj.multiselect = ChoicesFieldModel.GREEN + self.obj.save() + self.assertTrue(self.obj.history.latest().changes_display_dict["multiselect"][1] == "Green", + msg="The human readable text 'Green' is displayed.") + + def test_changes_display_dict_multiplechoice(self): + self.assertTrue(self.obj.history.latest().changes_display_dict["multiplechoice"][1] == "Red, Yellow, Green", + msg="The human readable text 'Red, Yellow, Green' is displayed.") + self.obj.multiplechoice = ChoicesFieldModel.RED + self.obj.save() + self.assertTrue(self.obj.history.latest().changes_display_dict["multiplechoice"][1] == "Red", + msg="The human readable text 'Red' is displayed.") + + +class CharfieldTextfieldModelTest(TestCase): + + def setUp(self): + self.PLACEHOLDER_LONGCHAR = "s" * 255 + self.PLACEHOLDER_LONGTEXTFIELD = "s" * 1000 + self.obj = CharfieldTextfieldModel.objects.create( + longchar=self.PLACEHOLDER_LONGCHAR, + longtextfield=self.PLACEHOLDER_LONGTEXTFIELD, + ) + + def test_changes_display_dict_longchar(self): + self.assertTrue(self.obj.history.latest().changes_display_dict["longchar"][1] == \ + "{}...".format(self.PLACEHOLDER_LONGCHAR[:140]), + msg="The string should be truncated at 140 characters with an ellipsis at the end.") + SHORTENED_PLACEHOLDER = self.PLACEHOLDER_LONGCHAR[:139] + self.obj.longchar = SHORTENED_PLACEHOLDER + self.obj.save() + self.assertTrue(self.obj.history.latest().changes_display_dict["longchar"][1] == SHORTENED_PLACEHOLDER, + msg="The field should display the entire string because it is less than 140 characters") + + def test_changes_display_dict_longtextfield(self): + self.assertTrue(self.obj.history.latest().changes_display_dict["longtextfield"][1] == \ + "{}...".format(self.PLACEHOLDER_LONGTEXTFIELD[:140]), + msg="The string should be truncated at 140 characters with an ellipsis at the end.") + SHORTENED_PLACEHOLDER = self.PLACEHOLDER_LONGTEXTFIELD[:139] + self.obj.longtextfield = SHORTENED_PLACEHOLDER + self.obj.save() + self.assertTrue(self.obj.history.latest().changes_display_dict["longtextfield"][1] == SHORTENED_PLACEHOLDER, + msg="The field should display the entire string because it is less than 140 characters") From 94ba93e6ebb566c797d4e900c2334ba1d4596233 Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Mon, 20 Feb 2017 13:47:27 -0500 Subject: [PATCH 02/31] Fix bug where integer fields and others would be able to be parsed by dateutil's parser and would get formatted as datetime output. Introspection is used to see if the internal field type is DateTime, Date, or Time and then try to parse it and format it appropriately. This adds functionality in that DateTime, Date and Time each now have their separate formats. --- src/auditlog/models.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/auditlog/models.py b/src/auditlog/models.py index 5ea82c57..d901bb00 100644 --- a/src/auditlog/models.py +++ b/src/auditlog/models.py @@ -258,12 +258,24 @@ def changes_display_dict(self): values_display.append(choices_dict.get(value, 'None')) else: for value in values: - try: - value = parser.parse(value) - value = value.strftime("%b %d, %Y %I:%M %p") - except ValueError: - pass - + if "DateTime" in field.get_internal_type(): + try: + value = parser.parse(value) + value = value.strftime("%b %d, %Y %I:%M %p") + except ValueError: + pass + elif "Date" in field.get_internal_type(): + try: + value = parser.parse(value) + value = value.strftime("%b %d, %Y") + except ValueError: + pass + elif "Time" in field.get_internal_type(): + try: + value = parser.parse(value) + value = value.strftime("%I:%M %p") + except ValueError: + pass if len(value) > 140: value = "{}...".format(value[:140]) From b80b8764bb4ee7da36a88da12df70e05ec5a89af Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Mon, 20 Feb 2017 14:43:50 -0500 Subject: [PATCH 03/31] Add 'reverse_mapping_fields' to registry. This is a simple reversal of the 'mapping_fields' dictionary keys and values. The purpose of this dictionary is so that the LogEntry model's 'changes_display_dict' method can reverse a human readable field name returned from 'changes' so that it can do extra checks on that field. Without this we would get a FieldDoesNotExist error when trying to lookup fields on the model in the 'changes_display_dict' method so that we could introspect attributes about them when determining how to display human readable changes. --- src/auditlog/models.py | 10 +++++++++- src/auditlog/registry.py | 4 ++++ src/auditlog_tests/tests.py | 4 ++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/auditlog/models.py b/src/auditlog/models.py index d901bb00..58171127 100644 --- a/src/auditlog/models.py +++ b/src/auditlog/models.py @@ -7,6 +7,7 @@ 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.encoding import python_2_unicode_compatible, smart_text @@ -243,7 +244,14 @@ def changes_display_dict(self): model = self.content_type.model_class() changes_display_dict = {} for field_name, values in iteritems(self.changes_dict): - field = model._meta.get_field(field_name) + try: + field = model._meta.get_field(field_name) + except FieldDoesNotExist: + from auditlog.registry import auditlog + model_fields = auditlog.get_model_fields(model._meta.model) + reversed_field = model_fields['reverse_mapping_fields'].get(field_name) + field = model._meta.get_field(reversed_field) + values_display = [] if field.choices: choices_dict = dict(field.choices) diff --git a/src/auditlog/registry.py b/src/auditlog/registry.py index edc98359..4588f84d 100644 --- a/src/auditlog/registry.py +++ b/src/auditlog/registry.py @@ -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): @@ -41,10 +42,12 @@ def registrar(cls): raise TypeError("Supplied model is not a valid model.") # Register the model and signals. + reverse_mapping_fields = {v: k for k, v in iteritems(mapping_fields)} self._registry[cls] = { 'include_fields': include_fields, 'exclude_fields': exclude_fields, 'mapping_fields': mapping_fields, + 'reverse_mapping_fields': reverse_mapping_fields, } self._connect_signals(cls) @@ -112,6 +115,7 @@ def get_model_fields(self, model): 'include_fields': self._registry[model]['include_fields'], 'exclude_fields': self._registry[model]['exclude_fields'], 'mapping_fields': self._registry[model]['mapping_fields'], + 'reverse_mapping_fields': self._registry[model]['reverse_mapping_fields'] } diff --git a/src/auditlog_tests/tests.py b/src/auditlog_tests/tests.py index a36503d6..6224cdf9 100644 --- a/src/auditlog_tests/tests.py +++ b/src/auditlog_tests/tests.py @@ -226,6 +226,10 @@ def test_register_mapping_fields(self): msg="The diff function maps 'sku' as 'Product No.' and can be retrieved.") self.assertTrue(smm.history.latest().changes_dict['not_mapped'][1] == 'Not mapped', msg="The diff function does not map 'not_mapped' and can be retrieved.") + self.assertTrue(smm.history.latest().changes_display_dict['Product No.'][1] == 'ASD301301A6', + msg="The diff function maps 'sku' as 'Product No.' and can be retrieved.") + self.assertTrue(smm.history.latest().changes_display_dict['not_mapped'][1] == 'Not mapped', + msg="The diff function does not map 'not_mapped' and can be retrieved.") class AdditionalDataModelTest(TestCase): From 23cadae910907b7903ab79ec6f218d1268ceea81 Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Wed, 6 Sep 2017 12:51:27 -0400 Subject: [PATCH 04/31] Revert .coveragerc addition --- .coveragerc | 13 ------------- .gitignore | 1 - 2 files changed, 14 deletions(-) delete mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index b5e57620..00000000 --- a/.coveragerc +++ /dev/null @@ -1,13 +0,0 @@ -[run] -branch = true - -[report] -omit = - *site-packages* - *auditlog_tests* - *auditlog/migrations* - src/runtests.py -show_missing = True -exclude_lines = - pragma: no cover - raise NotImplementedError diff --git a/.gitignore b/.gitignore index 05b95e16..e6d60c81 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,3 @@ venv/ .tox/ local_settings.py -.coverage From 8cc8f5276daec31c33593a88150948e9a0ba1aea Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Wed, 6 Sep 2017 12:52:26 -0400 Subject: [PATCH 05/31] Revert Pull Request Guideliens in README --- README.md | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/README.md b/README.md index d79d187c..ed51710c 100644 --- a/README.md +++ b/README.md @@ -27,16 +27,3 @@ Contribute ---------- If you have great ideas for Auditlog, or if you like to improve something, feel free to fork this repository and/or create a pull request. I'm open for suggestions. If you like to discuss something with me (about Auditlog), please open an issue. - -Pull Request Guidelines ------------------------ - -Before you submit a pull request, check that it meets these guidelines: - -1. The pull request should include tests. -2. If the pull request adds functionality, the docs should be updated. Put - your new functionality into a function with a docstring, and update relevant - documentation. -3. The pull request should work for Python 2.7, 3.4+, and with Django>=1.8. Check - https://travis-ci.org/jjkester/django-auditlog/pull_requests - and make sure that the tests pass for all supported Python versions. From 1e8a7b41f75bf64df194726f8f5e88fff3a44c9d Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Wed, 6 Sep 2017 12:53:34 -0400 Subject: [PATCH 06/31] Revert changing test database name --- src/auditlog_tests/test_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auditlog_tests/test_settings.py b/src/auditlog_tests/test_settings.py index 6bb99b59..eda79730 100644 --- a/src/auditlog_tests/test_settings.py +++ b/src/auditlog_tests/test_settings.py @@ -20,7 +20,7 @@ DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': 'auditlog_test.db', + 'NAME': 'auditlog_tests.db', } } From fd5069e56c80903b07e631ac836701141ba55709 Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Wed, 6 Sep 2017 12:55:24 -0400 Subject: [PATCH 07/31] Revert added newline --- src/auditlog/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/auditlog/models.py b/src/auditlog/models.py index 58171127..28123d61 100644 --- a/src/auditlog/models.py +++ b/src/auditlog/models.py @@ -320,7 +320,6 @@ def __init__(self, pk_indexable=True, **kwargs): kwargs['content_type_field'] = 'content_type' super(AuditlogHistoryField, self).__init__(**kwargs) - # South compatibility for AuditlogHistoryField try: from south.modelsinspector import add_introspection_rules From 26283fc829b3396c2e58e5e59223d704f2943d15 Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Wed, 6 Sep 2017 13:28:54 -0400 Subject: [PATCH 08/31] Add django-multiselectfield test dep, remove other req file --- requirements-test.txt | 1 + requirements_test.txt | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 requirements_test.txt diff --git a/requirements-test.txt b/requirements-test.txt index 41a8ec92..7f61305b 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -3,3 +3,4 @@ coverage==4.3.4 tox>=1.7.0 codecov>=2.0.0 +django-multiselectfield==0.1.8 diff --git a/requirements_test.txt b/requirements_test.txt deleted file mode 100644 index e3fb5a2b..00000000 --- a/requirements_test.txt +++ /dev/null @@ -1,2 +0,0 @@ -coverage==4.2 -django-multiselectfield==0.1.5 From cf661457a6cb48629510682f1fe32e3abae9144d Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Wed, 6 Sep 2017 13:30:01 -0400 Subject: [PATCH 09/31] Add psycopg2 test dep --- requirements-test.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-test.txt b/requirements-test.txt index 7f61305b..b3d233a6 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -4,3 +4,4 @@ coverage==4.3.4 tox>=1.7.0 codecov>=2.0.0 django-multiselectfield==0.1.8 +psycopg2 From 609ec28fe3d6763bc26c204bbb5424b1a5f42f39 Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Wed, 6 Sep 2017 13:30:13 -0400 Subject: [PATCH 10/31] Add postgres testing database and router --- src/auditlog_tests/router.py | 38 +++++++++++++++++++++++++++++ src/auditlog_tests/test_settings.py | 16 ++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 src/auditlog_tests/router.py diff --git a/src/auditlog_tests/router.py b/src/auditlog_tests/router.py new file mode 100644 index 00000000..d5a21aef --- /dev/null +++ b/src/auditlog_tests/router.py @@ -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 diff --git a/src/auditlog_tests/test_settings.py b/src/auditlog_tests/test_settings.py index eda79730..69cbba79 100644 --- a/src/auditlog_tests/test_settings.py +++ b/src/auditlog_tests/test_settings.py @@ -1,6 +1,7 @@ """ Settings file for the Auditlog test suite. """ +import django SECRET_KEY = 'test' @@ -17,10 +18,25 @@ 'auditlog.middleware.AuditlogMiddleware', ) +if django.VERSION <= (1, 9): + POSTGRES_DRIVER = 'django.db.backends.postgresql_psycopg2' +else: + POSTGRES_DRIVER = 'django.db.backends.postgresql' + +DATABASE_ROUTERS = ['auditlog_tests.router'] + DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': 'auditlog_tests.db', + }, + 'postgres': { + 'ENGINE': POSTGRES_DRIVER, + 'NAME': 'auditlog_tests_db', + 'USER': 'auditlog_user', + 'PASSWORD': 'auditlog_pass', + 'HOST': '127.0.0.1', + 'PORT': '5432', } } From a746302b8029b7e38e0302f84cf022990330607c Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Wed, 6 Sep 2017 13:36:18 -0400 Subject: [PATCH 11/31] Add test for Postgres's ArrayField --- src/auditlog_tests/models.py | 19 +++++++++++++++++++ src/auditlog_tests/tests.py | 26 +++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/auditlog_tests/models.py b/src/auditlog_tests/models.py index 577e6dd6..c079a70f 100644 --- a/src/auditlog_tests/models.py +++ b/src/auditlog_tests/models.py @@ -1,5 +1,6 @@ import uuid +from django.contrib.postgres.fields import ArrayField from django.db import models from auditlog.models import AuditlogHistoryField from auditlog.registry import auditlog @@ -186,6 +187,23 @@ class CharfieldTextfieldModel(models.Model): 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(size=3, choices=STATUS_CHOICES) + + auditlog.register(AltPrimaryKeyModel) auditlog.register(UUIDPrimaryKeyModel) auditlog.register(ProxyModel) @@ -198,3 +216,4 @@ class CharfieldTextfieldModel(models.Model): auditlog.register(DateTimeFieldModel) auditlog.register(ChoicesFieldModel) auditlog.register(CharfieldTextfieldModel) +auditlog.register(PostgresArrayFieldModel) diff --git a/src/auditlog_tests/tests.py b/src/auditlog_tests/tests.py index 6224cdf9..664b4f82 100644 --- a/src/auditlog_tests/tests.py +++ b/src/auditlog_tests/tests.py @@ -12,7 +12,7 @@ from auditlog_tests.models import SimpleModel, AltPrimaryKeyModel, UUIDPrimaryKeyModel, \ ProxyModel, SimpleIncludeModel, SimpleExcludeModel, SimpleMappingModel, RelatedModel, \ ManyRelatedModel, AdditionalDataIncludedModel, DateTimeFieldModel, ChoicesFieldModel, \ - CharfieldTextfieldModel + CharfieldTextfieldModel, PostgresArrayFieldModel class SimpleModelTest(TestCase): @@ -445,3 +445,27 @@ def test_changes_display_dict_longtextfield(self): self.obj.save() self.assertTrue(self.obj.history.latest().changes_display_dict["longtextfield"][1] == SHORTENED_PLACEHOLDER, msg="The field should display the entire string because it is less than 140 characters") + + +class PostgresArrayFieldModelTest(TestCase): + + def setUp(self): + self.obj = PostgresArrayFieldModel.objects.create( + arrayfield=[PostgresArrayFieldModel.RED, PostgresArrayFieldModel.GREEN], + ) + + def test_changes_display_dict_arrayfield(self): + self.assertTrue(self.obj.history.latest().changes_display_dict["arrayfield"][1] == "Red, Green", + msg="The human readable text for the two choices, 'Red, Green' is displayed.") + self.obj.arrayfield = PostgresArrayFieldModel.GREEN + self.obj.save() + self.assertTrue(self.obj.history.latest().changes_display_dict["arrayfield"][1] == "Green", + msg="The human readable text 'Green' is displayed.") + self.obj.arrayfield = None + self.obj.save() + self.assertTrue(self.obj.history.latest().changes_display_dict["arrayfield"][1] == "None", + msg="The human readable text 'None' is displayed.") + self.obj.arrayfield = PostgresArrayFieldModel.GREEN + self.obj.save() + self.assertTrue(self.obj.history.latest().changes_display_dict["arrayfield"][1] == "Green", + msg="The human readable text 'Green' is displayed.") From 5885953537bff1cfaf70a08247e162ec99b3fa0f Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Wed, 6 Sep 2017 13:50:47 -0400 Subject: [PATCH 12/31] Add basefield required arg to arrayfield --- src/auditlog_tests/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auditlog_tests/models.py b/src/auditlog_tests/models.py index c079a70f..7fe4bdde 100644 --- a/src/auditlog_tests/models.py +++ b/src/auditlog_tests/models.py @@ -201,7 +201,7 @@ class PostgresArrayFieldModel(models.Model): (GREEN, 'Green'), ) - arrayfield = ArrayField(size=3, choices=STATUS_CHOICES) + arrayfield = ArrayField(models.CharField(max_length=1, choices=STATUS_CHOICES), size=3) auditlog.register(AltPrimaryKeyModel) From 37b134a47775eff8e6e4f87e1671210a5a7f778b Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Wed, 6 Sep 2017 13:51:04 -0400 Subject: [PATCH 13/31] Add postgres support to travis builds --- .travis.yml | 2 ++ src/auditlog_tests/test_settings.py | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index abb3a13f..88c86942 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,8 @@ dist: trusty sudo: required language: python python: 3.6 +services: + - postgresql env: - TOX_ENV=py36-django-18 diff --git a/src/auditlog_tests/test_settings.py b/src/auditlog_tests/test_settings.py index 69cbba79..891b5a8c 100644 --- a/src/auditlog_tests/test_settings.py +++ b/src/auditlog_tests/test_settings.py @@ -23,7 +23,7 @@ else: POSTGRES_DRIVER = 'django.db.backends.postgresql' -DATABASE_ROUTERS = ['auditlog_tests.router'] +DATABASE_ROUTERS = ['auditlog_tests.router.PostgresRouter'] DATABASES = { 'default': { @@ -33,8 +33,8 @@ 'postgres': { 'ENGINE': POSTGRES_DRIVER, 'NAME': 'auditlog_tests_db', - 'USER': 'auditlog_user', - 'PASSWORD': 'auditlog_pass', + 'USER': 'postgres', + 'PASSWORD': '', 'HOST': '127.0.0.1', 'PORT': '5432', } From 17411f68155c7a1f06f211d2d042c221a69ce838 Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Wed, 6 Sep 2017 13:56:58 -0400 Subject: [PATCH 14/31] Add history field to PostgresArrayFieldModel --- src/auditlog_tests/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/auditlog_tests/models.py b/src/auditlog_tests/models.py index 7fe4bdde..266e3176 100644 --- a/src/auditlog_tests/models.py +++ b/src/auditlog_tests/models.py @@ -203,6 +203,8 @@ class PostgresArrayFieldModel(models.Model): arrayfield = ArrayField(models.CharField(max_length=1, choices=STATUS_CHOICES), size=3) + history = AuditlogHistoryField() + auditlog.register(AltPrimaryKeyModel) auditlog.register(UUIDPrimaryKeyModel) From 1aca8073f071d43791c002ca5687b897ed83b3fb Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Wed, 6 Sep 2017 14:50:11 -0400 Subject: [PATCH 15/31] Add support for multiple databases. LogEntry saves to same database of the model its associated to --- src/auditlog/models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/auditlog/models.py b/src/auditlog/models.py index 28123d61..6589a7b6 100644 --- a/src/auditlog/models.py +++ b/src/auditlog/models.py @@ -56,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): From 6c2338545effcc89ba5d622fbf9d3a525d358d37 Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Wed, 6 Sep 2017 15:14:46 -0400 Subject: [PATCH 16/31] If any literal evals fail default to None --- src/auditlog/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/auditlog/models.py b/src/auditlog/models.py index 6589a7b6..1f803dcc 100644 --- a/src/auditlog/models.py +++ b/src/auditlog/models.py @@ -265,6 +265,8 @@ def changes_display_dict(self): 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: for value in values: if "DateTime" in field.get_internal_type(): From 29f143113525d054921acb8df1fc32313e90c4a9 Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Wed, 6 Sep 2017 15:24:41 -0400 Subject: [PATCH 17/31] Add support for Postgres ArrayField in changes_display_dict --- src/auditlog/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/auditlog/models.py b/src/auditlog/models.py index 1f803dcc..e22339ed 100644 --- a/src/auditlog/models.py +++ b/src/auditlog/models.py @@ -254,8 +254,8 @@ def changes_display_dict(self): field = model._meta.get_field(reversed_field) values_display = [] - if field.choices: - choices_dict = dict(field.choices) + 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) From 23236ce157de6c037f01f04d06e2d9c523c1e511 Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Wed, 6 Sep 2017 15:25:17 -0400 Subject: [PATCH 18/31] Add tests for ArrayField --- src/auditlog_tests/tests.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/auditlog_tests/tests.py b/src/auditlog_tests/tests.py index 664b4f82..e1207f25 100644 --- a/src/auditlog_tests/tests.py +++ b/src/auditlog_tests/tests.py @@ -457,15 +457,15 @@ def setUp(self): def test_changes_display_dict_arrayfield(self): self.assertTrue(self.obj.history.latest().changes_display_dict["arrayfield"][1] == "Red, Green", msg="The human readable text for the two choices, 'Red, Green' is displayed.") - self.obj.arrayfield = PostgresArrayFieldModel.GREEN + self.obj.arrayfield = [PostgresArrayFieldModel.GREEN] self.obj.save() self.assertTrue(self.obj.history.latest().changes_display_dict["arrayfield"][1] == "Green", msg="The human readable text 'Green' is displayed.") - self.obj.arrayfield = None + self.obj.arrayfield = [] self.obj.save() - self.assertTrue(self.obj.history.latest().changes_display_dict["arrayfield"][1] == "None", - msg="The human readable text 'None' is displayed.") - self.obj.arrayfield = PostgresArrayFieldModel.GREEN + self.assertTrue(self.obj.history.latest().changes_display_dict["arrayfield"][1] == "", + msg="The human readable text '' is displayed.") + self.obj.arrayfield = [PostgresArrayFieldModel.GREEN] self.obj.save() self.assertTrue(self.obj.history.latest().changes_display_dict["arrayfield"][1] == "Green", msg="The human readable text 'Green' is displayed.") From 89e18684db958d8e6b70bb57e13aa517f35fac32 Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Wed, 6 Sep 2017 15:37:45 -0400 Subject: [PATCH 19/31] Add comments to changes_display_dict function --- src/auditlog/models.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/auditlog/models.py b/src/auditlog/models.py index e22339ed..4005047b 100644 --- a/src/auditlog/models.py +++ b/src/auditlog/models.py @@ -242,18 +242,23 @@ def changes_display_dict(self): """ :return: The changes recorded in this log entry intended for display to users as a dictionary object. """ + # Get the model model = self.content_type.model_class() 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: + # use the reverse field mapping to get the field from auditlog.registry import auditlog model_fields = auditlog.get_model_fields(model._meta.model) reversed_field = model_fields['reverse_mapping_fields'].get(field_name) field = model._meta.get_field(reversed_field) 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: @@ -269,24 +274,28 @@ def changes_display_dict(self): values_display.append(choices_dict.get(value, 'None')) else: for value in values: + # handle case where field is a datetime type if "DateTime" in field.get_internal_type(): try: value = parser.parse(value) value = value.strftime("%b %d, %Y %I:%M %p") except ValueError: pass + # handle case where field is a date type elif "Date" in field.get_internal_type(): try: value = parser.parse(value) value = value.strftime("%b %d, %Y") except ValueError: pass + # handle case where field is a time type elif "Time" in field.get_internal_type(): try: value = parser.parse(value) value = value.strftime("%I:%M %p") except ValueError: pass + # check if length is longer than 140 and truncate with ellipsis if len(value) > 140: value = "{}...".format(value[:140]) From 0a8fdf91ea7723d715965137909db9a76e393488 Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Wed, 6 Sep 2017 16:03:17 -0400 Subject: [PATCH 20/31] Add more tests to increase coverage --- src/auditlog_tests/models.py | 2 + src/auditlog_tests/tests.py | 88 +++++++++++++++++++++++++++++++++--- 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/auditlog_tests/models.py b/src/auditlog_tests/models.py index 266e3176..d72b5caa 100644 --- a/src/auditlog_tests/models.py +++ b/src/auditlog_tests/models.py @@ -148,6 +148,8 @@ class DateTimeFieldModel(models.Model): """ label = models.CharField(max_length=100) timestamp = models.DateTimeField() + date = models.DateField() + time = models.TimeField() history = AuditlogHistoryField() diff --git a/src/auditlog_tests/tests.py b/src/auditlog_tests/tests.py index e1207f25..46531028 100644 --- a/src/auditlog_tests/tests.py +++ b/src/auditlog_tests/tests.py @@ -264,13 +264,17 @@ class DateTimeFieldModelTest(TestCase): def test_model_with_same_time(self): timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc) - dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp) + date = datetime.date(2017, 1, 10) + time = datetime.time(12, 0) + dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp, date=date, time=time) dtm.save() self.assertTrue(dtm.history.count() == 1, msg="There is one log entry") # Change timestamp to same datetime and timezone timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc) dtm.timestamp = timestamp + dtm.date = datetime.date(2017, 1, 10) + dtm.time = datetime.time(12, 0) dtm.save() # Nothing should have changed @@ -278,7 +282,9 @@ def test_model_with_same_time(self): def test_model_with_different_timezone(self): timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc) - dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp) + date = datetime.date(2017, 1, 10) + time = datetime.time(12, 0) + dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp, date=date, time=time) dtm.save() self.assertTrue(dtm.history.count() == 1, msg="There is one log entry") @@ -290,9 +296,11 @@ def test_model_with_different_timezone(self): # Nothing should have changed self.assertTrue(dtm.history.count() == 1, msg="There is one log entry") - def test_model_with_different_time(self): + def test_model_with_different_datetime(self): timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc) - dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp) + date = datetime.date(2017, 1, 10) + time = datetime.time(12, 0) + dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp, date=date, time=time) dtm.save() self.assertTrue(dtm.history.count() == 1, msg="There is one log entry") @@ -304,9 +312,43 @@ def test_model_with_different_time(self): # The time should have changed. self.assertTrue(dtm.history.count() == 2, msg="There are two log entries") + def test_model_with_different_date(self): + timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc) + date = datetime.date(2017, 1, 10) + time = datetime.time(12, 0) + dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp, date=date, time=time) + dtm.save() + self.assertTrue(dtm.history.count() == 1, msg="There is one log entry") + + # Change timestamp to another datetime in the same timezone + date = datetime.datetime(2017, 1, 11) + dtm.date = date + dtm.save() + + # The time should have changed. + self.assertTrue(dtm.history.count() == 2, msg="There are two log entries") + + def test_model_with_different_time(self): + timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc) + date = datetime.date(2017, 1, 10) + time = datetime.time(12, 0) + dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp, date=date, time=time) + dtm.save() + self.assertTrue(dtm.history.count() == 1, msg="There is one log entry") + + # Change timestamp to another datetime in the same timezone + time = datetime.time(6, 0) + dtm.time = time + dtm.save() + + # The time should have changed. + self.assertTrue(dtm.history.count() == 2, msg="There are two log entries") + def test_model_with_different_time_and_timezone(self): timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc) - dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp) + date = datetime.date(2017, 1, 10) + time = datetime.time(12, 0) + dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp, date=date, time=time) dtm.save() self.assertTrue(dtm.history.count() == 1, msg="There is one log entry") @@ -320,7 +362,9 @@ def test_model_with_different_time_and_timezone(self): def test_changes_display_dict_datetime(self): timestamp = datetime.datetime(2017, 1, 10, 15, 0, tzinfo=timezone.utc) - dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp) + date = datetime.date(2017, 1, 10) + time = datetime.time(12, 0) + dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp, date=date, time=time) dtm.save() self.assertTrue(dtm.history.latest().changes_display_dict["timestamp"][1] == \ timestamp.strftime("%b %d, %Y %I:%M %p"), @@ -332,6 +376,38 @@ def test_changes_display_dict_datetime(self): timestamp.strftime("%b %d, %Y %I:%M %p"), msg="The datetime should be formatted in a human readable way.") + def test_changes_display_dict_date(self): + timestamp = datetime.datetime(2017, 1, 10, 15, 0, tzinfo=timezone.utc) + date = datetime.date(2017, 1, 10) + time = datetime.time(12, 0) + dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp, date=date, time=time) + dtm.save() + self.assertTrue(dtm.history.latest().changes_display_dict["date"][1] == \ + date.strftime("%b %d, %Y"), + msg="The date should be formatted in a human readable way.") + date = datetime.date(2017, 1, 11) + dtm.date = date + dtm.save() + self.assertTrue(dtm.history.latest().changes_display_dict["date"][1] == \ + date.strftime("%b %d, %Y"), + msg="The date should be formatted in a human readable way.") + + def test_changes_display_dict_date(self): + timestamp = datetime.datetime(2017, 1, 10, 15, 0, tzinfo=timezone.utc) + date = datetime.date(2017, 1, 10) + time = datetime.time(12, 0) + dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp, date=date, time=time) + dtm.save() + self.assertTrue(dtm.history.latest().changes_display_dict["time"][1] == \ + time.strftime("%I:%M %p"), + msg="The time should be formatted in a human readable way.") + time = datetime.time(6, 0) + dtm.time = time + dtm.save() + self.assertTrue(dtm.history.latest().changes_display_dict["time"][1] == \ + time.strftime("%I:%M %p"), + msg="The time should be formatted in a human readable way.") + class UnregisterTest(TestCase): def setUp(self): From 61da565355756577495c35eea035bcac4c669a0a Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Wed, 6 Sep 2017 16:35:12 -0400 Subject: [PATCH 21/31] Remove unneeded if check, the default argument to get provides the same fallback --- src/auditlog/diff.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/auditlog/diff.py b/src/auditlog/diff.py index c92c18c1..83e7dcf5 100644 --- a/src/auditlog/diff.py +++ b/src/auditlog/diff.py @@ -135,10 +135,7 @@ def model_instance_diff(old, new): new_value = get_field_value(new, field) if old_value != new_value: - if model_fields: - diff[model_fields['mapping_fields'].get(field.name, field.name)] = (smart_text(old_value), smart_text(new_value)) - else: - diff[field.name] = (smart_text(old_value), smart_text(new_value)) + diff[model_fields['mapping_fields'].get(field.name, field.name)] = (smart_text(old_value), smart_text(new_value)) if len(diff) == 0: diff = None From 8fc05e4712cc6281f89277f08b9777292c17b835 Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Mon, 11 Sep 2017 12:04:39 -0400 Subject: [PATCH 22/31] Simplify date handling logic and format based upon localization or Django's settings definitions. --- src/auditlog/models.py | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/src/auditlog/models.py b/src/auditlog/models.py index 4005047b..21b2f2b7 100644 --- a/src/auditlog/models.py +++ b/src/auditlog/models.py @@ -2,7 +2,6 @@ import json import ast -from datetime import datetime from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation @@ -10,6 +9,7 @@ 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 _ @@ -273,26 +273,17 @@ def changes_display_dict(self): 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 type - if "DateTime" in field.get_internal_type(): + # handle case where field is a datetime, date, or time type + if field_type in ["DateTimeField", "DateField", "TimeField"]: try: value = parser.parse(value) - value = value.strftime("%b %d, %Y %I:%M %p") - except ValueError: - pass - # handle case where field is a date type - elif "Date" in field.get_internal_type(): - try: - value = parser.parse(value) - value = value.strftime("%b %d, %Y") - except ValueError: - pass - # handle case where field is a time type - elif "Time" in field.get_internal_type(): - try: - value = parser.parse(value) - value = value.strftime("%I:%M %p") + 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 From 9893d669a337fe4c2c4e76d174ad5f4332977f8e Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Mon, 11 Sep 2017 12:04:53 -0400 Subject: [PATCH 23/31] Update tests to tests against django settings --- src/auditlog_tests/tests.py | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/src/auditlog_tests/tests.py b/src/auditlog_tests/tests.py index 46531028..6627960c 100644 --- a/src/auditlog_tests/tests.py +++ b/src/auditlog_tests/tests.py @@ -1,10 +1,11 @@ import datetime +from django.conf import settings from django.contrib.auth.models import User, AnonymousUser from django.core.exceptions import ValidationError from django.db.models.signals import pre_save from django.http import HttpResponse from django.test import TestCase, RequestFactory -from django.utils import timezone +from django.utils import dateformat, timezone from auditlog.middleware import AuditlogMiddleware from auditlog.models import LogEntry @@ -367,14 +368,16 @@ def test_changes_display_dict_datetime(self): dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp, date=date, time=time) dtm.save() self.assertTrue(dtm.history.latest().changes_display_dict["timestamp"][1] == \ - timestamp.strftime("%b %d, %Y %I:%M %p"), - msg="The datetime should be formatted in a human readable way.") + dateformat.format(timestamp, settings.DATETIME_FORMAT), + msg=("The datetime should be formatted according to Django's settings for" + " DATETIME_FORMAT unless USE_L10N is True.")) timestamp = timezone.now() dtm.timestamp = timestamp dtm.save() self.assertTrue(dtm.history.latest().changes_display_dict["timestamp"][1] == \ - timestamp.strftime("%b %d, %Y %I:%M %p"), - msg="The datetime should be formatted in a human readable way.") + dateformat.format(timestamp, settings.DATETIME_FORMAT), + msg=("The datetime should be formatted according to Django's settings for" + " DATETIME_FORMAT unless USE_L10N is True.")) def test_changes_display_dict_date(self): timestamp = datetime.datetime(2017, 1, 10, 15, 0, tzinfo=timezone.utc) @@ -383,30 +386,34 @@ def test_changes_display_dict_date(self): dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp, date=date, time=time) dtm.save() self.assertTrue(dtm.history.latest().changes_display_dict["date"][1] == \ - date.strftime("%b %d, %Y"), - msg="The date should be formatted in a human readable way.") + dateformat.format(date, settings.DATE_FORMAT), + msg=("The date should be formatted according to Django's settings for" + " DATE_FORMAT unless USE_L10N is True.")) date = datetime.date(2017, 1, 11) dtm.date = date dtm.save() self.assertTrue(dtm.history.latest().changes_display_dict["date"][1] == \ - date.strftime("%b %d, %Y"), - msg="The date should be formatted in a human readable way.") + dateformat.format(date, settings.DATE_FORMAT), + msg=("The date should be formatted according to Django's settings for" + " DATE_FORMAT unless USE_L10N is True.")) - def test_changes_display_dict_date(self): + def test_changes_display_dict_time(self): timestamp = datetime.datetime(2017, 1, 10, 15, 0, tzinfo=timezone.utc) date = datetime.date(2017, 1, 10) time = datetime.time(12, 0) dtm = DateTimeFieldModel(label='DateTimeField model', timestamp=timestamp, date=date, time=time) dtm.save() self.assertTrue(dtm.history.latest().changes_display_dict["time"][1] == \ - time.strftime("%I:%M %p"), - msg="The time should be formatted in a human readable way.") + dateformat.format(time, settings.TIME_FORMAT), + msg=("The time should be formatted according to Django's settings for" + " TIME_FORMAT unless USE_L10N is True.")) time = datetime.time(6, 0) dtm.time = time dtm.save() self.assertTrue(dtm.history.latest().changes_display_dict["time"][1] == \ - time.strftime("%I:%M %p"), - msg="The time should be formatted in a human readable way.") + dateformat.format(time, settings.TIME_FORMAT), + msg=("The time should be formatted according to Django's settings for" + " TIME_FORMAT unless USE_L10N is True.")) class UnregisterTest(TestCase): From 65787804627ab2a4b9f0dad86c4e6478725fe574 Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Mon, 11 Sep 2017 12:17:02 -0400 Subject: [PATCH 24/31] Add tests for when USE_L10N = True --- src/auditlog_tests/tests.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/auditlog_tests/tests.py b/src/auditlog_tests/tests.py index 6627960c..dd27fbb6 100644 --- a/src/auditlog_tests/tests.py +++ b/src/auditlog_tests/tests.py @@ -5,7 +5,7 @@ from django.db.models.signals import pre_save from django.http import HttpResponse from django.test import TestCase, RequestFactory -from django.utils import dateformat, timezone +from django.utils import dateformat, formats, timezone from auditlog.middleware import AuditlogMiddleware from auditlog.models import LogEntry @@ -370,14 +370,22 @@ def test_changes_display_dict_datetime(self): self.assertTrue(dtm.history.latest().changes_display_dict["timestamp"][1] == \ dateformat.format(timestamp, settings.DATETIME_FORMAT), msg=("The datetime should be formatted according to Django's settings for" - " DATETIME_FORMAT unless USE_L10N is True.")) + " DATETIME_FORMAT")) timestamp = timezone.now() dtm.timestamp = timestamp dtm.save() self.assertTrue(dtm.history.latest().changes_display_dict["timestamp"][1] == \ dateformat.format(timestamp, settings.DATETIME_FORMAT), msg=("The datetime should be formatted according to Django's settings for" - " DATETIME_FORMAT unless USE_L10N is True.")) + " DATETIME_FORMAT")) + + # Change USE_L10N = True + with self.settings(USE_L10N=True, LANGUAGE_CODE='en-GB'): + self.assertTrue(dtm.history.latest().changes_display_dict["timestamp"][1] == \ + formats.localize(timestamp), + msg=("The datetime should be formatted according to Django's settings for" + " USE_L10N is True with a different LANGUAGE_CODE.")) + def test_changes_display_dict_date(self): timestamp = datetime.datetime(2017, 1, 10, 15, 0, tzinfo=timezone.utc) @@ -397,6 +405,13 @@ def test_changes_display_dict_date(self): msg=("The date should be formatted according to Django's settings for" " DATE_FORMAT unless USE_L10N is True.")) + # Change USE_L10N = True + with self.settings(USE_L10N=True, LANGUAGE_CODE='en-GB'): + self.assertTrue(dtm.history.latest().changes_display_dict["date"][1] == \ + formats.localize(date), + msg=("The date should be formatted according to Django's settings for" + " USE_L10N is True with a different LANGUAGE_CODE.")) + def test_changes_display_dict_time(self): timestamp = datetime.datetime(2017, 1, 10, 15, 0, tzinfo=timezone.utc) date = datetime.date(2017, 1, 10) @@ -415,6 +430,13 @@ def test_changes_display_dict_time(self): msg=("The time should be formatted according to Django's settings for" " TIME_FORMAT unless USE_L10N is True.")) + # Change USE_L10N = True + with self.settings(USE_L10N=True, LANGUAGE_CODE='en-GB'): + self.assertTrue(dtm.history.latest().changes_display_dict["time"][1] == \ + formats.localize(time), + msg=("The time should be formatted according to Django's settings for" + " USE_L10N is True with a different LANGUAGE_CODE.")) + class UnregisterTest(TestCase): def setUp(self): From 591f596fcfe362364df54cb928f6f95eecafc262 Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Mon, 11 Sep 2017 13:18:11 -0400 Subject: [PATCH 25/31] Revert modifying the diff output and instead use mapping_fields in the changes_display_dict function. Remove usage of reverse_fields as it is no longer required. Default to field.verbose_name in the cases where there is no mapping_field, and if the field cannot be found default to the auditlog stored field_name --- src/auditlog/diff.py | 2 +- src/auditlog/models.py | 16 +++++++--------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/auditlog/diff.py b/src/auditlog/diff.py index 83e7dcf5..e73a15a5 100644 --- a/src/auditlog/diff.py +++ b/src/auditlog/diff.py @@ -135,7 +135,7 @@ def model_instance_diff(old, new): new_value = get_field_value(new, field) if old_value != new_value: - diff[model_fields['mapping_fields'].get(field.name, field.name)] = (smart_text(old_value), smart_text(new_value)) + diff[field.name] = (smart_text(old_value), smart_text(new_value)) if len(diff) == 0: diff = None diff --git a/src/auditlog/models.py b/src/auditlog/models.py index 21b2f2b7..06528a34 100644 --- a/src/auditlog/models.py +++ b/src/auditlog/models.py @@ -242,8 +242,10 @@ def changes_display_dict(self): """ :return: The changes recorded in this log entry intended for display to users as a dictionary object. """ - # Get the model + # 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): @@ -251,12 +253,8 @@ def changes_display_dict(self): try: field = model._meta.get_field(field_name) except FieldDoesNotExist: - # use the reverse field mapping to get the field - from auditlog.registry import auditlog - model_fields = auditlog.get_model_fields(model._meta.model) - reversed_field = model_fields['reverse_mapping_fields'].get(field_name) - field = model._meta.get_field(reversed_field) - + 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): @@ -291,8 +289,8 @@ def changes_display_dict(self): value = "{}...".format(value[:140]) values_display.append(value) - - changes_display_dict[field_name] = values_display + verbose_name = model_fields['mapping_fields'].get(field.name, field.verbose_name) + changes_display_dict[verbose_name] = values_display return changes_display_dict From 26b3665659d280b3116fdb1cbc28f9e881921087 Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Mon, 11 Sep 2017 13:18:49 -0400 Subject: [PATCH 26/31] Remove reverse_mapping_fields now that we aren't changing the field name in the auditlog diff itself --- src/auditlog/registry.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/auditlog/registry.py b/src/auditlog/registry.py index 4588f84d..124fd3c4 100644 --- a/src/auditlog/registry.py +++ b/src/auditlog/registry.py @@ -41,13 +41,10 @@ def registrar(cls): if not issubclass(cls, Model): raise TypeError("Supplied model is not a valid model.") - # Register the model and signals. - reverse_mapping_fields = {v: k for k, v in iteritems(mapping_fields)} self._registry[cls] = { 'include_fields': include_fields, 'exclude_fields': exclude_fields, 'mapping_fields': mapping_fields, - 'reverse_mapping_fields': reverse_mapping_fields, } self._connect_signals(cls) @@ -115,7 +112,6 @@ def get_model_fields(self, model): 'include_fields': self._registry[model]['include_fields'], 'exclude_fields': self._registry[model]['exclude_fields'], 'mapping_fields': self._registry[model]['mapping_fields'], - 'reverse_mapping_fields': self._registry[model]['reverse_mapping_fields'] } From 1930fb78a5dd2796d20330aef1505bd25afb237d Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Mon, 11 Sep 2017 13:19:46 -0400 Subject: [PATCH 27/31] Update tests to test fallback on field.verbose_name --- src/auditlog_tests/models.py | 4 ++-- src/auditlog_tests/tests.py | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/auditlog_tests/models.py b/src/auditlog_tests/models.py index d72b5caa..2311b805 100644 --- a/src/auditlog_tests/models.py +++ b/src/auditlog_tests/models.py @@ -110,7 +110,7 @@ class SimpleMappingModel(models.Model): """ sku = models.CharField(max_length=100) - vtxt = models.CharField(max_length=100) + vtxt = models.CharField(verbose_name='Version', max_length=100) not_mapped = models.CharField(max_length=100) history = AuditlogHistoryField() @@ -215,7 +215,7 @@ class PostgresArrayFieldModel(models.Model): auditlog.register(ManyRelatedModel) auditlog.register(ManyRelatedModel.related.through) auditlog.register(SimpleExcludeModel, exclude_fields=['text']) -auditlog.register(SimpleMappingModel, mapping_fields={'sku': 'Product No.', 'vtxt': 'Version'}) +auditlog.register(SimpleMappingModel, mapping_fields={'sku': 'Product No.'}) auditlog.register(AdditionalDataIncludedModel) auditlog.register(DateTimeFieldModel) auditlog.register(ChoicesFieldModel) diff --git a/src/auditlog_tests/tests.py b/src/auditlog_tests/tests.py index dd27fbb6..98394964 100644 --- a/src/auditlog_tests/tests.py +++ b/src/auditlog_tests/tests.py @@ -223,14 +223,18 @@ class SimpleMappingModelTest(TestCase): def test_register_mapping_fields(self): smm = SimpleMappingModel(sku='ASD301301A6', vtxt='2.1.5', not_mapped='Not mapped') smm.save() - self.assertTrue(smm.history.latest().changes_dict['Product No.'][1] == 'ASD301301A6', - msg="The diff function maps 'sku' as 'Product No.' and can be retrieved.") + self.assertTrue(smm.history.latest().changes_dict['sku'][1] == 'ASD301301A6', + msg="The diff function retains 'sku' and can be retrieved.") self.assertTrue(smm.history.latest().changes_dict['not_mapped'][1] == 'Not mapped', msg="The diff function does not map 'not_mapped' and can be retrieved.") self.assertTrue(smm.history.latest().changes_display_dict['Product No.'][1] == 'ASD301301A6', msg="The diff function maps 'sku' as 'Product No.' and can be retrieved.") - self.assertTrue(smm.history.latest().changes_display_dict['not_mapped'][1] == 'Not mapped', - msg="The diff function does not map 'not_mapped' and can be retrieved.") + self.assertTrue(smm.history.latest().changes_display_dict['Version'][1] == '2.1.5', + msg=("The diff function maps 'vtxt' as 'Version' through verbose_name" + " setting on the model field and can be retrieved.")) + self.assertTrue(smm.history.latest().changes_display_dict['not mapped'][1] == 'Not mapped', + msg=("The diff function uses the django default verbose name for 'not_mapped'" + " and can be retrieved.")) class AdditionalDataModelTest(TestCase): From 380e144cad0abda309b729eaa19ae237ca7995d4 Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Mon, 11 Sep 2017 13:36:23 -0400 Subject: [PATCH 28/31] travis fix --- .travis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index 88c86942..64b25a1b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,6 +7,9 @@ python: 3.6 services: - postgresql +before_script: + - pyenv versions + env: - TOX_ENV=py36-django-18 - TOX_ENV=py35-django-18 From 5fb6197e5fefaa051bf4e5b858497cf7a3b72baf Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Mon, 11 Sep 2017 13:44:59 -0400 Subject: [PATCH 29/31] Revert to old travis image while they are fixing issues with it --- .travis.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 64b25a1b..435609cc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,12 +4,10 @@ dist: trusty sudo: required language: python python: 3.6 +group: deprecated-2017Q3 services: - postgresql -before_script: - - pyenv versions - env: - TOX_ENV=py36-django-18 - TOX_ENV=py35-django-18 From 55795e12a5514a33c0d789767a209a60b3f2b041 Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Wed, 13 Sep 2017 08:34:16 -0400 Subject: [PATCH 30/31] Update usage.rst --- docs/source/usage.rst | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/source/usage.rst b/docs/source/usage.rst index d992aa39..1d2b300d 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -56,12 +56,25 @@ If you have field names on your models that aren't intuitive or user friendly yo 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 be displayed as they are defined in the model. +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 ------ @@ -135,7 +148,12 @@ The :py:class:`AuditlogHistoryField` provides easy access to :py:class:`LogEntry -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 translate ``choices`` fields into their human readable form, display timestamps in the form ``Jun. 31, 2017 12:55pm``, and truncate text greater than 140 characters to 140 characters with an ellipsis appended. +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`` no mapping field is present +- Long text and char fields will be truncated to 140 characters 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. From 27ae05e67f89e329ab0c42fb2cb923cd55e567a5 Mon Sep 17 00:00:00 2001 From: Ryan Castner Date: Wed, 13 Sep 2017 08:44:43 -0400 Subject: [PATCH 31/31] Update usage.rst --- docs/source/usage.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 1d2b300d..22bb6f6f 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -150,8 +150,8 @@ The :py:class:`AuditlogHistoryField` provides easy access to :py:class:`LogEntry 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`` no mapping field is present -- Long text and char fields will be truncated to 140 characters with an ellipsis appended +- 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``