Unikalna wartość BooleanField w Django?

88

Załóżmy, że mój models.py jest taki:

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

Chcę, aby tylko jedna z moich Characterinstancji miała, is_the_chosen_one == Truea wszystkie inne miały is_the_chosen_one == False. Jak najlepiej zapewnić przestrzeganie tego ograniczenia wyjątkowości?

Najwyższe oceny za odpowiedzi, które uwzględniają konieczność przestrzegania ograniczeń na poziomie bazy danych, modelu i formularza (administratora)!

sampablokuper
źródło
4
Dobre pytanie. Jestem również ciekawy, czy można ustawić takie ograniczenie. Wiem, że jeśli po prostu uczynisz to unikalnym ograniczeniem, otrzymasz tylko dwa możliwe wiersze w swojej bazie danych ;-)
Andre Miller
Niekoniecznie: jeśli używasz NullBooleanField, powinieneś mieć: (True, a False, dowolną liczbę NULL).
Matthew Schinckel
Według moich badań , @semente odpowiedź uwzględnia znaczenie przestrzegania ograniczenie w bazie danych, modelu i poziomy (Administrator) tworzą natomiast zapewnia doskonałe rozwiązanie nawet dla throughtabeli ManyToManyField, która potrzebuje unique_togetherograniczenie.
raratiru,

Odpowiedzi:

66

Zawsze, gdy potrzebowałem wykonać to zadanie, nadpisałem metodę zapisu modelu i kazałem mu sprawdzić, czy jakikolwiek inny model ma już ustawioną flagę (i wyłączyć ją).

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
źródło
3
Po prostu zmieniłbym 'def save (self):' na: 'def save (self, * args, ** kwargs):'
Marek
8
Próbowałem to edytować, aby zmienić save(self)na, save(self, *args, **kwargs)ale edycja została odrzucona. Czy którykolwiek z recenzentów mógłby poświęcić trochę czasu, aby wyjaśnić dlaczego - ponieważ wydaje się to być zgodne z najlepszą praktyką Django.
scytale
14
Próbowałem edytować, aby usunąć potrzebę try / except i uczynić proces bardziej wydajnym, ale został on odrzucony .. Zamiast tworzyć get()obiekt Character, a następnie save()ponownie, wystarczy filtrować i aktualizować, co daje tylko jedno zapytanie SQL i pomaga zachować spójność DB: 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
Nie mogę zasugerować żadnej lepszej metody wykonania tego zadania, ale chcę to powiedzieć, nigdy nie ufaj metodom zapisywania lub czyszczenia, jeśli używasz aplikacji internetowej, którą możesz przesłać kilka żądań do punktu końcowego w tym samym momencie. Nadal musisz wdrożyć bezpieczniejszy sposób, być może na poziomie bazy danych.
u.unver34
1
Poniżej znajduje się lepsza odpowiedź. Odpowiedź Ellisa Percivala wykorzystuje transaction.atomicto, co jest tutaj ważne. Jest to również bardziej wydajne przy użyciu pojedynczego zapytania.
alexbhandari
34

Zastąpiłbym metodę zapisu modelu i jeśli ustawiłeś wartość logiczną na True, upewnij się, że wszystkie inne są ustawione na 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)

Próbowałem zredagować podobną odpowiedź Adama, ale została ona odrzucona za zbyt dużą zmianę oryginalnej odpowiedzi. Ten sposób jest bardziej zwięzły i skuteczny, ponieważ sprawdzanie innych wpisów odbywa się w jednym zapytaniu.

Ellis Percival
źródło
7
Myślę, że jest to najlepsza odpowiedź, ale chciałbym zaproponować zawijanie savedo @transaction.atomictransakcji. Ponieważ może się zdarzyć, że usuniesz wszystkie flagi, ale zapisanie się nie powiedzie i skończy się na tym, że wszystkie postacie nie zostaną wybrane.
Mitar
Dziękuję za te słowa. Masz całkowitą rację i zaktualizuję odpowiedź.
Ellis Percival
@Mitar @transaction.atomicchroni również przed stanem wyścigu.
Paweł Furmaniak
1
Najlepsze rozwiązanie spośród wszystkich!
Arturo
1
W przypadku transakcji transaction.atomic użyłem menedżera kontekstu zamiast dekoratora. Nie widzę powodu, aby używać transakcji atomowej na każdym modelu, ponieważ ma to znaczenie tylko wtedy, gdy pole boolowskie jest prawdziwe. Sugeruję użycie with transaction.atomic:wewnątrz instrukcji if wraz z zapisaniem wewnątrz if. Następnie dodajemy kolejny blok i zapisujemy również w innym bloku.
alexbhandari
29

