Valeur BooleanField unique dans Django?

89

Supposons que mon models.py soit comme ceci:

class Character(models.Model):
    name = models.CharField(max_length=255)
    is_the_chosen_one = models.BooleanField()

Je veux qu'une seule de mes Characterinstances ait is_the_chosen_one == Trueet que toutes les autres aient is_the_chosen_one == False. Comment puis-je m'assurer au mieux que cette contrainte d'unicité est respectée?

Meilleures notes pour les réponses qui prennent en compte l'importance de respecter la contrainte au niveau de la base de données, du modèle et du formulaire (admin)!

sampablokuper
la source
4
Bonne question. Je suis également curieux de savoir s'il est possible de mettre en place une telle contrainte. Je sais que si vous en faites simplement une contrainte unique, vous vous retrouverez avec seulement deux lignes possibles dans votre base de données ;-)
Andre Miller
Pas nécessairement: si vous utilisez un NullBooleanField, alors vous devriez pouvoir avoir: (un True, un False, n'importe quel nombre de NULL).
Matthew Schinckel
Selon mes recherches , @semente answer, prend en compte l'importance de respecter la contrainte au niveau de la base de données, du modèle et du formulaire (admin) tout en fournissant une excellente solution même pour une throughtable ManyToManyFieldqui nécessite une unique_togethercontrainte.
raratiru

Réponses:

66

Chaque fois que j'ai eu besoin d'accomplir cette tâche, ce que j'ai fait est de remplacer la méthode de sauvegarde du modèle et de la faire vérifier si un autre modèle a déjà défini l'indicateur (et de le désactiver).

class Character(models.Model):
    name = models.CharField(max_length=255)
    is_the_chosen_one = models.BooleanField()

    def save(self, *args, **kwargs):
        if self.is_the_chosen_one:
            try:
                temp = Character.objects.get(is_the_chosen_one=True)
                if self != temp:
                    temp.is_the_chosen_one = False
                    temp.save()
            except Character.DoesNotExist:
                pass
        super(Character, self).save(*args, **kwargs)
Adam
la source
3
Je changerais simplement 'def save (self):' en: 'def save (self, * args, ** kwargs):'
Marek
8
J'ai essayé de modifier ceci pour changer save(self)en save(self, *args, **kwargs)mais la modification a été rejetée. L'un des examinateurs pourrait-il prendre le temps d'expliquer pourquoi - car cela semble être conforme aux meilleures pratiques de Django.
scytale
14
J'ai essayé de modifier pour supprimer le besoin d'essayer / sauf et pour rendre le processus plus efficace, mais il a été rejeté. Au lieu d' get()ingérer l'objet Character puis de le save()réintégrer, il vous suffit de filtrer et de mettre à jour, ce qui ne produit qu'une seule requête SQL et aide à garder la base de données cohérente: if self.is_the_chosen_one:<newline> Character.objects.filter(is_the_chosen_one=True).update(is_the_chosen_one=False)<newline>super(Character, self).save(*args, **kwargs)
Ellis Percival
2
Je ne peux pas suggérer de meilleure méthode pour accomplir cette tâche, mais je tiens à dire que, ne faites jamais confiance aux méthodes de sauvegarde ou de nettoyage si vous exécutez une application Web, ce que vous pourriez envoyer à un point final au même moment. Vous devez toujours implémenter une manière plus sûre peut-être au niveau de la base de données.
u.unver34
1
Il y a une meilleure réponse ci-dessous. La réponse d'Ellis Percival utilise transaction.atomicce qui est important ici. Il est également plus efficace d'utiliser une seule requête.
alexbhandari
35

Je remplacerais la méthode de sauvegarde du modèle et si vous avez défini le booléen sur True, assurez-vous que tous les autres sont définis sur False.

from django.db import transaction

class Character(models.Model):
    name = models.CharField(max_length=255)
    is_the_chosen_one = models.BooleanField()

    def save(self, *args, **kwargs):
        if not self.is_the_chosen_one:
            return super(Character, self).save(*args, **kwargs)
        with transaction.atomic():
            Character.objects.filter(
                is_the_chosen_one=True).update(is_the_chosen_one=False)
            return super(Character, self).save(*args, **kwargs)

J'ai essayé de modifier la réponse similaire d'Adam, mais elle a été rejetée pour avoir trop changé la réponse originale. Cette méthode est plus succincte et efficace car la vérification des autres entrées se fait en une seule requête.

Ellis Percival
la source
8
Je pense que c'est la meilleure réponse, mais je suggérerais de conclure saveune @transaction.atomictransaction. Parce qu'il peut arriver que vous supprimiez tous les indicateurs, mais que l'enregistrement échoue et que vous vous retrouviez avec tous les caractères non choisis.
Mitar
Merci de l'avoir dit. Vous avez tout à fait raison et je vais mettre à jour la réponse.
Ellis Percival
@Mitar @transaction.atomicprotège également des conditions de course.
Pawel Furmaniak
1
La meilleure solution parmi toutes!
Arturo
1
En ce qui concerne transaction.atomic, j'ai utilisé le gestionnaire de contexte au lieu d'un décorateur. Je ne vois aucune raison d'utiliser la transaction atomique sur chaque modèle sauf car cela n'a d'importance que si le champ booléen est vrai. Je suggère d'utiliser with transaction.atomic:à l'intérieur de l'instruction if avec l'enregistrement à l'intérieur du if. Ensuite, ajoutez un bloc else et enregistrez également dans le bloc else.
alexbhandari
29

Au lieu d'utiliser le nettoyage / l'enregistrement du modèle personnalisé, j'ai créé un champ personnalisé remplaçant la pre_saveméthode activée django.db.models.BooleanField. Au lieu de déclencher une erreur si un autre champ l'était True, j'ai créé tous les autres champs Falsesi c'était le cas True. Aussi au lieu de générer une erreur si le champ était Falseet aucun autre champ ne l'était True, je l'ai enregistré le champTrue

fields.py

from django.db.models import BooleanField


class UniqueBooleanField(BooleanField):
    def pre_save(self, model_instance, add):
        objects = model_instance.__class__.objects
        # If True then set all others as False
        if getattr(model_instance, self.attname):
            objects.update(**{self.attname: False})
        # If no true object exists that isnt saved model, save as True
        elif not objects.exclude(id=model_instance.id)\
                        .filter(**{self.attname: True}):
            return True
        return getattr(model_instance, self.attname)

# To use with South
from south.modelsinspector import add_introspection_rules
add_introspection_rules([], ["^project\.apps\.fields\.UniqueBooleanField"])

models.py

from django.db import models

from project.apps.fields import UniqueBooleanField


class UniqueBooleanModel(models.Model):
    unique_boolean = UniqueBooleanField()

    def __unicode__(self):
        return str(self.unique_boolean)
saul.shanabrook
la source
2
Cela semble beaucoup plus propre que les autres méthodes
pistache
2
J'aime aussi cette solution, bien qu'il semble potentiellement dangereux d'avoir les objects.update définir tous les autres objets sur False dans le cas où les modèles UniqueBoolean sont True. Ce serait encore mieux si UniqueBooleanField prenait un argument facultatif pour indiquer si les autres objets doivent être définis sur False ou si une erreur doit être levée (l'autre alternative judicieuse). De plus, compte tenu de votre commentaire dans elif, où vous souhaitez définir l'attribut sur true, je pense que vous devriez passer Return Trueàsetattr(model_instance, self.attname, True)
Andrew Chase
2
UniqueBooleanField n'est pas vraiment unique car vous pouvez avoir autant de valeurs False que vous le souhaitez. Vous ne savez pas quel meilleur nom serait ... OneTrueBooleanField? Ce que je veux vraiment, c'est pouvoir étendre cela en combinaison avec une clé étrangère afin que je puisse avoir un BooleanField qui n'était autorisé à être True qu'une seule fois par relation (par exemple, une carte de crédit a un champ "primaire" et un FK à l'utilisateur et la combinaison Utilisateur / Principal est Vrai une fois par utilisation). Dans ce cas, je pense que la réponse prioritaire d'Adam sera plus simple pour moi.
Andrew Chase
1
Il convient de noter que cette méthode vous permet de vous retrouver dans un état sans ligne définie comme truesi vous supprimiez la seule trueligne.
rblk
11

La solution suivante est un peu moche mais pourrait fonctionner:

class MyModel(models.Model):
    is_the_chosen_one = models.NullBooleanField(default=None, unique=True)

    def save(self, *args, **kwargs):
        if self.is_the_chosen_one is False:
            self.is_the_chosen_one = None
        super(MyModel, self).save(*args, **kwargs)

Si vous définissez is_the_chosen_one sur False ou None, il sera toujours NULL. Vous pouvez avoir NULL autant que vous le souhaitez, mais vous ne pouvez avoir qu'un seul True.

semente
la source
1
La première solution à laquelle j'ai pensé aussi. NULL est toujours unique, vous pouvez donc toujours avoir une colonne avec plus d'un NULL.
kaleissin
10

En essayant de joindre les deux bouts avec les réponses ici, je trouve que certains d'entre eux résolvent le même problème avec succès et chacun convient à différentes situations:

Je choisirais:

  • @semente : respecte la contrainte au niveau de la base de données, du modèle et du formulaire d'administration tout en écrasant le moins possible Django ORM. De plus, il peutProbablementêtre utilisé à l'intérieur d'une throughtable d'un ManyToManyFielddans ununique_together situation.(Je vais le vérifier et faire un rapport)

    class MyModel(models.Model):
        is_the_chosen_one = models.NullBooleanField(default=None, unique=True)
    
        def save(self, *args, **kwargs):
            if self.is_the_chosen_one is False:
                self.is_the_chosen_one = None
            super(MyModel, self).save(*args, **kwargs)
    
  • @Ellis Percival : n'atteint la base de données qu'une seule fois et accepte l'entrée actuelle comme celle choisie. Propre et élégant.

    from django.db import transaction
    
    class Character(models.Model):
        name = models.CharField(max_length=255)
        is_the_chosen_one = models.BooleanField()
    
    def save(self, *args, **kwargs):
        if not self.is_the_chosen_one:
            # The use of return is explained in the comments
            return super(Character, self).save(*args, **kwargs)  
        with transaction.atomic():
            Character.objects.filter(
                is_the_chosen_one=True).update(is_the_chosen_one=False)
            # The use of return is explained in the comments
            return super(Character, self).save(*args, **kwargs)  
    

Autres solutions non adaptées à mon cas mais viables:

@nemocorp remplace la cleanméthode pour effectuer une validation. Cependant, il n'indique pas quel modèle est «celui» et ce n'est pas convivial. Malgré cela, c'est une très belle approche surtout si quelqu'un n'a pas l'intention d'être aussi agressif que @Flyte.

@ saul.shanabrook et @Thierry J. créeraient un champ personnalisé qui changerait toute autre entrée "is_the_one" en Falseou augmenterait un ValidationError. Je suis juste réticent à intégrer de nouvelles fonctionnalités à mon installation Django à moins que cela ne soit absolument nécessaire.

@daigorocub : utilise les signaux Django. Je trouve que c'est une approche unique et donne un indice sur la façon d'utiliser Django Signals . Cependant, je ne suis pas sûr qu'il s'agisse d'une utilisation - strictement parlant - des signaux, car je ne peux pas considérer cette procédure comme faisant partie d'une «application découplée».

raratiru
la source
Merci pour l'examen! J'ai mis à jour un peu ma réponse, sur la base de l'un des commentaires, au cas où vous souhaiteriez également mettre à jour votre code ici.
Ellis Percival
@EllisPercival Merci pour l'indice! J'ai mis à jour le code en conséquence. Gardez à l'esprit que models.Model.save () ne renvoie rien.
raratiru
C'est très bien. C'est surtout pour éviter d'avoir le premier retour sur sa propre ligne. Votre version est en fait incorrecte, car elle n'inclut pas le .save () dans la transaction atomique. De plus, il devrait être 'with transaction.atomic ():' à la place.
Ellis Percival
1
@EllisPercival OK, merci! En effet, nous avons besoin que tout soit annulé, en cas d' save()échec de l' opération!
raratiru
6
class Character(models.Model):
    name = models.CharField(max_length=255)
    is_the_chosen_one = models.BooleanField()

    def save(self, *args, **kwargs):
        if self.is_the_chosen_one:
            qs = Character.objects.filter(is_the_chosen_one=True)
            if self.pk:
                qs = qs.exclude(pk=self.pk)
            if qs.count() != 0:
                # choose ONE of the next two lines
                self.is_the_chosen_one = False # keep the existing "chosen one"
                #qs.update(is_the_chosen_one=False) # make this obj "the chosen one"
        super(Character, self).save(*args, **kwargs)

class CharacterForm(forms.ModelForm):
    class Meta:
        model = Character

    # if you want to use the new obj as the chosen one and remove others, then
    # be sure to use the second line in the model save() above and DO NOT USE
    # the following clean method
    def clean_is_the_chosen_one(self):
        chosen = self.cleaned_data.get('is_the_chosen_one')
        if chosen:
            qs = Character.objects.filter(is_the_chosen_one=True)
            if self.instance.pk:
                qs = qs.exclude(pk=self.instance.pk)
            if qs.count() != 0:
                raise forms.ValidationError("A Chosen One already exists! You will pay for your insolence!")
        return chosen

Vous pouvez également utiliser le formulaire ci-dessus pour l'administrateur, utilisez simplement

class CharacterAdmin(admin.ModelAdmin):
    form = CharacterForm
admin.site.register(Character, CharacterAdmin)
shadfc
la source
4
class Character(models.Model):
    name = models.CharField(max_length=255)
    is_the_chosen_one = models.BooleanField()

    def clean(self):
        from django.core.exceptions import ValidationError
        c = Character.objects.filter(is_the_chosen_one__exact=True)  
        if c and self.is_the_chosen:
            raise ValidationError("The chosen one is already here! Too late")

Cela a rendu la validation disponible dans le formulaire d'administration de base

nemocorp
la source
4

Il est plus simple d'ajouter ce type de contrainte à votre modèle après Django version 2.2. Vous pouvez utiliser directement UniqueConstraint.condition. Documents Django

Remplacez simplement vos modèles class Metacomme ceci:

class Meta:
    constraints = [
        UniqueConstraint(fields=['is_the_chosen_one'], condition=Q(is_the_chosen_one=True), name='unique_is_the_chosen_one')
    ]
mangofet
la source
2

Et c'est tout.

def save(self, *args, **kwargs):
    if self.default_dp:
        DownloadPageOrder.objects.all().update(**{'default_dp': False})
    super(DownloadPageOrder, self).save(*args, **kwargs)
palestamp
la source
2

En utilisant une approche similaire à Saul, mais un objectif légèrement différent:

class TrueUniqueBooleanField(BooleanField):

    def __init__(self, unique_for=None, *args, **kwargs):
        self.unique_for = unique_for
        super(BooleanField, self).__init__(*args, **kwargs)

    def pre_save(self, model_instance, add):
        value = super(TrueUniqueBooleanField, self).pre_save(model_instance, add)

        objects = model_instance.__class__.objects

        if self.unique_for:
            objects = objects.filter(**{self.unique_for: getattr(model_instance, self.unique_for)})

        if value and objects.exclude(id=model_instance.id).filter(**{self.attname: True}):
            msg = 'Only one instance of {} can have its field {} set to True'.format(model_instance.__class__, self.attname)
            if self.unique_for:
                msg += ' for each different {}'.format(self.unique_for)
            raise ValidationError(msg)

        return value

Cette implémentation lèvera un ValidationErrorlors de la tentative de sauvegarde d'un autre enregistrement avec une valeur True.

En outre, j'ai ajouté l' unique_forargument qui peut être défini sur n'importe quel autre champ du modèle, pour vérifier l'unicité vraie uniquement pour les enregistrements avec la même valeur, tels que:

class Phone(models.Model):
    user = models.ForeignKey(User)
    main = TrueUniqueBooleanField(unique_for='user', default=False)
Thierry J.
la source
1

Est-ce que j'obtiens des points pour répondre à ma question?

Le problème était qu'il se retrouvait dans la boucle, corrigé par:

    # is this the testimonial image, if so, unselect other images
    if self.testimonial_image is True:
        others = Photograph.objects.filter(project=self.project).filter(testimonial_image=True)
        pdb.set_trace()
        for o in others:
            if o != self: ### important line
                o.testimonial_image = False
                o.save()
bytejunkie
la source
Non, pas de points pour répondre à votre propre question et accepter cette réponse. Cependant, il y a des points à souligner si quelqu'un vote pour votre réponse. :)
dandan78
Etes-vous sûr de ne pas vouloir répondre à votre propre question ici ? Fondamentalement, vous et @sampablokuper avez eu la même question
j_syk
1

J'ai essayé certaines de ces solutions et j'ai fini avec une autre, juste pour des raisons de brièveté du code (pas besoin de remplacer les formulaires ou d'enregistrer la méthode). Pour que cela fonctionne, le champ ne peut pas être unique dans sa définition, mais le signal garantit que cela se produit.

# making default_number True unique
@receiver(post_save, sender=Character)
def unique_is_the_chosen_one(sender, instance, **kwargs):
    if instance.is_the_chosen_one:
        Character.objects.all().exclude(pk=instance.pk).update(is_the_chosen_one=False)
daigorocub
la source
0

Mise à jour 2020 pour rendre les choses moins compliquées pour les débutants:

class Character(models.Model):
    name = models.CharField(max_length=255)
    is_the_chosen_one = models.BooleanField(blank=False, null=False, default=False)

    def save(self):
         if self.is_the_chosen_one == True:
              items = Character.objects.filter(is_the_chosen_one = True)
              for x in items:
                   x.is_the_chosen_one = False
                   x.save()
         super().save()

Bien sûr, si vous voulez que le booléen unique soit False, vous échangeriez simplement chaque instance de True avec False et vice versa.

Geai
la source