¿Valor único de BooleanField en Django?

88

Supongamos que mi models.py es así:

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

Quiero que solo una de mis Characterinstancias tenga is_the_chosen_one == Truey todas las demás is_the_chosen_one == False. ¿Cuál es la mejor manera de garantizar que se respete esta restricción de unicidad?

¡Las mejores calificaciones para las respuestas que tienen en cuenta la importancia de respetar la restricción en los niveles de base de datos, modelo y formulario (administrador)!

sampablokuper
fuente
4
Buena pregunta. También tengo curiosidad por saber si es posible establecer tal restricción. Sé que si simplemente lo convierte en una restricción única, terminará con solo dos filas posibles en su base de datos ;-)
Andre Miller
No necesariamente: si usa un NullBooleanField, entonces debería poder tener: (un True, un False, cualquier número de NULL).
Matthew Schinckel
Según mi investigación , @semente responde, tiene en cuenta la importancia de respetar la restricción en los niveles de la base de datos, el modelo y el formulario (administrador), mientras que proporciona una gran solución incluso para una throughtabla ManyToManyFieldque necesita una unique_togetherrestricción.
raratiru

Respuestas:

66

Siempre que he necesitado realizar esta tarea, lo que he hecho es anular el método de guardado para el modelo y hacer que verifique si algún otro modelo ya tiene el indicador configurado (y apagarlo).

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)
Adán
fuente
3
Simplemente cambiaría 'def save (self):' a: 'def save (self, * args, ** kwargs):'
Marek
8
Traté de editarla para el cambio save(self)a save(self, *args, **kwargs)pero la edición fue rechazado. ¿Podría alguno de los revisores tomarse el tiempo para explicar por qué, ya que esto parece ser coherente con las mejores prácticas de Django?
scytale
14
Intenté editar para eliminar la necesidad de probar / excepto y hacer que el proceso sea más eficiente, pero fue rechazado. En lugar de get()usar el objeto Carácter y luego volver save()a hacerlo, solo necesita filtrar y actualizar, lo que produce solo una consulta SQL y ayuda a mantener la base de datos consistente: 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
No puedo sugerir ningún método mejor para realizar esa tarea, pero quiero decir que nunca confíe en los métodos de guardado o limpieza si está ejecutando una aplicación web que puede llevar algunas solicitudes a un punto final en el mismo momento. Aún debe implementar una forma más segura, tal vez a nivel de base de datos.
u.unver34
1
Hay una mejor respuesta a continuación. La respuesta de Ellis Percival usa lo transaction.atomicque es importante aquí. También es más eficiente usar una sola consulta.
alexbhandari
34

Anularía el método de guardado del modelo y si ha establecido el booleano en Verdadero, asegúrese de que todos los demás estén configurados en Falso.

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)

Intenté editar la respuesta similar de Adam, pero fue rechazada por cambiar demasiado la respuesta original. De esta forma es más conciso y eficiente ya que la verificación de otras entradas se realiza en una sola consulta.

Ellis Percival
fuente
7
Creo que esta es la mejor respuesta, pero sugeriría que se envuelva saveen una @transaction.atomictransacción. Porque podría suceder que elimine todas las banderas, pero luego el guardado falle y termine con todos los caracteres no elegidos.
Mitar
Gracias por decir eso. Tiene toda la razón y actualizaré la respuesta.
Ellis Percival
@Mitar @transaction.atomictambién protege de la condición de carrera.
Pawel Furmaniak
1
¡La mejor solución entre todas!
Arturo
1
Con respecto a transaction.atomic, utilicé el administrador de contexto en lugar de un decorador. No veo ninguna razón para usar transacciones atómicas en todos los modelos excepto, ya que esto solo importa si el campo booleano es verdadero. Sugiero usar with transaction.atomic:dentro de la declaración if junto con guardar dentro de if. Luego agregue un bloque else y también guarde en el bloque else.
alexbhandari
29

En lugar de usar limpieza / guardado de modelos personalizados, creé un campo personalizado que anula elpre_save método en django.db.models.BooleanField. En lugar de generar un error si había otro campo True, hice todos los demás campos Falsesi lo estaba True. Además, en lugar de generar un error si el campo estaba Falsey ningún otro campo lo estaba True, lo guardé comoTrue

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

modelos.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
fuente
2
Esto parece mucho más limpio que los otros métodos
pistache
2
También me gusta esta solución, aunque parece potencialmente peligroso que objects.update establezca todos los demás objetos en False en el caso de que los modelos UniqueBoolean sean True. Sería incluso mejor si UniqueBooleanField tomara un argumento opcional para indicar si los otros objetos deben establecerse en False o si se debe generar un error (la otra alternativa sensata). Además, dado su comentario en el elif, donde desea establecer el atributo en verdadero, creo que debería cambiar Return Trueasetattr(model_instance, self.attname, True)
Andrew Chase
2
UniqueBooleanField no es realmente único, ya que puede tener tantos valores falsos como desee. No estoy seguro de cuál sería un nombre mejor ... ¿OneTrueBooleanField? Lo que realmente quiero es poder establecer el alcance de esto en combinación con una clave externa para poder tener un BooleanField que solo se permitió ser True una vez por relación (por ejemplo, una CreditCard tiene un campo "primario" y un FK para el usuario y la combinación Usuario / Principal es Verdadero una vez por uso). Para ese caso, creo que la respuesta primordial de Adam será más sencilla para mí.
Andrew Chase
1
Cabe señalar que este método le permite terminar en un estado sin filas establecidas como truesi eliminara la única truefila.
rblk
11

