Valor BooleanField único no Django?

87

Suponha que meu models.py seja assim:

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

Eu quero que apenas uma de minhas Characterinstâncias tenha is_the_chosen_one == Truee todas as outras tenham is_the_chosen_one == False. Qual a melhor forma de garantir que essa restrição de exclusividade seja respeitada?

Melhores notas para as respostas que levam em consideração a importância de respeitar as restrições nos níveis de banco de dados, modelo e formulário (admin)!

sampablokuper
fonte
4
Boa pergunta. Também estou curioso para saber se é possível definir essa restrição. Eu sei que se você simplesmente criar uma restrição única, acabará com apenas duas linhas possíveis em seu banco de dados ;-)
Andre Miller
Não necessariamente: se você usar um NullBooleanField, então você deve ser capaz de ter: (um True, um False, qualquer número de NULLs).
Matthew Schinckel
De acordo com minha pesquisa , a resposta @semente leva em consideração a importância de respeitar a restrição nos níveis de banco de dados, modelo e formulário (admin) ao mesmo tempo em que fornece uma ótima solução até mesmo para uma throughtabela ManyToManyFieldque precisa de uma unique_togetherrestrição.
raratiru

Respostas:

66

Sempre que preciso realizar essa tarefa, o que faço é substituir o método de salvamento do modelo e fazer com que ele verifique se algum outro modelo já tem o sinalizador definido (e desative-o).

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ão
fonte
3
Eu mudaria 'def save (self):' para: 'def save (self, * args, ** kwargs):'
Marek
8
Tentei editar para mudar save(self)para, save(self, *args, **kwargs)mas a edição foi rejeitada. Algum dos revisores poderia levar algum tempo para explicar o porquê - já que isso parece ser consistente com as melhores práticas do Django.
scytale
14
Tentei editar para remover a necessidade de try / except e para tornar o processo mais eficiente, mas foi rejeitado. Em vez de get()inserir o objeto Character e depois save()inseri-lo novamente, você só precisa filtrar e atualizar, o que produz apenas uma consulta SQL e ajuda a manter o banco de dados 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
Não posso sugerir nenhum método melhor para realizar essa tarefa, mas quero dizer que, nunca confie nos métodos save ou clean se você estiver executando um aplicativo da web que pode levar algumas solicitações para um endpoint no mesmo momento. Você ainda deve implementar uma maneira mais segura, talvez no nível do banco de dados.
u.unver34
1
Há uma resposta melhor abaixo. A resposta de Ellis Percival usa o transaction.atomicque é importante aqui. Também é mais eficiente usando uma única consulta.
alexbhandari
33

Eu sobrescreveria o método save do modelo e se você definiu o booleano como True, certifique-se de que todos os outros estejam definidos como 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)

Tentei editar a resposta semelhante de Adam, mas foi rejeitada por alterar muito a resposta original. Desta forma é mais sucinta e eficiente, pois a verificação das demais entradas é feita em uma única consulta.

Ellis Percival
fonte
7
Eu acho que esta é a melhor resposta, mas eu sugeriria encerrar saveem uma @transaction.atomictransação. Porque pode acontecer que você remova todas as bandeiras, mas então o salvamento falhe e você acabe com todos os personagens não escolhidos.
Mitar de
Obrigado por dizer isso. Você está absolutamente certo e atualizarei a resposta.
Ellis Percival
@Mitar @transaction.atomictambém protege contra condições de corrida.
Pawel Furmaniak
1
Melhor solução entre todas!
Arturo
1
Em relação à transaction.atomic, usei o gerenciador de contexto em vez de um decorador. Não vejo razão para usar transação atômica em todos os modelos, exceto porque isso só importa se o campo booleano for verdadeiro. Eu sugiro usar with transaction.atomic:dentro da instrução if junto com salvar dentro do if. Em seguida, adicionando um bloco else e salvando também no bloco else.
alexbhandari
29

Em vez de usar limpeza / salvamento de modelo personalizado, criei um campo personalizado substituindo o pre_savemétodo em django.db.models.BooleanField. Em vez de gerar um erro se outro campo fosse True, fiz todos os outros campos Falsese fosse True. Além disso, em vez de gerar um erro se o campo era Falsee nenhum outro era True, salvei o campo 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"])

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
fonte
2
Parece muito mais simples do que os outros métodos
pistache
2
Também gosto dessa solução, embora pareça potencialmente perigoso fazer com que o objects.update defina todos os outros objetos como False no caso em que o modelo UniqueBoolean é True. Seria ainda melhor se UniqueBooleanField pegasse um argumento opcional para indicar se os outros objetos deveriam ser configurados como False ou se um erro deveria ser gerado (a outra alternativa sensata). Além disso, dado seu comentário no elif, onde você deseja definir o atributo como verdadeiro, acho que você deve mudar Return Trueparasetattr(model_instance, self.attname, True)
Andrew Chase
2
UniqueBooleanField não é realmente único, já que você pode ter quantos valores False desejar. Não tenho certeza de qual nome seria melhor ... OneTrueBooleanField? O que eu realmente quero é poder definir isso em combinação com uma chave estrangeira para que eu possa ter um BooleanField que só foi permitido ser True uma vez por relacionamento (por exemplo, um CreditCard tem um campo "primário" e um FK para o usuário e a combinação Usuário / Primário é verdadeira uma vez por uso). Para esse caso, acho que a resposta de Adam substituindo save será mais direta para mim.
Andrew Chase de
1
Deve-se notar que este método permite que você termine em um estado sem linhas definidas como truese você excluir a única truelinha.
rblk
11

