From 15dd0e34272fc67e833f5ee1c7f89d99212b73b0 Mon Sep 17 00:00:00 2001 From: Damjan Kuznar Date: Tue, 14 Jul 2020 11:47:37 +0200 Subject: [PATCH 1/5] Option to automatically include Date(Time)Field with auto_now enabled --- src/dirtyfields/dirtyfields.py | 33 ++++++++++++++++++++++++++++++--- tests/models.py | 6 ++++++ tests/test_auto_now.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 tests/test_auto_now.py diff --git a/src/dirtyfields/dirtyfields.py b/src/dirtyfields/dirtyfields.py index 3b85cae..36ce150 100644 --- a/src/dirtyfields/dirtyfields.py +++ b/src/dirtyfields/dirtyfields.py @@ -1,10 +1,13 @@ # Adapted from http://stackoverflow.com/questions/110803/dirty-fields-in-django +import datetime from copy import deepcopy from django.core.exceptions import ValidationError +from django.db.models import DateTimeField, DateField from django.db.models.expressions import BaseExpression from django.db.models.expressions import Combinable from django.db.models.signals import post_save, m2m_changed +from django.utils import timezone from .compare import raw_compare, compare_states, normalise_value from .compat import is_buffer @@ -108,7 +111,7 @@ def _as_dict_m2m(self): return m2m_fields - def get_dirty_fields(self, check_relationship=False, check_m2m=None, verbose=False): + def get_dirty_fields(self, check_relationship=False, check_m2m=None, verbose=False, include_auto_now=False): if self._state.adding: # If the object has not yet been saved in the database, all fields are considered dirty # for consistency (see https://github.com/romgar/django-dirtyfields/issues/65 for more details) @@ -134,6 +137,30 @@ def get_dirty_fields(self, check_relationship=False, check_m2m=None, verbose=Fal self.normalise_function) modified_fields.update(modified_m2m_fields) + if modified_fields and include_auto_now: + auto_add_fields = {} + relevant_datetime_fields = filter( + lambda value: isinstance(value, (DateTimeField, DateField)) and value.auto_now, + self._meta.fields + ) + for field in relevant_datetime_fields: + field_value = getattr(self, field.attname) + try: + # Store the converted value for fields with conversion + field_value = field.to_python(field_value) + except ValidationError: + # The current value is not valid so we cannot convert it + pass + print(field.name, field_value) + current_value = None + if isinstance(field, DateTimeField): + current_value = timezone.now() + elif isinstance(field, DateField): + current_value = datetime.date.today() + auto_add_fields[field.name] = {"saved": field_value, "current": current_value} + print(auto_add_fields) + modified_fields.update(auto_add_fields) + if not verbose: # Keeps backward compatibility with previous function return modified_fields = {key: self.normalise_function[0](value['saved']) for key, value in modified_fields.items()} @@ -144,8 +171,8 @@ def is_dirty(self, check_relationship=False, check_m2m=None): return {} != self.get_dirty_fields(check_relationship=check_relationship, check_m2m=check_m2m) - def save_dirty_fields(self): - dirty_fields = self.get_dirty_fields(check_relationship=True) + def save_dirty_fields(self, include_auto_now=False): + dirty_fields = self.get_dirty_fields(check_relationship=True, include_auto_now=include_auto_now) self.save(update_fields=dirty_fields.keys()) diff --git a/tests/models.py b/tests/models.py index 14bf280..1d88ad5 100644 --- a/tests/models.py +++ b/tests/models.py @@ -68,6 +68,12 @@ class TestCurrentDatetimeModel(DirtyFieldsMixin, models.Model): datetime_field = models.DateTimeField(default=timezone.now) +class TestAutoNowDatetimeModel(DirtyFieldsMixin, models.Model): + datetime_field = models.DateTimeField(auto_now=True) + date_field = models.DateField(auto_now=True) + test_string = models.TextField() + + class TestM2MModel(DirtyFieldsMixin, models.Model): m2m_field = models.ManyToManyField(TestModel) ENABLE_M2M_CHECK = True diff --git a/tests/test_auto_now.py b/tests/test_auto_now.py new file mode 100644 index 0000000..cee0870 --- /dev/null +++ b/tests/test_auto_now.py @@ -0,0 +1,29 @@ +from decimal import Decimal +import pytest + +from .models import TestAutoNowDatetimeModel + + +@pytest.mark.django_db +def test_auto_now_updated_on_save_dirty_fields(): + tm = TestAutoNowDatetimeModel.objects.create(test_string="test") + + previous_datetime = tm.datetime_field + previous_date = tm.date_field + + # If the object has just been saved in the db, fields are not dirty + assert not tm.is_dirty() + + # As soon as we change a field, it becomes dirty + tm.test_string = "changed" + assert tm.is_dirty() + + assert tm.get_dirty_fields(include_auto_now=True) == { + "test_string": "test", + "datetime_field": previous_datetime, + "date_field": previous_date, + } + tm.save_dirty_fields(include_auto_now=True) + tm.refresh_from_db() + assert tm.datetime_field > previous_datetime + assert tm.date_field == previous_date # date most likely will not change during updates From e172cae06d329802931aae44346f4c1090aafc7d Mon Sep 17 00:00:00 2001 From: Damjan Kuznar Date: Tue, 14 Jul 2020 11:48:52 +0200 Subject: [PATCH 2/5] Remove debugging prints --- src/dirtyfields/dirtyfields.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/dirtyfields/dirtyfields.py b/src/dirtyfields/dirtyfields.py index 36ce150..1deefc1 100644 --- a/src/dirtyfields/dirtyfields.py +++ b/src/dirtyfields/dirtyfields.py @@ -151,14 +151,12 @@ def get_dirty_fields(self, check_relationship=False, check_m2m=None, verbose=Fal except ValidationError: # The current value is not valid so we cannot convert it pass - print(field.name, field_value) current_value = None if isinstance(field, DateTimeField): current_value = timezone.now() elif isinstance(field, DateField): current_value = datetime.date.today() auto_add_fields[field.name] = {"saved": field_value, "current": current_value} - print(auto_add_fields) modified_fields.update(auto_add_fields) if not verbose: From 2ce33fd287befcfb7ab92ba26000b344c8f24128 Mon Sep 17 00:00:00 2001 From: Damjan Kuznar Date: Tue, 14 Jul 2020 12:06:40 +0200 Subject: [PATCH 3/5] Update docs --- docs/index.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/index.rst b/docs/index.rst index 9abd63d..4fd71e6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -142,6 +142,17 @@ If you want to check a limited set of model fields, you should set ``FIELDS_TO_C This can be used in order to increase performance. +DateTimeField and DateField with auto_now set to True. +------------------------------------------------------ +You can automatically include `DateTimeField` and `DateField` fields with `auto_now` set to `True` to the list of dirty +fields when any other field is dirty by using the `include_auto_now` parameter with `get_dirty_fields` or +`save_dirty_fields`. This is for example useful when using `modified_date` field to track record updates. + +:: + + >>> tm.get_dirty_fields(include_auto_now=True) + >>> tm.save_dirty_fields(include_auto_now=True) + Saving dirty fields. ---------------------------- If you want to only save dirty fields from an instance in the database (only these fields will be involved in SQL query), you can use ``save_dirty_fields`` method. From 83190fba2f4ba0404a0d0328d07da07a6c4d1172 Mon Sep 17 00:00:00 2001 From: Damjan Kuznar Date: Tue, 14 Jul 2020 13:21:33 +0200 Subject: [PATCH 4/5] Refactoring for better code test coverage and additional test --- requirements.txt | 2 +- src/dirtyfields/dirtyfields.py | 75 +++++++++++++++++----------------- tests/models.py | 7 ++++ tests/test_auto_now.py | 23 ++++++++++- 4 files changed, 68 insertions(+), 39 deletions(-) diff --git a/requirements.txt b/requirements.txt index 7080741..cc80864 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -Django>=1.11 +Django>=1.11,<3 pytz>=2015.7 diff --git a/src/dirtyfields/dirtyfields.py b/src/dirtyfields/dirtyfields.py index 1deefc1..4772564 100644 --- a/src/dirtyfields/dirtyfields.py +++ b/src/dirtyfields/dirtyfields.py @@ -12,6 +12,7 @@ from .compare import raw_compare, compare_states, normalise_value from .compat import is_buffer +SKIP_FIELD = object() def get_m2m_with_model(given_model): return [ @@ -58,46 +59,50 @@ def _as_dict(self, check_relationship, include_primary_key=True): deferred_fields = self.get_deferred_fields() for field in self._meta.fields: + field_value = self.__resolve_field_value(field, check_relationship, include_primary_key, deferred_fields) + if field_value != SKIP_FIELD: + # Explanation of copy usage here : + # https://github.com/romgar/django-dirtyfields/commit/efd0286db8b874b5d6bd06c9e903b1a0c9cc6b00 + all_field[field.name] = deepcopy(field_value) - # For backward compatibility reasons, in particular for fkey fields, we check both - # the real name and the wrapped name (it means that we can specify either the field - # name with or without the "_id" suffix. - field_names_to_check = [field.name, field.get_attname()] - if self.FIELDS_TO_CHECK and (not any(name in self.FIELDS_TO_CHECK for name in field_names_to_check)): - continue + return all_field - if field.primary_key and not include_primary_key: - continue + def __resolve_field_value(self, field, check_relationship=False, include_primary_key=True, deferred_fields=tuple()): + # For backward compatibility reasons, in particular for fkey fields, we check both + # the real name and the wrapped name (it means that we can specify either the field + # name with or without the "_id" suffix. + field_names_to_check = [field.name, field.get_attname()] + if self.FIELDS_TO_CHECK and (not any(name in self.FIELDS_TO_CHECK for name in field_names_to_check)): + return SKIP_FIELD - if field.remote_field: - if not check_relationship: - continue + if field.primary_key and not include_primary_key: + return SKIP_FIELD - if field.get_attname() in deferred_fields: - continue + if field.remote_field: + if not check_relationship: + return SKIP_FIELD - field_value = getattr(self, field.attname) + if field.get_attname() in deferred_fields: + return SKIP_FIELD - # If current field value is an expression, we are not evaluating it - if isinstance(field_value, (BaseExpression, Combinable)): - continue + field_value = getattr(self, field.attname) - try: - # Store the converted value for fields with conversion - field_value = field.to_python(field_value) - except ValidationError: - # The current value is not valid so we cannot convert it - pass + # If current field value is an expression, we are not evaluating it + if isinstance(field_value, (BaseExpression, Combinable)): + return SKIP_FIELD - if is_buffer(field_value): - # psycopg2 returns uncopyable type buffer for bytea - field_value = bytes(field_value) + try: + # Store the converted value for fields with conversion + field_value = field.to_python(field_value) + except ValidationError: + # The current value is not valid so we cannot convert it + pass - # Explanation of copy usage here : - # https://github.com/romgar/django-dirtyfields/commit/efd0286db8b874b5d6bd06c9e903b1a0c9cc6b00 - all_field[field.name] = deepcopy(field_value) + if is_buffer(field_value): + # psycopg2 returns uncopyable type buffer for bytea + field_value = bytes(field_value) - return all_field + return field_value def _as_dict_m2m(self): m2m_fields = {} @@ -144,13 +149,9 @@ def get_dirty_fields(self, check_relationship=False, check_m2m=None, verbose=Fal self._meta.fields ) for field in relevant_datetime_fields: - field_value = getattr(self, field.attname) - try: - # Store the converted value for fields with conversion - field_value = field.to_python(field_value) - except ValidationError: - # The current value is not valid so we cannot convert it - pass + field_value = self.__resolve_field_value(field) + if field_value == SKIP_FIELD: + continue current_value = None if isinstance(field, DateTimeField): current_value = timezone.now() diff --git a/tests/models.py b/tests/models.py index 1d88ad5..6064853 100644 --- a/tests/models.py +++ b/tests/models.py @@ -74,6 +74,13 @@ class TestAutoNowDatetimeModel(DirtyFieldsMixin, models.Model): test_string = models.TextField() +class TestAutoNowDatetimeFieldToCheckModel(DirtyFieldsMixin, models.Model): + datetime_field = models.DateTimeField(auto_now=True) + date_field = models.DateField(auto_now=True) + test_string = models.TextField() + FIELDS_TO_CHECK = ["test_string"] + + class TestM2MModel(DirtyFieldsMixin, models.Model): m2m_field = models.ManyToManyField(TestModel) ENABLE_M2M_CHECK = True diff --git a/tests/test_auto_now.py b/tests/test_auto_now.py index cee0870..851be5c 100644 --- a/tests/test_auto_now.py +++ b/tests/test_auto_now.py @@ -1,7 +1,7 @@ from decimal import Decimal import pytest -from .models import TestAutoNowDatetimeModel +from .models import TestAutoNowDatetimeModel, TestAutoNowDatetimeFieldToCheckModel @pytest.mark.django_db @@ -27,3 +27,24 @@ def test_auto_now_updated_on_save_dirty_fields(): tm.refresh_from_db() assert tm.datetime_field > previous_datetime assert tm.date_field == previous_date # date most likely will not change during updates + + +@pytest.mark.django_db +def test_fields_to_check_set_skips_automatic_include(): + tm = TestAutoNowDatetimeFieldToCheckModel.objects.create(test_string="test") + + previous_datetime = tm.datetime_field + previous_date = tm.date_field + + # If the object has just been saved in the db, fields are not dirty + assert not tm.is_dirty() + + # As soon as we change a field, it becomes dirty + tm.test_string = "changed" + assert tm.is_dirty() + + assert tm.get_dirty_fields(include_auto_now=True) == {"test_string": "test"} + tm.save_dirty_fields(include_auto_now=True) + tm.refresh_from_db() + assert tm.datetime_field == previous_datetime + assert tm.date_field == previous_date # date most likely will not change during updates From 072c17201298cf44e6464120d231c026d3cdb83a Mon Sep 17 00:00:00 2001 From: Damjan Kuznar Date: Tue, 14 Jul 2020 13:27:47 +0200 Subject: [PATCH 5/5] Resolved flake8 errors --- src/dirtyfields/dirtyfields.py | 1 + tests/test_auto_now.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dirtyfields/dirtyfields.py b/src/dirtyfields/dirtyfields.py index 4772564..f11c381 100644 --- a/src/dirtyfields/dirtyfields.py +++ b/src/dirtyfields/dirtyfields.py @@ -14,6 +14,7 @@ SKIP_FIELD = object() + def get_m2m_with_model(given_model): return [ (f, f.model if f.model != given_model else None) diff --git a/tests/test_auto_now.py b/tests/test_auto_now.py index 851be5c..ee4c53e 100644 --- a/tests/test_auto_now.py +++ b/tests/test_auto_now.py @@ -1,4 +1,3 @@ -from decimal import Decimal import pytest from .models import TestAutoNowDatetimeModel, TestAutoNowDatetimeFieldToCheckModel