Skip to content
Closed
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
7 changes: 7 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ Install
$ pip install django-dirtyfields


Run tests
=========

.. code-block:: bash
$ PYTHONPATH=src coverage run -m pytest


Usage
=====

Expand Down
2 changes: 1 addition & 1 deletion src/dirtyfields/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
121 changes: 80 additions & 41 deletions src/dirtyfields/dirtyfields.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand All @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion tests-requirements.txt
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions tests/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions tests/test_auto_now.py
Original file line number Diff line number Diff line change
@@ -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