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)