Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
Django>=1.11
Django>=1.11,<3
pytz>=2015.7
93 changes: 60 additions & 33 deletions src/dirtyfields/dirtyfields.py
Original file line number Diff line number Diff line change
@@ -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 [
Expand Down Expand Up @@ -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 = {}
Expand All @@ -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)
Expand All @@ -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()}
Expand All @@ -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())


Expand Down
13 changes: 13 additions & 0 deletions tests/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions tests/test_auto_now.py
Original file line number Diff line number Diff line change
@@ -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