La siguiente solución es un poco fea pero podría funcionar:

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 establece is_the_chosen_one en False o None, siempre será NULL. Puede tener NULL tanto como desee, pero solo puede tener uno True.

semente
fuente
1
También pensé en la primera solución. NULL es siempre único, por lo que siempre puede tener una columna con más de un NULL.
kaleissin
10

Tratando de llegar a fin de mes con las respuestas aquí, encuentro que algunos de ellos abordan el mismo problema con éxito y cada uno es adecuado en diferentes situaciones:

Yo elegiría:

  • @semente : respeta la restricción en los niveles de la base de datos, el modelo y el formulario de administración, mientras que anula Django ORM lo menos posible. Además puedeprobablementeser utilizado dentro de una throughmesa de a ManyToManyFielden ununique_together situación.(Lo comprobaré e informaré)

    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 : llega a la base de datos solo una vez más y acepta la entrada actual como la elegida. Limpio y elegante.

    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)  
    

Otras soluciones no adecuadas para mi caso pero viables:

@nemocorp está anulando el cleanmétodo para realizar una validación. Sin embargo, no informa qué modelo es "el indicado" y esto no es fácil de usar. A pesar de eso, es un enfoque muy agradable, especialmente si alguien no tiene la intención de ser tan agresivo como @Flyte.

@ saul.shanabrook y @Thierry J. crearían un campo personalizado que cambiaría cualquier otra entrada "is_the_one" Falseo generaría un ValidationError. Simplemente soy reacio a implementar nuevas funciones en mi instalación de Django a menos que sea absolutamente necesario.

@daigorocub : usa señales de Django. Lo encuentro un enfoque único y da una pista de cómo usar Django Signals . Sin embargo, no estoy seguro de si esto es un uso -estricto- "adecuado" de las señales, ya que no puedo considerar este procedimiento como parte de una "aplicación desacoplada".

raratiru
fuente
¡Gracias por la reseña! Actualicé un poco mi respuesta, basándome en uno de los comentarios, en caso de que también desee actualizar su código aquí.
Ellis Percival
@EllisPercival ¡Gracias por la pista! Actualicé el código en consecuencia. Sin embargo, tenga en cuenta que models.Model.save () no devuelve algo.
raratiru
Esta bien. Es principalmente para ahorrar tener el primer retorno en su propia línea. Su versión es realmente incorrecta, ya que no incluye .save () en la transacción atómica. Además, debería ser 'with transaction.atomic ():' en su lugar.
Ellis Percival
1
@EllisPercival OK, ¡gracias! De hecho, necesitamos que todo se revierta en caso de save()que falle la operación.
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

También puede usar el formulario anterior para administrador, solo use

class CharacterAdmin(admin.ModelAdmin):
    form = CharacterForm
admin.site.register(Character, CharacterAdmin)
shadfc
fuente
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")

Al hacer esto, la validación estuvo disponible en el formulario de administración básico

nemocorp
fuente
4

Es más sencillo agregar este tipo de restricción a su modelo después de la versión 2.2 de Django. Puede usar directamente UniqueConstraint.condition. Documentos de Django

Simplemente anule sus modelos de class Metaesta manera:

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

Y eso es todo.

def save(self, *args, **kwargs):
    if self.default_dp:
        DownloadPageOrder.objects.all().update(**{'default_dp': False})
    super(DownloadPageOrder, self).save(*args, **kwargs)
sello postal
fuente
2

Usando un enfoque similar al de Saul, pero con un propósito ligeramente diferente:

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

Esta implementación generará un ValidationErroral intentar guardar otro registro con un valor de Verdadero.

Además, he agregado el unique_forargumento que se puede establecer en cualquier otro campo en el modelo, para verificar la unicidad verdadera solo para registros con el mismo valor, como:

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

¿Obtengo puntos por responder a mi pregunta?

El problema era que se encontraba en el bucle, solucionado por:

    # 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
fuente
No, no hay puntos por responder su propia pregunta y aceptar esa respuesta. Sin embargo, hay puntos que se deben hacer si alguien vota a favor de su respuesta. :)
dandan78
¿Estás seguro de que no pretendías responder aquí a tu propia pregunta ? Básicamente, @sampablokuper y tú tenéis la misma pregunta
j_syk
1

Probé algunas de estas soluciones y terminé con otra, solo en aras de la brevedad del código (no tengo que anular formularios o guardar el método). Para que esto funcione, el campo no puede ser único en su definición, pero la señal se asegura de que eso suceda.

# 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
fuente
0

Actualización 2020 para hacer las cosas menos complicadas para los principiantes:

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

Por supuesto, si desea que el booleano único sea False, simplemente intercambiaría todas las instancias de True con False y viceversa.

Arrendajo
fuente