保存时,如何检查字段是否已更改?

372

在我的模型中,我有:

class Alias(MyBaseModel):
    remote_image = models.URLField(
        max_length=500, null=True,
        help_text='''
            A URL that is downloaded and cached for the image.
            Only used when the alias is made
        '''
    )
    image = models.ImageField(
        upload_to='alias', default='alias-default.png',
        help_text="An image representing the alias"
    )

    
    def save(self, *args, **kw):
        if (not self.image or self.image.name == 'alias-default.png') and self.remote_image :
            try :
                data = utils.fetch(self.remote_image)
                image = StringIO.StringIO(data)
                image = Image.open(image)
                buf = StringIO.StringIO()
                image.save(buf, format='PNG')
                self.image.save(
                    hashlib.md5(self.string_id).hexdigest() + ".png", ContentFile(buf.getvalue())
                )
            except IOError :
                pass

这在第一次 remote_image 更改时运作良好。

当别名上的 remote_image 被修改时,我如何获取新图像?其次,有更好的缓存远程图像的方法吗?

28个回答

533

你需要覆盖models.Model__init__方法,以便保留原始值的副本。这将使您无需进行另一个数据库查找(这总是一件好事)。

    class Person(models.Model):
        name = models.CharField()

        __original_name = None

        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.__original_name = self.name

        def save(self, force_insert=False, force_update=False, *args, **kwargs):
            if self.name != self.__original_name:
                # name changed - do something here

            super().save(force_insert, force_update, *args, **kwargs)
            self.__original_name = self.name

32
与其覆盖 init 方法,我建议使用 post_init 信号。http://docs.djangoproject.com/en/dev/ref/signals/#post-init - vikingosegundo
41
Django文档建议使用覆盖方法:http://docs.djangoproject.com/en/dev/topics/db/models/#overriding-predefined-model-methods - Colonel Sponsz
14
如果您对对象进行更改,保存它,然后再进行其他更改并再次调用 save(),它仍将正确工作。 - philfreo
26
@Josh如果你有几个应用服务器同时连接同一个数据库,而数据库只跟踪内存中的更改,这样会出现问题吗? - Jens Alm
16
@lajarre,我认为你的评论有点误导。文档建议你在这样做时要小心。他们并没有反对这样做。 - Josh
显示剩余30条评论

269

我使用以下混合器:

from django.forms.models import model_to_dict


class ModelDiffMixin(object):
    """
    A model mixin that tracks model fields' values and provide some useful api
    to know what fields have been changed.
    """

    def __init__(self, *args, **kwargs):
        super(ModelDiffMixin, self).__init__(*args, **kwargs)
        self.__initial = self._dict

    @property
    def diff(self):
        d1 = self.__initial
        d2 = self._dict
        diffs = [(k, (v, d2[k])) for k, v in d1.items() if v != d2[k]]
        return dict(diffs)

    @property
    def has_changed(self):
        return bool(self.diff)

    @property
    def changed_fields(self):
        return self.diff.keys()

    def get_field_diff(self, field_name):
        """
        Returns a diff for field if it's changed and None otherwise.
        """
        return self.diff.get(field_name, None)

    def save(self, *args, **kwargs):
        """
        Saves model and set initial state.
        """
        super(ModelDiffMixin, self).save(*args, **kwargs)
        self.__initial = self._dict

    @property
    def _dict(self):
        return model_to_dict(self, fields=[field.name for field in
                             self._meta.fields])

使用方法:

>>> p = Place()
>>> p.has_changed
False
>>> p.changed_fields
[]
>>> p.rank = 42
>>> p.has_changed
True
>>> p.changed_fields
['rank']
>>> p.diff
{'rank': (0, 42)}
>>> p.categories = [1, 3, 5]
>>> p.diff
{'categories': (None, [1, 3, 5]), 'rank': (0, 42)}
>>> p.get_field_diff('categories')
(None, [1, 3, 5])
>>> p.get_field_diff('rank')
(0, 42)
>>>

注意

请注意,此解决方案仅适用于当前请求的情况。因此,它主要适用于简单的情况。在并发环境中,多个请求可以同时操作同一个模型实例的情况下,您肯定需要采用不同的方法。



