Django - 保存前比较旧字段和新字段的值

92

我有一个Django模型,需要在保存前比较字段的旧值和新值。

我已经尝试过save()继承和pre_save信号。它们都被正确触发了,但我找不到实际更改的字段列表,也无法比较旧值和新值。是否有方法可以解决?我需要这个来优化pre-save操作。

谢谢!


1
save方法中从数据库中获取旧值,然后逐个字段进行比较,这样怎么样? - J0HN
你想要什么样的优化? - Leonardo.Z
我有Python触发器代码,用于计算报告数据,只有在某些字段更改时才需要重新计算,而不是在任何保存事件上。 - Y.N
@Leonardo.Z 这取决于事务隔离级别,并且正确处理这种情况远远超出了问题的范围。 - J0HN
显示剩余6条评论
11个回答

92

对于这个问题,Django 有非常简单的方法。

可以在模型的 init 方法中使用“记忆”传入的值,如下所示:

def __init__(self, *args, **kwargs):
    super(MyClass, self).__init__(*args, **kwargs)
    self.initial_parametername = self.parametername
    ---
    self.initial_parameternameX = self.parameternameX

现实生活中的例子:

在课堂上:

def __init__(self, *args, **kwargs):
    super(MyClass, self).__init__(*args, **kwargs)
    self.__important_fields = ['target_type', 'target_id', 'target_object', 'number', 'chain', 'expiration_date']
    for field in self.__important_fields:
        setattr(self, '__original_%s' % field, getattr(self, field))

def has_changed(self):
    for field in self.__important_fields:
        orig = '__original_%s' % field
        if getattr(self, orig) != getattr(self, field):
            return True
    return False

然后在modelform的保存方法中:

def save(self, force_insert=False, force_update=False, commit=True):
    # Prep the data
    obj = super(MyClassForm, self).save(commit=False)

    if obj.has_changed():

        # If we're down with commitment, save this shit
        if commit:
            obj.save(force_insert=True)

    return obj

8
我更喜欢Odif的方式,因为我需要触发没有表单的模型的操作(在API或管理站点进行更改后)。 - Y.N
1
__init__ 什么时候被调用?它只在初始创建时工作还是在后续更新中也有效? - wasabigeek
1
每次创建模型实例时都会调用__init__。如果实例在其生命周期内更新了多次,则__init__仅在开始时调用。 - Odif Yltsaeb
1
这里仅指不涉及使用 savebulk_create 将模型保存到其他位置的情况。 - Julio Marins
1
请注意这种方法。当我尝试执行 Model.objects.delete() 时,如果我想要缓存的字段是外键(即使您尝试将self._old_<field>_id 存储为整数),我会遇到许多问题(包括 Python 崩溃或达到递归限制)。 - DimmuR
显示剩余7条评论

63

最好在ModelForm级别上执行此操作。

在那里,您可以获得所有需要比较的数据,以在保存方法中使用:

  1. self.data:传递给表格的实际数据。
  2. self.cleaned_data:经过验证后已清理的数据,包含可保存在模型中的数据
  3. self.changed_data:已更改的字段列表。如果没有更改,则为空

如果您想在模型级别进行此操作,则可以按照Odif答案中指定的方法进行。


1
我同意你的答案,同时 self.instance 在这个问题上也可以派得上用场。 - lehins
@AlexeyKuleshevich同意,但仅限于在表单的_post_clean(is_valid->errors->full_clean->_post_clean)之前,此时实例将被更新以包括新值。如果是它们的第一次调用,则在form.clean_fieldname()form.clean()中访问似乎是可以的。 - jozxyqk
4
那样做没问题,但仅适用于使用表单保存的情况,而这并不总是发生。 - gdvalderrama
是的,没错。如果你不使用表单,就无法做到这一点。但使用表单是理想的方式。 - Sahil kalra
self.changed_data 对我来说是新的。 - Mohammed Shareef C
我很想听Sahil或其他人对“最好这样做”的限制的看法。如果您不仅通过表单、管理员以及消费者,甚至通过导入管道等方式有新数据进来,那么ModelForm是否仍然是您首选的方法?在这些情况下,我认为保存钩子是放置此类逻辑的最佳单个位置。 - LisaD