Zamiast korzystać z czyszczenia / zapisywania modelu niestandardowego, utworzyłem plik niestandardowe pole nadpisujące pre_savemetodę django.db.models.BooleanField. Zamiast zgłaszać błąd, jeśli było inne pole True, zrobiłem wszystkie inne pola, Falsejeśli było True. Zamiast zgłaszać błąd, jeśli pole było Falsei żadne inne pole nie było True, zapisałem je jako poleTrue

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
źródło
2
Wygląda to znacznie lepiej niż inne metody
pistache
2
To rozwiązanie również mi się podoba, chociaż wydaje mi się, że potencjalnie niebezpieczne jest ustawianie wszystkich innych obiektów na False w przypadku, gdy modele UniqueBoolean mają wartość True. Byłoby jeszcze lepiej, gdyby UniqueBooleanField przyjęła opcjonalny argument, aby wskazać, czy inne obiekty powinny być ustawione na False, czy też należy zgłosić błąd (druga rozsądna alternatywa). Ponadto, biorąc pod uwagę twój komentarz w elifie, gdzie chcesz ustawić atrybut na true, myślę, że powinieneś zmienić Return Truenasetattr(model_instance, self.attname, True)
Andrew Chase
2
UniqueBooleanField nie jest naprawdę wyjątkowa, ponieważ możesz mieć tyle wartości False, ile chcesz. Nie wiesz, jaka byłaby lepsza nazwa ... OneTrueBooleanField? To, czego naprawdę chcę, to móc określić zakres tego w połączeniu z kluczem obcym, tak żebym mógł mieć wartość BooleanField, która może mieć wartość True tylko raz na relację (np. Karta kredytowa ma pole „podstawowe” i FK dla użytkownika i kombinacja Użytkownik / Podstawowy ma wartość Prawda raz na użycie). W takim przypadku myślę, że odpowiedź Adama nadpisująca save będzie dla mnie prostsza.
Andrew Chase,
1
Należy zauważyć, że ta metoda pozwala na osiągnięcie stanu bez ustawionych wierszy, tak truejakbyś usuwał jedyny truewiersz.
rblk
11

Poniższe rozwiązanie jest trochę brzydkie, ale może działać:

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)

Jeśli ustawisz is_the_chosen_one na False lub None, zawsze będzie NULL. Możesz mieć NULL tyle, ile chcesz, ale możesz mieć tylko jedną True.

semente
źródło
1
Pierwsze rozwiązanie również pomyślałem. Wartość NULL jest zawsze wyjątkowa, więc zawsze możesz mieć kolumnę zawierającą więcej niż jedną wartość NULL.
kaleissin
10

Próbując związać koniec z końcem z odpowiedziami tutaj, stwierdzam, że niektóre z nich odnoszą się z powodzeniem do tego samego problemu, a każdy jest odpowiedni w różnych sytuacjach:

Wybrałbym:

  • @semente : szanuje ograniczenie na poziomie bazy danych, modelu i formularza administratora, podczas gdy zastępuje Django ORM w najmniejszym możliwym stopniu. Co więcej, możeprawdopodobniebyć używane wewnątrz throughtabeli ManyToManyFieldw unique_togethersytuacji.(Sprawdzę to i zamelduję)

    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 : Uderza do bazy danych tylko jeden dodatkowy raz i akceptuje bieżący wpis jako wybrany. Czysty i elegancki.

    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)  
    

Inne rozwiązania nieodpowiednie dla mojego przypadku, ale wykonalne:

@nemocorp zastępuje cleanmetodę w celu przeprowadzenia walidacji. Jednak nie informuje, który model jest „tym”, a to nie jest przyjazne dla użytkownika. Mimo to jest to bardzo fajne podejście, zwłaszcza jeśli ktoś nie zamierza być tak agresywny jak @Flyte.

