Form field for a foreign key in ModelForm with too many choices for ModelChoiceField?

Viewed 323

I have a simple foreign key relationship I want to use in a ModelForm, but without a ModelChoiceField.

class Sample(models.Model):
    alt = IntegerField(db_index=True, unique=True)

class Assignment(models.Model):
    sample = models.ForeignKey(Sample, on_delete=models.CASCADE)

I want to have the AssignmentForm select the sample based on the contents of the sample's alt field. With a ModelChoiceField it would be like this:

class SampleSelect(ModelChoiceField):
    def label_from_instance(self, obj):
        return obj.alt

class AssignmentForm(ModelForm):
    sample = SampleSelect(queryset=Sample.objects.all())
    class Meta:
        model = Assignment
        fields = ['sample']

The ModelChoiceField documentation says to use something else if the number of choices is large.

Allows the selection of a single model object, suitable for representing a foreign key. Note that the default widget for ModelChoiceField becomes impractical when the number of entries increases. You should avoid using it for more than 100 items.

I think I need a custom form field, but I cannot figure out how to do this.

class SampleBAltField(IntegerField):
    def clean(self, value):
        try:
            return Sample.objects.get(alt=value)
        except Sample.DoesNotExist:
            raise ValidationError(f'Sample with alt {value} does not exist')

This existing code should take an integer from the form and map it back to a foreign key, but I cannot figure out what to override to populate the field for a bound form from the Sample instance.

Is there a relatively easy way to solve this issue with FormFields in the ModelForm, or do I need to write the Form from scratch?

3 Answers

The ModelChoiceField documentation says to use something else if the number of choices is large.

The documentation suggests using a different widget (otherwise a user will have to select from a dropdown with too many items), but you don't necessarily need an entirely different field.

If you want the field of the bound form to be n instance of Sample, then ModelChoiceField is still appropriate.

To avoid the problem anticipated in the documentation, you could just change the widget for the field. You might need to decide exactly what that is. One simple choice would be to use a NumberInput widget where the user just enters an integer for the foreign key.

from django.forms.widgets import NumberInput

class AssignmentForm(ModelForm):
    sample = ModelChoiceField(queryset=Sample.objects.all(), widget=NumberInput)
    class Meta:
        model = Assignment
        fields = ['sample']

select the sample based on the contents of the sample's alt field

What you want here is a separate issue from what you quoted from the documentation. You can choose to implement this with or without changing the widget.

If you want the user to provide the alt value rather than the primary key of the Sample, you can use the to_field_name argument for ModelChoiceField (note this is only appropriate here because your alt field is unique)

class AssignmentForm(ModelForm):
    sample = ModelChoiceField(
        queryset=Sample.objects.all(),
        widget=NumberInput,
        help_text="Enter the alt of the sample",
        to_field_name='alt'
    )

    class Meta:
        model = Assignment
        fields = ["sample"]

In order for the initial value to render correctly when rendering a bound form, you can provide the initial keyword argument when instantiating the bound form:

form = AssignmentForm(instance=inst,
                      initial={'sample': inst.sample.alt})

Alternatively, you can override the __init__ method of the form to do this automatically when the form is instantiated:

class AssignmentForm(ModelForm):
    ...
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if self.instance.pk:
            self.initial.update({'sample': self.instance.sample.alt})

What you're looking for is a custom widget, not the field itself.

Django Admin has something like that. It's called "Raw ID Field" widget. You can try it by configuring it in admin.py by setting raw_id_fields = ['field_name'] on your registered admin.ModelAdmin.

Documentation: https://docs.djangoproject.com/en/3.2/ref/contrib/admin/#django.contrib.admin.ModelAdmin.raw_id_fields

Implementation: https://github.com/django/django/blob/ca9872905559026af82000e46cde6f7dedc897b6/django/contrib/admin/widgets.py#L120

You can use that outside of admin but that will require adding some Django Admin's JS to your application.

Alternatively, if you need something much simpler, you can copy the idea.

If you are not using the sample model in other instances where the sample is getting displayed by it's id or name in a template, you could just add a str method to the model to allow for it to be displayed in a custom manor.

class Sample(models.Model):
    alt = IntegerField(db_index=True, unique=True)

    def __str__(self):
        return f"{self.alt}"

Now whenever you call your model instance, it is displayed by the alt field. This will be true for the model view in the Django Admin as well.

Related