web-dev-qa-db-fra.com

Django: Lors de la sauvegarde, comment vérifier si un champ a été modifié?

Dans mon modèle j'ai:

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

Ce qui fonctionne bien pour la première fois, le remote_image change. 

Comment puis-je récupérer une nouvelle image quand quelqu'un a modifié le remote_image sur l'alias? Et deuxièmement, existe-t-il un meilleur moyen de mettre en cache une image distante?

237
Paul Tarjan

Bien que ce soit un peu tard, laissez-moi jeter cette solution pour d'autres qui rencontrent ce post. Essentiellement, vous souhaitez remplacer la méthode __init__ de models.Model afin de conserver une copie de la valeur d'origine. Ainsi, vous n’aurez pas à faire une autre recherche dans la base de données (ce qui est toujours une bonne chose).

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

  __original_name = None

  def __init__(self, *args, **kwargs):
    super(Person, self).__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(Person, self).save(force_insert, force_update, *args, **kwargs)
    self.__original_name = self.name
370
Josh

J'utilise le mixin suivant:

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])

Usage:

>>> 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)
>>>

Remarque

Veuillez noter que cette solution fonctionne bien dans le contexte de la demande actuelle uniquement. Ainsi, il convient principalement aux cas simples. Dans un environnement concurrent où plusieurs demandes peuvent manipuler la même instance de modèle en même temps, vous avez certainement besoin d'une approche différente.

163
iperelivskiy

Et maintenant, pour la réponse directe: un moyen de vérifier si la valeur du champ a changé consiste à extraire les données d'origine de la base de données avant d'enregistrer l'instance. Considérons cet exemple:

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)

La même chose s'applique lorsque vous travaillez avec un formulaire. Vous pouvez le détecter à la méthode clean ou save d'un ModelForm:

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 = []
130
zgoda

Le meilleur moyen est d'utiliser un signal pre_save. Cela n’était peut-être pas une option en 2009 lorsque cette question a été posée et posée, mais tous ceux qui le voient aujourd'hui devraient le faire de la manière suivante:

@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
129
Chris Pratt

Depuis que Django 1.8 est disponible, vous pouvez utiliser from_db classmethod pour mettre en cache l'ancienne valeur de remote_image. Ensuite, dans save , vous pouvez comparer l’ancienne et la nouvelle valeur du champ pour vérifier si la valeur a changé.

@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!
49
Serge

Notez que le suivi des modifications de champ est disponible dans Django-model-utils.

https://Django-model-utils.readthedocs.org/en/latest/index.html

15
Lee Hinde

Si vous utilisez un formulaire, vous pouvez utiliser le fichier modified_data ( docs ) du formulaire:

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
13
laffuste

Je suis un peu en retard pour la fête mais j'ai trouvé cette solution aussi: Django Dirty Fields

5
Fred Campos

Depuis Django 1.8, il existe la méthode from_db, comme le mentionne Serge. En fait, les documents Django incluent ce cas d'utilisation spécifique à titre d'exemple:

https://docs.djangoproject.com/en/dev/ref/models/instances/#customizing-model-loading

Ci-dessous, un exemple montrant comment enregistrer les valeurs initiales des champs chargés à partir de la base de données.

5
Amichai Schreiber

Vous pouvez utiliser Django-model-changes pour le faire sans recherche supplémentaire dans la base de données:

from Django.dispatch import receiver
from Django_model_changes import ChangesMixin

class Alias(ChangesMixin, MyBaseModel):
   # your model

@receiver(pre_save, sender=Alias)
def do_something_if_changed(sender, instance, **kwargs):
    if 'remote_image' in instance.changes():
        # do something
3
Robert Kajic

La solution optimale est probablement celle qui n'inclut pas d'opération de lecture de base de données supplémentaire avant l'enregistrement de l'instance de modèle, ni aucune autre bibliothèque Django. C'est pourquoi les solutions de laffuste sont préférables. Dans le contexte d'un site d'administration, on peut simplement écraser la méthode save_model et y invoquer la méthode has_changed du formulaire, tout comme dans la réponse de Sion ci-dessus. Vous arrivez à quelque chose comme ceci, en vous basant sur l'exemple de Sion mais en utilisant "modified_data" pour obtenir tous les changements possibles:

class ModelAdmin(admin.ModelAdmin):
   fields=['name','mode']
   def save_model(self, request, obj, form, change):
     form.changed_data #output could be ['name']
     #do somethin the changed name value...
     #call the super method
     super(self,ModelAdmin).save_model(request, obj, form, change)
  • Ecraser save_model:

https://docs.djangoproject.com/fr/1.10/ref/contrib/admin/#Django.contrib.admin.ModelAdmin.save_model

  • Modified-data-method intégré pour un champ:

https://docs.djangoproject.com/fr/1.10/ref/forms/api/#Django.forms.Form.changed_data

3
user3061675

Cela fonctionne pour moi dans Django 1.8

def clean(self):
    if self.cleaned_data['name'] != self.initial['name']:
        # Do something
2
jhrs21

J'avais cette situation avant que ma solution ne remplace la méthode pre_save() de la classe de champ cible; elle ne sera appelée que si le champ a été modifié. 
utile avec FileField exemple: 

class PDFField(FileField):
    def pre_save(self, model_instance, add):
        # do some operations on your file 
        # if and only if you have changed the filefield

désavantage:
inutile si vous voulez effectuer une opération (post_save) comme utiliser l'objet créé dans un travail (si certains champs ont été modifiés)

2
MYaser

Une autre réponse tardive, mais si vous essayez simplement de voir si un nouveau fichier a été chargé dans un champ de fichier, essayez ceci: (adapté du commentaire de Christopher Adams sur le lien http://zmsmith.com/2010/05/Django-vérifier-si-un-champ-a-changé/ dans le commentaire de zach ici)

Lien mis à jour: 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
2
Aaron McMillin

Bien que cela ne réponde pas réellement à votre question, je procéderais différemment.

Effacez simplement le champ remote_image après avoir enregistré avec succès la copie locale. Ensuite, dans votre méthode de sauvegarde, vous pouvez toujours mettre à jour l'image lorsque remote_image n'est pas vide.

Si vous souhaitez conserver une référence à l'URL, vous pouvez utiliser un champ booléen non modifiable pour gérer l'indicateur de mise en cache plutôt que le champ remote_image lui-même.

2
SmileyChris

améliorer @josh answer pour tous les champs:

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

def __init__(self, *args, **kwargs):
    super(Person, self).__init__(*args, **kwargs)
    self._original_fields = dict([(field.attname, getattr(self, field.attname))
        for field in self._meta.local_fields if not isinstance(field, models.ForeignKey)])

def save(self, *args, **kwargs):
  if self.id:
    for field in self._meta.local_fields:
      if not isinstance(field, models.ForeignKey) and\
        self._original_fields[field.name] != getattr(self, field.name):
        # Do Something    
  super(Person, self).save(*args, **kwargs)

pour clarifier, le getattr fonctionne pour obtenir des champs tels que person.name avec des chaînes (c'est-à-dire getattr(person, "name")

2
Hassek

J'ai étendu le mixin de @livskiy comme suit:

class ModelDiffMixin(models.Model):
    """
    A model mixin that tracks model fields' values and provide some useful api
    to know what fields have been changed.
    """
    _dict = DictField(editable=False)
    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.
        """
        object_dict = model_to_dict(self,
               fields=[field.name for field in self._meta.fields])
        for field in object_dict:
            # for FileFields
            if issubclass(object_dict[field].__class__, FieldFile):
                try:
                    object_dict[field] = object_dict[field].path
                except :
                    object_dict[field] = object_dict[field].name

            # TODO: add other non-serializable field types
        self._dict = object_dict
        super(ModelDiffMixin, self).save(*args, **kwargs)

    class Meta:
        abstract = True

et DictField est:

class DictField(models.TextField):
    __metaclass__ = models.SubfieldBase
    description = "Stores a python dict"

    def __init__(self, *args, **kwargs):
        super(DictField, self).__init__(*args, **kwargs)

    def to_python(self, value):
        if not value:
            value = {}

        if isinstance(value, dict):
            return value

        return json.loads(value)

    def get_prep_value(self, value):
        if value is None:
            return value
        return json.dumps(value)

    def value_to_string(self, obj):
        value = self._get_val_from_obj(obj)
        return self.get_db_prep_value(value)

il peut être utilisé en l'étendant dans vos modèles un champ _dict sera ajouté lors de la synchronisation/migration et ce champ stockera l'état de vos objets

1
MYaser

Une modification de la réponse de @ ivanperelivskiy:

@property
def _dict(self):
    ret = {}
    for field in self._meta.get_fields():
        if isinstance(field, ForeignObjectRel):
            # foreign objects might not have corresponding objects in the database.
            if hasattr(self, field.get_accessor_name()):
                ret[field.get_accessor_name()] = getattr(self, field.get_accessor_name())
            else:
                ret[field.get_accessor_name()] = None
        else:
            ret[field.attname] = getattr(self, field.attname)
    return ret

Ceci utilise la méthode publique de Django 1.10 get_fields à la place. Cela rend le code plus évolutif, mais comprend surtout les clés étrangères et les champs modifiables = False.

Pour référence, voici l'implémentation de .fields

@cached_property
def fields(self):
    """
    Returns a list of all forward fields on the model and its parents,
    excluding ManyToManyFields.

    Private API intended only to be used by Django itself; get_fields()
    combined with filtering of field properties is the public API for
    obtaining this field list.
    """
    # For legacy reasons, the fields property should only contain forward
    # fields that are not private or with a m2m cardinality. Therefore we
    # pass these three filters as filters to the generator.
    # The third lambda is a longwinded way of checking f.related_model - we don't
    # use that property directly because related_model is a cached property,
    # and all the models may not have been loaded yet; we don't want to cache
    # the string reference to the related_model.
    def is_not_an_m2m_field(f):
        return not (f.is_relation and f.many_to_many)

    def is_not_a_generic_relation(f):
        return not (f.is_relation and f.one_to_many)

    def is_not_a_generic_foreign_key(f):
        return not (
            f.is_relation and f.many_to_one and not (hasattr(f.remote_field, 'model') and f.remote_field.model)
        )

    return make_immutable_fields_list(
        "fields",
        (f for f in self._get_fields(reverse=False)
         if is_not_an_m2m_field(f) and is_not_a_generic_relation(f) and is_not_a_generic_foreign_key(f))
    )
0
theicfire

Voici une autre façon de le faire.

class Parameter(models.Model):

    def __init__(self, *args, **kwargs):
        super(Parameter, self).__init__(*args, **kwargs)
        self.__original_value = self.value

    def clean(self,*args,**kwargs):
        if self.__original_value == self.value:
            print("igual")
        else:
            print("distinto")

    def save(self,*args,**kwargs):
        self.full_clean()
        return super(Parameter, self).save(*args, **kwargs)
        self.__original_value = self.value

    key = models.CharField(max_length=24, db_index=True, unique=True)
    value = models.CharField(max_length=128)

Selon documentation: objets de validation

"La deuxième étape que full_clean () effectue consiste à appeler Model.clean (). Cette méthode doit être remplacée pour permettre la validation personnalisée de votre modèle . Cette méthode doit être utilisée pour fournir une validation de modèle personnalisée et pour modifier les attributs de votre Si vous le souhaitez, vous pouvez par exemple l'utiliser pour fournir automatiquement une valeur à un champ ou effectuer une validation nécessitant un accès à plusieurs champs: "

0
Gonzalo

en tant qu'extension de la réponse de SmileyChris, vous pouvez ajouter un champ date/heure au modèle pour last_updated, et définir une sorte de limite pour l'âge maximal auquel vous le laisserez arriver avant de rechercher une modification

0
Jiaaro

Si vous ne trouvez pas l’intérêt de surcharger la méthode save, vous pouvez le faire.

  model_fields = [f.name for f in YourModel._meta.get_fields()]
  valid_data = {
        key: new_data[key]
        for key in model_fields
        if key in new_data.keys()
  }

  for (key, value) in valid_data.items():
        if getattr(instance, key) != value:
           print ('Data has changed')

        setattr(instance, key, value)

 instance.save()
0
theTypan

Le mixin de @ivanlivski est excellent. 

Je l'ai étendu à

  • Assurez-vous que cela fonctionne avec les champs décimaux.
  • Exposer les propriétés pour simplifier l'utilisation

Le code mis à jour est disponible ici: https://github.com/sknutsonsf/python-contrib/blob/master/src/Django/utils/ModelDiffMixin.py

Pour aider les personnes novices en Python ou Django, je vais donner un exemple plus complet: . Cette utilisation particulière consiste à extraire un fichier d'un fournisseur de données et à s'assurer que les enregistrements de la base de données reflètent le fichier. 

Mon objet modèle:

class Station(ModelDiffMixin.ModelDiffMixin, models.Model):
    station_name = models.CharField(max_length=200)
    nearby_city = models.CharField(max_length=200)

    precipitation = models.DecimalField(max_digits=5, decimal_places=2)
    # <list of many other fields>

   def is_float_changed (self,v1, v2):
        ''' Compare two floating values to just two digit precision
        Override Default precision is 5 digits
        '''
        return abs (round (v1 - v2, 2)) > 0.01

La classe qui charge le fichier a ces méthodes:

class UpdateWeather (object)
    # other methods omitted

    def update_stations (self, filename):
        # read all existing data 
        all_stations = models.Station.objects.all()
        self._existing_stations = {}

        # insert into a collection for referencing while we check if data exists
        for stn in all_stations.iterator():
            self._existing_stations[stn.id] = stn

        # read the file. result is array of objects in known column order
        data = read_tabbed_file(filename)

        # iterate rows from file and insert or update where needed
        for rownum in range(sh.nrows):
            self._update_row(sh.row(rownum));

        # now anything remaining in the collection is no longer active
        # since it was not found in the newest file
        # for now, delete that record
        # there should never be any of these if the file was created properly
        for stn in self._existing_stations.values():
            stn.delete()
            self._num_deleted = self._num_deleted+1


    def _update_row (self, rowdata):
        stnid = int(rowdata[0].value) 
        name = rowdata[1].value.strip()

        # skip the blank names where data source has ids with no data today
        if len(name) < 1:
            return

        # fetch rest of fields and do sanity test
        nearby_city = rowdata[2].value.strip()
        precip = rowdata[3].value

        if stnid in self._existing_stations:
            stn = self._existing_stations[stnid]
            del self._existing_stations[stnid]
            is_update = True;
        else:
            stn = models.Station()
            is_update = False;

        # object is new or old, don't care here            
        stn.id = stnid
        stn.station_name = name;
        stn.nearby_city = nearby_city
        stn.precipitation = precip

        # many other fields updated from the file 

        if is_update == True:

            # we use a model mixin to simplify detection of changes
            # at the cost of extra memory to store the objects            
            if stn.has_changed == True:
                self._num_updated = self._num_updated + 1;
                stn.save();
        else:
            self._num_created = self._num_created + 1;
            stn.save()
0
sknutsonsf

Pourquoi ne pas utiliser la solution de David Cramer:

http://cramer.io/2010/12/06/tracking-changes-to-fields-in-Django/

J'ai eu du succès en l'utilisant comme ceci:

@track_data('name')
class Mode(models.Model):
    name = models.CharField(max_length=5)
    mode = models.CharField(max_length=5)

    def save(self, *args, **kwargs):
        if self.has_changed('name'):
            print 'name changed'

    # OR #

    @classmethod
    def post_save(cls, sender, instance, created, **kwargs):
        if instance.has_changed('name'):
            print "Hooray!"
0
Sion