4
非常完美,不需要执行额外的查询。非常感谢! - Stéphane
@IMFletcher 在您的情况下,您处理的是分配给模型字段的未经清理的数据。这种情况超出了此 Mixin 的范围。您可以尝试首先使用模型表单清理数据,在保存时免费填充您的模型字段。或者手动进行,即 model_instance.field_name = model_form.cleaned_data['field_name']。 - iperelivskiy
9
Mixin 很棒,但与 .only() 一起使用时会出现问题。如果 Model 至少有 3 个字段,则调用 Model.objects.only('id') 将导致无限递归。 为解决此问题,我们应该在初始化时删除被延迟的字段,并略微更改 _dict 属性。链接 - gleb.pitsevich
28
和 Josh 的回答类似,这段代码在单进程测试服务器上看起来运行良好,但一旦部署到任何多进程服务器上,就会产生错误的结果。你无法在不查询数据库的情况下知道是否已经改变了数据库中的值。 - rspeer
2
在 refresh_from_db 中,我们还应该重新初始化初始状态。def refresh_from_db(self, using=None, fields=None): super().refresh_from_db(using, fields) self.__initial = self._dict - Lev Lybin
显示剩余13条评论

210

最好的方法是使用pre_save信号。也许在2009年提出这个问题并得到答案时,这可能不是一个选择,但是今天看到这个问题的任何人都应该以这种方式做:

@receiver(pre_save, sender=MyModel)
def do_something_if_changed(sender, instance, **kwargs):
    try:
        obj = sender.objects.get(pk=instance.pk)
    except sender.DoesNotExist:
        pass # Object is new, so field hasn't technically changed, but you may want to do something else here.
    else:
        if not obj.some_field == instance.some_field: # Field has changed
            # do something

8
如果Josh上面描述的方法不涉及额外的数据库查询,为什么这是最好的方法? - joshcartme
54
  1. 那个方法是一个hack,信号基本上是设计用于类似这样的用途
  2. 那个方法需要对您的模型进行修改,而这个方法则不需要
  3. 正如您可以在那个答案的评论中阅读到的那样,它具有潜在的问题副作用,而这种解决方案则没有。
