diff --git a/README.rst b/README.rst index 761d44c..7683e75 100644 --- a/README.rst +++ b/README.rst @@ -46,6 +46,13 @@ Install $ pip install django-dirtyfields +Run tests +========= + +.. code-block:: bash + $ PYTHONPATH=src coverage run -m pytest + + Usage ===== diff --git a/src/dirtyfields/__init__.py b/src/dirtyfields/__init__.py index ab41041..3b2cbce 100644 --- a/src/dirtyfields/__init__.py +++ b/src/dirtyfields/__init__.py @@ -7,6 +7,6 @@ __all__ = ['DirtyFieldsMixin'] __version__ = "1.8.3.dev0" -from dirtyfields.dirtyfields import DirtyFieldsMixin +from dirtyfields.dirtyfields import DefaultDirtyFieldsMixin, DirtyFieldsMixin VERSION = tuple(map(int, __version__.split(".")[0:3])) diff --git a/src/dirtyfields/dirtyfields.py b/src/dirtyfields/dirtyfields.py index 142b1ea..9ac4d5b 100644 --- a/src/dirtyfields/dirtyfields.py +++ b/src/dirtyfields/dirtyfields.py @@ -1,10 +1,13 @@ from copy import deepcopy +from django.conf import settings from django.core.exceptions import ValidationError from django.core.files import File +from django.db.models import DateField, DateTimeField 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 @@ -48,57 +51,65 @@ def _connect_m2m_relations(self): dispatch_uid='{name}-DirtyFieldsMixin-sweeper-m2m'.format( name=self.__class__.__name__)) - def _as_dict(self, check_relationship, include_primary_key=True): - """ - Capture the model fields' state as a dictionary. + def _skip_field(self, field, check_relationship, include_primary_key): + # 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 True - Only capture values we are confident are in the database, or would be - saved to the database if self.save() is called. - """ - all_field = {} + if field.primary_key and not include_primary_key: + return True - deferred_fields = self.get_deferred_fields() + if field.remote_field: + if not check_relationship: + return True - for field in self._meta.concrete_fields: + if field.get_attname() in self.get_deferred_fields(): + return True - # 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 + field_value = getattr(self, field.attname) - if field.primary_key and not include_primary_key: - continue + if isinstance(field_value, File): + # Uses the name for files due to a perfomance regression caused by Django 3.1. + # For more info see: https://github.com/romgar/django-dirtyfields/issues/165 + field_value = field_value.name - if field.remote_field: - if not check_relationship: - continue + # If current field value is an expression, we are not evaluating it + if isinstance(field_value, (BaseExpression, Combinable)): + return True + return False - if field.get_attname() in deferred_fields: - continue + def _resolve_field_value(self, field): + 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 = getattr(self, field.attname) + if isinstance(field_value, memoryview): + # psycopg2 returns uncopyable type buffer for bytea + field_value = bytes(field_value) - if isinstance(field_value, File): - # Uses the name for files due to a perfomance regression caused by Django 3.1. - # For more info see: https://github.com/romgar/django-dirtyfields/issues/165 - field_value = field_value.name + return field_value - # If current field value is an expression, we are not evaluating it - if isinstance(field_value, (BaseExpression, Combinable)): - continue + def _as_dict(self, check_relationship, include_primary_key=True): + """ + Capture the model fields' state as a dictionary. + + Only capture values we are confident are in the database, or would be + saved to the database if self.save() is called. + """ + all_field = {} - 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 + for field in self._meta.concrete_fields: + if self._skip_field(field, check_relationship, include_primary_key): + continue - if isinstance(field_value, memoryview): - # psycopg2 returns uncopyable type buffer for bytea - field_value = bytes(field_value) + field_value = self._resolve_field_value(field) # Explanation of copy usage here : # https://github.com/romgar/django-dirtyfields/commit/efd0286db8b874b5d6bd06c9e903b1a0c9cc6b00 @@ -144,6 +155,22 @@ 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 getattr(settings, 'DIRTYFIELDS_UPDATE_AUTO_NOW', False): + # If any fields are marked as dirty, we want to update auto_now fields too + auto_now_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 isinstance(field, DateTimeField): + current_value = timezone.now() + elif isinstance(field, DateField): + current_value = timezone.now().date() + auto_now_fields[field.name] = {"saved": field_value, "current": current_value} + modified_fields.update(auto_now_fields) + if not verbose: # Keeps backward compatibility with previous function return modified_fields = { @@ -159,10 +186,22 @@ def is_dirty(self, check_relationship=False, check_m2m=None): def save_dirty_fields(self): if self._state.adding: - self.save() + super().save() else: dirty_fields = self.get_dirty_fields(check_relationship=True) - self.save(update_fields=dirty_fields.keys()) + super().save(update_fields=dirty_fields.keys()) + + +class DefaultDirtyFieldsMixin(DirtyFieldsMixin): + """ + Save dirty fields automatically when calling save() + """ + + def save(self, *args, save_all=False, **kwargs): + if save_all: + super().save(*args, **kwargs) + else: + self.save_dirty_fields() def reset_state(sender, instance, **kwargs): diff --git a/tests-requirements.txt b/tests-requirements.txt index 0ca6df4..90636ae 100644 --- a/tests-requirements.txt +++ b/tests-requirements.txt @@ -1,5 +1,5 @@ # let pip choose the latest version compatible with the python/django version being tested. -psycopg2 +psycopg2-binary pytest pytest-django jsonfield diff --git a/tests/models.py b/tests/models.py index b2dd9e9..cdc1460 100644 --- a/tests/models.py +++ b/tests/models.py @@ -77,6 +77,12 @@ class CurrentDatetimeModelTest(DirtyFieldsMixin, models.Model): datetime_field = models.DateTimeField(default=django_timezone.now) +class AutoNowDatetimeModel(DirtyFieldsMixin, models.Model): + datetime_field = models.DateTimeField(auto_now=True) + date_field = models.DateField(auto_now=True) + test_string = models.TextField() + + class Many2ManyModelTest(DirtyFieldsMixin, models.Model): m2m_field = models.ManyToManyField(ModelTest) ENABLE_M2M_CHECK = True diff --git a/tests/test_auto_now.py b/tests/test_auto_now.py new file mode 100644 index 0000000..c0a6db5 --- /dev/null +++ b/tests/test_auto_now.py @@ -0,0 +1,31 @@ +import pytest + +from django.test.utils import override_settings + +from .models import AutoNowDatetimeModel + + +@pytest.mark.django_db +@override_settings(DIRTYFIELDS_UPDATE_AUTO_NOW=True) +def test_auto_now_updated_on_save_dirty_fields(): + tm = AutoNowDatetimeModel.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() == { + "test_string": "test", + "datetime_field": previous_datetime, + "date_field": previous_date, + } + tm.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