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. 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 3b85cae..f11c381 100644 --- a/src/dirtyfields/dirtyfields.py +++ b/src/dirtyfields/dirtyfields.py @@ -1,14 +1,19 @@ # 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 +SKIP_FIELD = object() + def get_m2m_with_model(given_model): return [ @@ -55,46 +60,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 = {} @@ -108,7 +117,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 +143,24 @@ 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 = self.__resolve_field_value(field) + if field_value == SKIP_FIELD: + continue + 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} + 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..6064853 100644 --- a/tests/models.py +++ b/tests/models.py @@ -68,6 +68,19 @@ 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 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 new file mode 100644 index 0000000..ee4c53e --- /dev/null +++ b/tests/test_auto_now.py @@ -0,0 +1,49 @@ +import pytest + +from .models import TestAutoNowDatetimeModel, TestAutoNowDatetimeFieldToCheckModel + + +@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 + + +@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