Django Autocomplete reversed ForeignKey field

I have two models

models.py

class Group(models.Model):
    name = models.CharField()

class Book(models.Model):
    name = models.CharField()
    group = models.ForeignKey(Group, on_delete=models.SET_NULL, related_name='books', related_query_name="book", null=True, blank=True)

I need in admin panel add to Group form multiple select for books.

I have successfully solved this problem using the code below.

admin.py

from .models import Group, Book
from django.forms import ModelForm, ModelMultipleChoiceField
from django.contrib.admin import widgets


class Group_ModelForm(ModelForm):
    books = ModelMultipleChoiceField(queryset=Book.objects.all(), widget=widgets.FilteredSelectMultiple('Books', False), required=True, label='Books')
    
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if 'instance' in kwargs and kwargs['instance'] is not None:
            self.fields['books'].initial = kwargs['books'].books.all()

    
@admin.register(Group)
class Group_ModelAdmin(admin.ModelAdmin):
    list_display = ['name']
    list_display_links = ['name']
    fields = ['books', 'name']
    form = Group_ModelForm

    def save_model(self, request, obj, form, change):
        super().save_model(request, obj, form, change)
        if change:
            Book.objects.filter(group=obj).update(group=None)
        form.cleaned_data['books'].update(group=obj)

But, when the number of books exceeded 100 000, the Group form took a long time to load, because FilteredSelectMultiple load all 100 000 objects on init.

I try replace FilteredSelectMultiple widget to AutocompleteSelectMultiple widget, but AutocompleteSelectMultiple do not work without many-to-many field in Group model. I try override AutocompleteMixin class to create custom AutocompleteSelectMultiple widget, but I didn’t succeed: the AutocompleteSelectMultiple widget still required a many-to-many field in Group model.

How to do autocomplete select widget for custom field books in Group form?

P.S. In the Group form I need to select existing books. I don't need a inline form that creates new books.

I use Django 4.2

I succeeded in my task. Here's what you need to do:

In project's urls.py

from django.contrib.admin.views.autocomplete import AutocompleteJsonView
from django.apps import apps
from django.core.exceptions import PermissionDenied
from django.http import Http404

class AutocompleteJsonView_for_custom_field(AutocompleteJsonView):
    def process_request(self, request):
        term = request.GET.get("term", "")
        try:
            app_label = request.GET["app_label"]
            model_name = request.GET["model_name"]
        except KeyError as e:
            raise PermissionDenied from e

        # Retrieve objects from parameters.
        try:
            remote_model = apps.get_model(app_label, model_name)
        except LookupError as e:
            raise PermissionDenied from e

        try:
            model_admin = self.admin_site._registry[remote_model]
        except KeyError as e:
            raise PermissionDenied from e

        # Validate suitability of objects.
        if not model_admin.get_search_fields(request):
            raise Http404(
                "%s must have search_fields for the autocomplete_view."
                % type(model_admin).__qualname__
            )

        to_field_name = remote_model._meta.pk.attname
        if not model_admin.to_field_allowed(request, to_field_name):
            raise PermissionDenied

        return term, model_admin, None, to_field_name

    def get_queryset(self):
        """Return queryset based on ModelAdmin.get_search_results()."""
        qs = self.model_admin.get_queryset(self.request)
        # qs = qs.complex_filter(self.source_field.get_limit_choices_to())
        qs, search_use_distinct = self.model_admin.get_search_results(
            self.request, qs, self.term
        )
        if search_use_distinct:
            qs = qs.distinct()
        return qs

urlpatterns = [
...
path('admin_ajax/autocomplete_for_custom_field', AutocompleteJsonView_for_custom_field.as_view(admin_site=admin.site), name='autocomplete_for_custom_field'),
]

In admin.py

from django.contrib.admin.widgets import get_select2_language
from django.urls import reverse
import json
from django.conf import settings