- Chris Pratt
2
如果你只关心在保存之前捕捉到改变,那么这种方式非常好。然而,如果你想要立即对改变做出反应,这种方式就不起作用了。我已经遇到了许多类似的情况(并且现在正在处理一个这样的实例)。 - Josh
7
@Josh:你所说的“立刻对变化作出反应”是什么意思?这会以何种方式不让你“做出反应”? - Chris Pratt
3
抱歉,我忘记了这个问题的范围,并参考了一个完全不同的问题。话虽如此,我认为信号是在这里采取的好方法(既然它们可用)。但是,我发现许多人认为覆盖保存是一种“hack”。我不认为情况是这样的。正如这个答案建议的那样(https://dev59.com/mHVC5IYBdhLWcg3w1E1q),我认为当您不处理“特定于所涉及模型”的更改时,覆盖是最佳实践。话虽如此,我没有打算强加这种信仰给任何人。 - Josh
显示剩余7条评论

159

现在直接回答:检查字段值是否更改的一种方法是在保存实例之前从数据库获取原始数据。考虑以下示例:

class MyModel(models.Model):
    f1 = models.CharField(max_length=1)

    def save(self, *args, **kw):
        if self.pk is not None:
            orig = MyModel.objects.get(pk=self.pk)
            if orig.f1 != self.f1:
                print 'f1 changed'
        super(MyModel, self).save(*args, **kw)

当处理一个表单时,同样的事情也适用。你可以在ModelForm的clean或save方法中检测它:

class MyModelForm(forms.ModelForm):

    def clean(self):
        cleaned_data = super(ProjectForm, self).clean()
        #if self.has_changed():  # new instance or existing updated (form has data to save)
        if self.instance.pk is not None:  # new instance only
            if self.instance.f1 != cleaned_data['f1']:
                print 'f1 changed'
        return cleaned_data

    class Meta:
        model = MyModel
        exclude = []

24
Josh的解决方案更加适合数据库,额外的调用来验证更改内容是代价昂贵的。 - dd.
即使是第一次保存模型,考虑f1已更改会很好。 - Josh Bothun
8
写作前多读一遍并不会太花费时间。此外,如果有多个请求,追踪更改的方法将无法运作。尽管在提取和保存之间会出现竞争条件。 - dalore
3
别再告诉别人要检查pk is not None了,这并不适用于使用UUIDField的情况。这只是错误的建议。 - user3467349
4
你可以通过在保存方法上添加@transaction.atomic装饰器来避免竞态条件。 - Frank Pape
3
@dalore,虽然您需要确保事务隔离级别足够。在PostgreSQL中,默认为"读取已提交",但是需要可重复读 - Frank Pape

67
自Django 1.8发布以来,您可以使用from_db类方法来缓存remote_image的旧值。然后在save方法中,您可以比较字段的旧值和新值,以检查该值是否已更改。
@classmethod
def from_db(cls, db, field_names, values):
    new = super(Alias, cls).from_db(db, field_names, values)
    # cache value went from the base
    new._loaded_remote_image = values[field_names.index('remote_image')]
    return new

def save(self, force_insert=False, force_update=False, using=None,
         update_fields=None):
    if (self._state.adding and self.remote_image) or \
        (not self._state.adding and self._loaded_remote_image != self.remote_image):
        # If it is first save and there is no cached remote_image but there is new one, 
        # or the value of remote_image has changed - do your stuff!

3
谢谢 -- 这里有一个文档的参考链接:https://docs.djangoproject.com/en/1.8/ref/models/instances/#customizing-model-loading。我认为这仍然会导致之前提到的问题,即数据库在评估和比较之间可能会发生更改,但这是一个不错的新选项。 - trpt4him
1
与其搜索值(基于值的数量为O(n)),不如直接使用new._loaded_remote_image = new.remote_image,这样会更快且更清晰。 - dalore
1
不幸的是,我必须撤回之前(现已删除)的评论。虽然 from_dbrefresh_from_db 调用,但实例上的属性(即已加载或先前的属性)未被更新。因此,我找不到任何理由认为这比 __init__ 更好,因为您仍然需要处理 3 种情况:__init__/from_dbrefresh_from_dbsave - claytond

37

7
来自 django-model-utils 的 FieldTracker 似乎非常有效,谢谢! - Greg Sadetsky

24
如果您正在使用表单,可以使用Form的 changed_data (文档)。
class AliasForm(ModelForm):

    def save(self, commit=True):
        if 'remote_image' in self.changed_data:
            # do things
            remote_image = self.cleaned_data['remote_image']
            do_things(remote_image)
        super(AliasForm, self).save(commit)

    class Meta:
        model = Alias

11

3
看着这些票据,似乎这个软件包现在的状态不太好(正在寻找维护者,需要在12月31日之前更改其CI等)。 - Overdrivr

9
非常晚才加入游戏,但这是 Chris Pratt's answer 的一个版本,它通过使用 transaction 块和 select_for_update() 来防止竞争条件,牺牲了性能。请注意保留 HTML 标签。
@receiver(pre_save, sender=MyModel)
@transaction.atomic
def do_something_if_changed(sender, instance, **kwargs):
    try:
        obj = sender.objects.select_for_update().get(pk=instance.pk)
    except sender.DoesNotExist:
        pass # Object is new, so field hasn't technically changed, but you may want to do something else here.
    else:
        if not obj.some_field == instance.some_field: # Field has changed
            # do something

最后将成为第一!!问题..有人知道在信号中是否可能获取用户吗? - Lara
@Lara 可能不在信号中,但是在我的方面,我正在覆盖ModelFormMixin.form_valid()在我的基于类的视图中来更新当前实例的一些字段(即updated_by)。因此,我可以使用self.request.user获取/设置用户... - scūriolus

7
如果你只是想查看一个新文件是否已经上传到文件字段中,可以尝试以下代码(修改自Christopher Adams在这里的评论http://zmsmith.com/2010/05/django-check-if-a-field-has-changed/)。
更新链接:https://web.archive.org/web/20130101010327/http://zmsmith.com:80/2010/05/django-check-if-a-field-has-changed/
def save(self, *args, **kw):
    from django.core.files.uploadedfile import UploadedFile
    if hasattr(self.image, 'file') and isinstance(self.image.file, UploadedFile) :
        # Handle FileFields as special cases, because the uploaded filename could be
        # the same as the filename that's already there even though there may
        # be different file contents.

        # if a file was just uploaded, the storage model with be UploadedFile
        # Do new file stuff here
        pass

这是一个非常棒的解决方案,用于检查是否上传了新文件。比起根据数据库检查文件名,这种方法要好得多,因为文件名可能相同。你也可以在 pre_save 接收器中使用它。感谢您分享这个! - DataGreed
1
这是一个示例,使用mutagen读取音频信息并在数据库中更新文件时,更新音频持续时间的代码 - https://gist.github.com/DataGreed/1ba46ca7387950abba2ff53baf70fec2 - DataGreed

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