44
此外,您还可以使用django-model-utils中的FieldTracker来实现此功能:
  1. Just add tracker field to your model:

    tracker = FieldTracker()
    
  2. Now in pre_save and post_save you can use:

    instance.tracker.previous('modelfield')     # get the previous value
    instance.tracker.has_changed('modelfield')  # just check if it is changed
    

6
是的,我就是喜欢这个界面的简洁...又有一个需求要加入了! - Kevin Parker
但是这个跟踪器字段是表中的真实列吗?还是只是一个虚假的字段? - toscanelli
3
@toscanelli,它不会向表中添加一列。 - texnic
1
提醒一下,确保再次进行makemigrations和migrate操作,否则会出现属性错误,如“tracker未找到”。 - Amoroso
4
这个东西很诱人,但有人在这里报告了一个性能问题。而且团队没有任何更新或跟进。所以,检查一下tracker.py的源代码。看起来需要很多工作和信号传递。所以问题是它是否值得使用,或者用例太有限只需要追踪一个或两个字段。 - John Pang

37

Django的文档中包含一个示例,准确演示如何执行此操作:

从Django 1.8开始(包括Django 2.x和3.x),有一个from_db类方法,可用于在从数据库加载时自定义模型实例创建。

注意:如果使用此方法,则不会有额外的数据库查询。

from django.db import Model

class MyClass(models.Model):
    
    @classmethod
    def from_db(cls, db, field_names, values):
        instance = super().from_db(db, field_names, values)
        
        # save original values, when model is loaded from database,
        # in a separate attribute on the model
        instance._loaded_values = dict(zip(field_names, values))
        
        return instance
因此,现在原始值可以在模型的 _loaded_values 属性中使用。您可以在 save 方法中访问该属性,以检查某个值是否正在更新。
class MyClass(models.Model):
    field_1 = models.CharField(max_length=1)

    @classmethod
    def from_db(cls, db, field_names, values):
        ...
        # use code from above

    def save(self, *args, **kwargs):

        # check if a new db row is being added
        # When this happens the `_loaded_values` attribute will not be available
        if not self._state.adding:

            # check if field_1 is being updated
            if self._loaded_values['field_1'] != self.field_1:
                # do something

        super().save(*args, **kwargs)
            
            

2
这很酷,但它不会给你M2M关系。例如,如果您想要跟踪用户关联的组的更改,似乎没有任何方法可以使用此技术来实现。 - shacker

5
在现代的Django中,有一件非常重要的事情需要添加到上面答案中被接受的答案的内容中。当您使用deferonly查询集API时,您可能会陷入无限递归的情况中。 django.db.models.query_utils.DeferredAttribute__get __()方法调用django.db.models.Modelrefresh_from_db()方法。在refresh_from_db()中有一行db_instance = db_instance_qs.get(),这一行递归地调用了实例的__init__()方法。
因此,有必要确保目标属性未被延迟。
def __init__(self, *args, **kwargs):
    super(MyClass, self).__init__(*args, **kwargs)

    deferred_fields = self.get_deferred_fields()
    important_fields = ['target_type', 'target_id', 'target_object', 'number', 'chain', 'expiration_date']

    self.__important_fields = list(filter(lambda x: x not in deferred_fields, important_fields))
    for field in self.__important_fields:
        setattr(self, '__original_%s' % field, getattr(self, field))

最近我遇到了这个问题。感谢@Youngkwang的帮助。 - suvodipMondal
还有一件事,如果我想选择相关的字段呢? - suvodipMondal
不推荐将原始值存储在__init__中,即使您的方法很详细。请改用from_db。请参阅Django讨论:https://forum.djangoproject.com/t/recursionerror-when-deleting-a-model-instance-in-admin/7862/19 - undefined

5

这样也可以起作用:

class MyModel(models.Model):
    my_field = fields.IntegerField()

    def save(self, *args, **kwargs):
       # Compare old vs new
       if self.pk:
           obj = MyModel.objects.values('my_value').get(pk=self.pk)
           if obj['my_value'] != self.my_value:
               # Do stuff...
               pass
       super().save(*args, **kwargs)