A solução a seguir é um pouco feia, mas pode 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)

Se você definir is_the_chosen_one como False ou None, será sempre NULL. Você pode ter NULL o quanto quiser, mas só pode ter um True.

semente
fonte
1
A primeira solução que pensei também. NULL é sempre único, então você sempre pode ter uma coluna com mais de um NULL.
kaleissin
10

Tentando pagar as contas com as respostas aqui, acho que alguns deles abordam o mesmo problema com sucesso e cada um é adequado em diferentes situações:

Eu escolheria:

  • @semente : Respeita a restrição nos níveis de banco de dados, modelo e formulário de administração, enquanto sobrescreve o Django ORM o mínimo possível. Além disso, podeprovavelmenteser usado dentro de uma throughtabela de ManyToManyFielduma unique_togethersituação.(Vou verificar e relatar)

    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 : Acesse o banco de dados apenas uma vez extra e aceita a entrada atual como escolhida. Limpo e 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)  
    

Outras soluções não adequadas para o meu caso, mas viáveis:

@nemocorp está substituindo o cleanmétodo para realizar uma validação. No entanto, ele não informa qual modelo é "aquele" e isso não é amigável. Apesar disso, é uma abordagem muito boa, especialmente se alguém não pretende ser tão agressivo quanto @Flyte.

@ saul.shanabrook e @Thierry J. criariam um campo personalizado que alteraria qualquer outra entrada "is_the_one" para Falseou geraria um ValidationError. Estou apenas relutante em implementar novos recursos na minha instalação do Django, a menos que seja absolutamente necessário.

@daigorocub : Usa sinais Django. Acho que é uma abordagem única e dá uma dica de como usar Django Signals . No entanto, não tenho certeza se este é um uso - estritamente falando - "adequado" de sinais, uma vez que não posso considerar este procedimento como parte de uma "aplicação desacoplada".

raratiru
fonte
Obrigado pela revisão! Atualizei um pouco minha resposta, com base em um dos comentários, caso você queira atualizar seu código aqui também.
Ellis Percival de
@EllisPercival Obrigado pela dica! Eu atualizei o código de acordo. Tenha em mente que models.Model.save () não retorna algo.
raratiru
Isso é bom. É principalmente apenas para salvar o primeiro retorno em sua própria linha. Sua versão está realmente incorreta, pois não inclui o .save () na transação atômica. Além disso, deve ser 'with transaction.atomic ():' em vez disso.
Ellis Percival de
1
@EllisPercival OK, obrigado! Na verdade, precisamos reverter tudo, caso a save()operação falhe!
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

Você também pode usar o formulário acima para administrador, basta usar

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

Isso tornou a validação disponível no formulário administrativo básico

nemocorp
fonte
4

É mais simples adicionar este tipo de restrição ao seu modelo após o Django versão 2.2. Você pode usar diretamente UniqueConstraint.condition. Django Docs

Basta substituir seus modelos class Metadesta forma:

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

E isso é tudo.

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

Usando uma abordagem semelhante à de Saul, mas com um propósito ligeiramente 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 implementação irá gerar um ValidationErrorao tentar salvar outro registro com um valor True.

Além disso, adicionei o unique_forargumento que pode ser definido como qualquer outro campo no modelo, para verificar a verdadeira exclusividade apenas para registros com o mesmo valor, como:

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

Eu ganho pontos por responder à minha pergunta?

O problema era que ele estava no loop, corrigido 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
fonte
Não, nenhum ponto para responder à sua própria pergunta e aceitar essa resposta. No entanto, há pontos a serem observados se alguém aprovar sua resposta. :)
dandan78
Tem certeza de que não pretendia responder à sua própria pergunta aqui ? Basicamente, você e @sampablokuper tiveram a mesma pergunta
j_syk
1

Eu tentei algumas dessas soluções e acabei com outra, apenas por uma questão de abreviação de código (não precisa sobrescrever formulários ou salvar método). Para que isso funcione, o campo não pode ser único em sua definição, mas o sinal garante que isso aconteça.

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

Atualização de 2020 para tornar as coisas menos complicadas para iniciantes:

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

Obviamente, se você quiser que o booleano exclusivo seja False, basta trocar todas as instâncias de True por False e vice-versa.

Jay
fonte