@ saul.shanabrook i @Thierry J. utworzyliby niestandardowe pole, które zmieniłoby dowolny inny wpis „is_the_one” na Falselub podniósł ValidationError. Jestem po prostu niechętny do wprowadzania nowych funkcji do mojej instalacji Django, chyba że jest to absolutnie konieczne.

@daigorocub : używa sygnałów Django. Uważam, że jest to wyjątkowe podejście i podpowiada, jak używać sygnałów Django . Jednak nie jestem pewien, czy jest to - ściśle mówiąc - „właściwe” użycie sygnałów, ponieważ nie mogę traktować tej procedury jako części „aplikacji oddzielonej”.

raratiru
źródło
Dzięki za recenzję! Zaktualizowałem trochę moją odpowiedź, na podstawie jednego z komentarzy, na wypadek, gdybyś chciał tutaj zaktualizować swój kod.
Ellis Percival
@EllisPercival Dziękuję za podpowiedź! Odpowiednio zaktualizowałem kod. Pamiętaj jednak, że models.Model.save () niczego nie zwraca.
raratiru
W porządku. Chodzi głównie o to, aby zapisać pierwszy powrót na własnej linii. Twoja wersja jest faktycznie nieprawidłowa, ponieważ nie zawiera .save () w atomowej transakcji. Dodatkowo powinno to być „with transaction.atomic ():”.
Ellis Percival
1
@EllisPercival OK, dziękuję! Rzeczywiście, potrzebujemy wszystkiego wycofanego na save()wypadek niepowodzenia operacji!
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

Możesz użyć powyższego formularza również dla administratora, po prostu użyj

class CharacterAdmin(admin.ModelAdmin):
    form = CharacterForm
admin.site.register(Character, CharacterAdmin)
shadfc
źródło
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")

W ten sposób walidacja była dostępna w podstawowym formularzu administratora

nemocorp
źródło
4

Łatwiej jest dodać tego rodzaju ograniczenie do modelu po wersji 2.2 Django. Możesz bezpośrednio użyć UniqueConstraint.condition. Django Docs

Po prostu zastąp swoje modele w class Metaten sposób:

class Meta:
    constraints = [
        UniqueConstraint(fields=['is_the_chosen_one'], condition=Q(is_the_chosen_one=True), name='unique_is_the_chosen_one')
    ]
mangofet
źródło
2

I to wszystko.

def save(self, *args, **kwargs):
    if self.default_dp:
        DownloadPageOrder.objects.all().update(**{'default_dp': False})
    super(DownloadPageOrder, self).save(*args, **kwargs)
palestamp
źródło
2

Stosując podobne podejście jak Saul, ale nieco inny cel:

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

Ta implementacja spowoduje podniesienie a ValidationErrorpodczas próby zapisania innego rekordu z wartością True.

Dodałem również unique_forargument, który można ustawić na dowolne inne pole w modelu, aby sprawdzić prawdziwą unikalność tylko dla rekordów o tej samej wartości, takich jak:

class Phone(models.Model):
    user = models.ForeignKey(User)
    main = TrueUniqueBooleanField(unique_for='user', default=False)
Thierry J.
źródło
1

Czy dostanę punkty za odpowiedź na moje pytanie?

problem polegał na tym, że znalazł się w pętli, naprawiony przez:

    # 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
źródło
Nie, nie ma punktów za odpowiedź na własne pytanie i zaakceptowanie tej odpowiedzi. Są jednak pewne punkty, na które należy zwrócić uwagę, jeśli ktoś przychyli się do Twojej odpowiedzi. :)
dandan78
Czy na pewno nie chciałeś zamiast tego odpowiedzieć na swoje pytanie ? Zasadniczo ty i @sampablokuper mieliście to samo pytanie
j_syk
1

Wypróbowałem niektóre z tych rozwiązań i skończyłem na innym, tylko ze względu na zwięzłość kodu (nie muszę nadpisywać formularzy ani zapisywać metody). Aby to zadziałało, pole nie może być unikalne w swojej definicji, ale sygnał zapewnia, że ​​tak się stanie.

# 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
źródło
0

Aktualizacja 2020, aby ułatwić początkującym:

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

Oczywiście, jeśli chcesz, aby unikalna wartość logiczna miała wartość False, po prostu zamień każdą instancję True na False i odwrotnie.

Sójka
źródło