12
在每次保存之前执行查找似乎不太高效。 - Ian E
1
“在每次保存之前执行查找似乎不太高效。”我同意。但这取决于上下文。无论如何,你有什么建议? - Akhorus
2
@IanE 我添加了一个答案,避免了数据库查询 https://dev59.com/yWAg5IYBdhLWcg3wo8P2#64116052 - GunnerFan

4

我的使用情况是,每当某个字段更改其值时,我需要在模型中设置一个非规范化的值。然而,由于被监视的字段是m2m关系,我不想在每次调用保存时执行DB查找以检查是否需要更新非规范化字段。因此,我编写了这个小混合类(使用@Odif Yitsaeb的答案作为灵感),以便仅在必要时更新非规范化字段。

class HasChangedMixin(object):
    """ this mixin gives subclasses the ability to set fields for which they want to monitor if the field value changes """
    monitor_fields = []

    def __init__(self, *args, **kwargs):
        super(HasChangedMixin, self).__init__(*args, **kwargs)
        self.field_trackers = {}

    def __setattr__(self, key, value):
        super(HasChangedMixin, self).__setattr__(key, value)
        if key in self.monitor_fields and key not in self.field_trackers:
            self.field_trackers[key] = value

    def changed_fields(self):
        """
        :return: `list` of `str` the names of all monitor_fields which have changed
        """
        changed_fields = []
        for field, initial_field_val in self.field_trackers.items():
            if getattr(self, field) != initial_field_val:
                changed_fields.append(field)

        return changed_fields

喜欢这个实现方式,高效且简单,可以轻松添加到任何可能需要它的模型中 :) - Hassek

2

这里有一个应用程序可以让您在模型保存之前访问字段的先前和当前值:django-smartfields

以下是如何以优美的声明性方式解决此问题:

from django.db import models
from smartfields import fields, processors
from smartfields.dependencies import Dependency

class ConditionalProcessor(processors.BaseProcessor):

    def process(self, value, stashed_value=None, **kwargs):
        if value != stashed_value:
            # do any necessary modifications to new value
            value = ... 
        return value

class MyModel(models.Model):
    my_field = fields.CharField(max_length=10, dependencies=[
        Dependency(processor=ConditionalProcessor())
    ])

此外,仅在替换字段值时才会调用此处理器。

2

我同意Sahil的观点,使用ModelForm更好更容易。但是,您需要自定义ModelForm的clean方法并在那里执行验证。在我的情况下,我想要防止更新模型实例,如果模型上的字段已设置。

我的代码如下:

from django.forms import ModelForm

class ExampleForm(ModelForm):
    def clean(self):
        cleaned_data = super(ExampleForm, self).clean()
        if self.instance.field:
            raise Exception
        return cleaned_data

1
另一种实现方法是使用 post_initpost_save 信号来存储模型的初始状态。
@receiver(models.signals.post_init)
@receiver(models.signals.post_save)
def _set_initial_state(
    sender: Type[Any],
    instance: Optional[models.Model] = None,
    **kwargs: Any,
) -> None:
    """
    Store the initial state of the model
    """

    if isinstance(instance, MyModel):
        instance._initial_state = instance.state

其中stateMyModel中一个字段的名称,_initial_state是初始化/保存模态框时复制的初始版本。

请注意,如果state是容器类型(例如字典),您可能需要适当使用deepcopy


我刚试了这种方法,但是出现了一个错误,说实例没有state属性。你是不是指的是instance._state?无论如何,你怎么访问最初的字段值呢?instance._state似乎没有存储这些值。 - JGC
state 是您希望保存的变量名称。 _initial_state 是已保存的副本。请使用适当的变量名称进行替换。 - Danielle Madeley
我在使用你的 instance._initial_state = instance.state 时出现错误,因为 instance.state 不存在。我收到了一个错误,指出“instance没有state属性”。 - JGC
你的模型中有一个名为 state 的字段吗? - Danielle Madeley
不是。你的例子是基于 state 是模型字段,而 _initial_state 是初始值的拷贝。这样就解释了一切。我之前以为 state 是 Django 内置变量,用于存储模型状态。 - JGC
1
正确,state是您模型中字段的名称。 - Danielle Madeley

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接