class AutocompleteMixin_for_custom_field:
    def __init__(self, remote_model, attrs=None, choices=(), using=None):
        self.remote_model = remote_model
        self.db = using
        self.choices = choices
        self.attrs = {} if attrs is None else attrs.copy()
        self.i18n_name = get_select2_language()

    def build_attrs(self, base_attrs, extra_attrs=None):
        """
        Set select2's AJAX attributes.

        Attributes can be set using the html5 data attribute.
        Nested attributes require a double dash as per
        https://select2.org/configuration/data-attributes#nested-subkey-options
        """
        attrs = super().build_attrs(base_attrs, extra_attrs=extra_attrs)
        attrs.setdefault("class", "")
        attrs.update(
            {
                "data-ajax--cache": "true",
                "data-ajax--delay": 250,
                "data-ajax--type": "GET",
                "data-ajax--url": reverse('autocomplete_for_custom_field'),
                "data-app-label": self.remote_model._meta.app_label,
                "data-model-name": self.remote_model._meta.model_name,
                "data-field-name": '',
                "data-theme": "admin-autocomplete",
                "data-allow-clear": json.dumps(not self.is_required),
                "data-placeholder": "",  # Allows clearing of the input.
                "lang": self.i18n_name,
                "class": attrs["class"]
                + (" " if attrs["class"] else "")
                + "admin-autocomplete",
            }
        )
        return attrs

    def optgroups(self, name, value, attr=None):
        """Return selected options based on the ModelChoiceIterator."""
        default = (None, [], 0)
        groups = [default]
        has_selected = False
        selected_choices = {
            str(v) for v in value if str(v) not in self.choices.field.empty_values
        }
        if not self.is_required and not self.allow_multiple_selected:
            default[1].append(self.create_option(name, "", "", False, 0))
        to_field_name = self.remote_model._meta.pk.attname
        choices = (
            (getattr(obj, to_field_name), self.choices.field.label_from_instance(obj))
            for obj in self.choices.queryset.using(self.db).filter(
                **{"%s__in" % to_field_name: selected_choices}
            )
        )
        for option_value, option_label in choices:
            selected = str(option_value) in value and (
                has_selected is False or self.allow_multiple_selected
            )
            has_selected |= selected
            index = len(default[1])
            subgroup = default[1]
            subgroup.append(
                self.create_option(
                    name, option_value, option_label, selected_choices, index
                )
            )
        return groups

    @property
    def media(self):
        extra = "" if settings.DEBUG else ".min"
        i18n_file = (
            ("admin/js/vendor/select2/i18n/%s.js" % self.i18n_name,)
            if self.i18n_name
            else ()
        )
        return forms.Media(
            js=(
                "admin/js/vendor/jquery/jquery%s.js" % extra,
                "admin/js/vendor/select2/select2.full%s.js" % extra,
            )
            + i18n_file
            + (
                "admin/js/jquery.init.js",
                "admin/js/autocomplete.js",
            ),
            css={
                "screen": (
                    "admin/css/vendor/select2/select2%s.css" % extra,
                    "admin/css/autocomplete.css",
                ),
            },
        )

class AutocompleteSelectMultiple_for_custom_field(AutocompleteMixin_for_custom_field, forms.SelectMultiple):
    pass

class Group_ModelForm(ModelForm):
    books = ModelMultipleChoiceField(queryset=Book.objects.all(), widget=AutocompleteSelectMultiple_for_custom_field(Book), required=True, label=gettext_lazy('Books'))

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if 'instance' in kwargs and kwargs['instance'] is not None:
            self.fields['books'].initial = kwargs['instance'].books.all()

@admin.register(Group)
class Group_ModelAdmin(admin.ModelAdmin):
    list_display = ['name']
    list_display_links = ['name']
    fields = ['books', 'name']
    form = Group_ModelForm

    def save_model(self, request, obj, form, change):
        super().save_model(request, obj, form, change)
        if change:
            Book.objects.filter(group=obj).update(group=None)
        form.cleaned_data['books'].update(group=obj)
Back to Top