5

TL;DR :

Filtering a queryset according to a related object's value may cause duplicate values in the result.

This behaviour spreads on the limit_choices_to FK's attribute in a model field when using it in a similar way, causing a MultipleObjectsReturned error when using a modelform associated with this model and selecting a duplicate value.

Is it possible to apply distinct() or equivalent on a model's foreign key's limit_choices_to in order to avoid duplicate in the options of a modelform's field?


Reproducing the problem :

With python manage.py shell (and solving it) :

Let two models A and B:

class A(models.Model):
    pass

class B(models.Model):
    a = models.ForeignKey(A)
    d = models.BooleanField(default=False)

and the following entries :

>>> a = A.objects.create()
>>> b1 = B.objects.create(a=a, d=True)
>>> b2 = B.objects.create(a=a, d=True)

The following queryset using a get() causes an error :

>>> A.objects.filter(b__d=True).get(id=1)
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "/home/vmonteco/.venvs/django/lib/python3.6/site-packages/django/db/models/query.py", line 384, in get
    (self.model._meta.object_name, num)
app.models.MultipleObjectsReturned: get() returned more than one A -- it returned 2!

Which sounds normal since a is present twice in the filter()'s result :

>>> A.objects.filter(b__d=True)
<QuerySet [<A: A object>, <A: A object>]>

This error can be solved easily with a simple distinct() :

>>> A.objects.filter(b__d=True).distinct().get(id=1)
<A: A object>

With a third model and it's associated modelform :

Let's add a third model :

class C(models.Model):
    a = models.ForeignKey(A, limit_choices_to={'b__d': True})

I could create/edit instances with a modelform :

class CForm(forms.ModelForm):
    class Meta:
        model = C
        fields = ['a',]

The queryset populating the a field's choices should look like something like this :

>>> A.objects.filter(b__d=True)
<QuerySet [<A: A object>, <A: A object>]>

Which only contains the same object twice :

>>> A.objects.filter(b__d=True).values('id')
<QuerySet [{'id': 1}, {'id': 1}]>

Then, at the form submission, django applies a get(id=selected_value) on the field's queryset. If the selected value is a duplicate value, the problem I exposed in the previous part occurs.

Current solution :

The only solution I found so far is to overwrite the field's queryset in my modelform in order to ensure there is no duplicate :

class CForm(forms.ModelForm):
    class Meta:
        model = C
        fields = ['a',]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields['a'].queryset = self.fields['a'].queryset.distinct()

But since this queryset is defined directly after the model field's definition, this solution feels unsatisfying and looks more like a workaround. limit_choices_to doesn't seem to document this case.

Could there be a more appropriate way to avoid duplicates in a field's queryset when limit_choices_to is used?

vmonteco
  • 14,136
  • 15
  • 55
  • 86
  • Does [this](https://stackoverflow.com/questions/291945/how-do-i-filter-foreignkey-choices-in-a-django-modelform) help? – Sachin Aug 25 '18 at 15:10
  • 3
    Unfortunately I think your workaround is the best solution you'll find. This has been a known bug for some time: https://code.djangoproject.com/ticket/11707 – souldeux Aug 28 '18 at 02:03

1 Answers1

0

I created a callable to get the list of choices, preventing foreign key queries that return more than one row:

class ParameterTypeGetter():
    def __init__(self, class_name):
        self.class_name = class_name

    def __call__(self):
        types = ParameterType.objects.filter(datatype__data_class__name=self.class_name)
        return Q(parameter_type__in=types)

class ElementData(models.Model, VarDataCommentsMixIn):
    parameter = models.ForeignKey(
        Parameter, null=True, blank=True,
        on_delete=models.PROTECT,
        limit_choices_to=ParameterTypeGetter('parameter'))
    multiparameter = models.ManyToManyField(
        Parameter, related_name='multiparameter', blank=True,
        limit_choices_to=ParameterTypeGetter('multiparameter'))

The way it was when I had this problem:

        limit_choices_to=dict(
            parameter_type__datatype__data_class__name='parameter'),)
rapto
  • 405
  • 3
  • 15