Skip to content

Commit

Permalink
Fixed #29010, Fixed #29138 -- Added limit_choices_to and to_field sup…
Browse files Browse the repository at this point in the history
…port to autocomplete fields.

* Fixed #29010 -- Added limit_choices_to support to autocomplete fields.
* Fixed #29138 -- Allowed autocomplete fields to target a custom
  to_field rather than the PK.
  • Loading branch information
codingjoe committed Jan 12, 2021
1 parent ba3fb2e commit 3071660
Show file tree
Hide file tree
Showing 11 changed files with 199 additions and 59 deletions.
9 changes: 2 additions & 7 deletions django/contrib/admin/options.py
Expand Up @@ -19,7 +19,6 @@
get_deleted_objects, lookup_needs_distinct, model_format_dict,
model_ngettext, quote, unquote,
)
from django.contrib.admin.views.autocomplete import AutocompleteJsonView
from django.contrib.admin.widgets import (
AutocompleteSelect, AutocompleteSelectMultiple,
)
Expand Down Expand Up @@ -225,7 +224,7 @@ def formfield_for_foreignkey(self, db_field, request, **kwargs):

if 'widget' not in kwargs:
if db_field.name in self.get_autocomplete_fields(request):
kwargs['widget'] = AutocompleteSelect(db_field.remote_field, self.admin_site, using=db)
kwargs['widget'] = AutocompleteSelect(db_field, self.admin_site, using=db)
elif db_field.name in self.raw_id_fields:
kwargs['widget'] = widgets.ForeignKeyRawIdWidget(db_field.remote_field, self.admin_site, using=db)
elif db_field.name in self.radio_fields:
Expand Down Expand Up @@ -255,7 +254,7 @@ def formfield_for_manytomany(self, db_field, request, **kwargs):
autocomplete_fields = self.get_autocomplete_fields(request)
if db_field.name in autocomplete_fields:
kwargs['widget'] = AutocompleteSelectMultiple(
db_field.remote_field,
db_field,
self.admin_site,
using=db,
)
Expand Down Expand Up @@ -622,7 +621,6 @@ def wrapper(*args, **kwargs):
return [
path('', wrap(self.changelist_view), name='%s_%s_changelist' % info),
path('add/', wrap(self.add_view), name='%s_%s_add' % info),
path('autocomplete/', wrap(self.autocomplete_view), name='%s_%s_autocomplete' % info),
path('<path:object_id>/history/', wrap(self.history_view), name='%s_%s_history' % info),
path('<path:object_id>/delete/', wrap(self.delete_view), name='%s_%s_delete' % info),
path('<path:object_id>/change/', wrap(self.change_view), name='%s_%s_change' % info),
Expand Down Expand Up @@ -1652,9 +1650,6 @@ def _changeform_view(self, request, object_id, form_url, extra_context):

return self.render_change_form(request, context, add=add, change=not add, obj=obj, form_url=form_url)

def autocomplete_view(self, request):
return AutocompleteJsonView.as_view(model_admin=self)(request)

def add_view(self, request, form_url='', extra_context=None):
return self.changeform_view(request, None, form_url, extra_context)

Expand Down
5 changes: 5 additions & 0 deletions django/contrib/admin/sites.py
Expand Up @@ -4,6 +4,7 @@

from django.apps import apps
from django.contrib.admin import ModelAdmin, actions
from django.contrib.admin.views.autocomplete import AutocompleteJsonView
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.core.exceptions import ImproperlyConfigured
from django.db.models.base import ModelBase
Expand Down Expand Up @@ -255,6 +256,7 @@ def wrapper(*args, **kwargs):
wrap(self.password_change_done, cacheable=True),
name='password_change_done',
),
path('autocomplete/', wrap(self.autocomplete_view), name='autocomplete'),
path('jsi18n/', wrap(self.i18n_javascript, cacheable=True), name='jsi18n'),
path(
'r/<int:content_type_id>/<path:object_id>/',
Expand Down Expand Up @@ -401,6 +403,9 @@ def login(self, request, extra_context=None):
request.current_app = self.name
return LoginView.as_view(**defaults)(request)

def autocomplete_view(self, request):
return AutocompleteJsonView.as_view(admin_site=self)(request)

def _build_app_dict(self, request, label=None):
"""
Build the app dictionary. The optional `label` parameter filters models
Expand Down
5 changes: 4 additions & 1 deletion django/contrib/admin/static/admin/js/autocomplete.js
Expand Up @@ -7,7 +7,10 @@
data: function(params) {
return {
term: params.term,
page: params.page
page: params.page,
app_label: $element.data('app-label'),
model_name: $element.data('model-name'),
field_name: $element.data('field-name')
};
}
}
Expand Down
68 changes: 59 additions & 9 deletions django/contrib/admin/views/autocomplete.py
@@ -1,11 +1,13 @@
from django.apps import apps
from django.core.exceptions import FieldDoesNotExist, PermissionDenied
from django.http import Http404, JsonResponse
from django.views.generic.list import BaseListView


class AutocompleteJsonView(BaseListView):
"""Handle AutocompleteWidget's AJAX requests for data."""
paginate_by = 20
model_admin = None
admin_site = None

def get(self, request, *args, **kwargs):
"""
Expand All @@ -15,20 +17,16 @@ def get(self, request, *args, **kwargs):
pagination: {more: true}
}
"""
if not self.model_admin.get_search_fields(request):
raise Http404(
'%s must have search_fields for the autocomplete_view.' %
type(self.model_admin).__name__
)
self.term, self.model_admin, self.source_field, to_field_name = self.process_request(request)

if not self.has_perm(request):
return JsonResponse({'error': '403 Forbidden'}, status=403)
raise PermissionDenied

self.term = request.GET.get('term', '')
self.object_list = self.get_queryset()
context = self.get_context_data()
return JsonResponse({
'results': [
{'id': str(obj.pk), 'text': str(obj)}
{'id': str(getattr(obj, to_field_name)), 'text': str(obj)}
for obj in context['object_list']
],
'pagination': {'more': context['page_obj'].has_next()},
Expand All @@ -41,11 +39,63 @@ def get_paginator(self, *args, **kwargs):
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())

This comment has been minimized.

Copy link
@fopinappb

fopinappb Feb 3, 2022

ManyToManyRel do not have 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

def process_request(self, request):
"""
Validate request integrity, extract and return request parameters.
Since the subsequent view permission check requires the target model
admin, which is determined here, raise PermissionDenied if the
requested app, model or field are malformed.
Raise Http404 if the target model admin is not configured properly with
search_fields.
"""
term = request.GET.get('term', '')
try:
app_label = request.GET['app_label']
model_name = request.GET['model_name']
field_name = request.GET['field_name']
except KeyError as e:
raise PermissionDenied from e

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

try:
source_field = source_model._meta.get_field(field_name)
except FieldDoesNotExist as e:
raise PermissionDenied from e
try:
remote_model = source_field.remote_field.model
except AttributeError 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 = getattr(source_field.remote_field, 'field_name', model_admin.model._meta.pk.name)

This comment has been minimized.

Copy link
@dlis

dlis Feb 20, 2021

Unfortunately, that breaks autocomplete for inherited models. Details here – https://code.djangoproject.com/ticket/32466

if not model_admin.to_field_allowed(request, to_field_name):
raise PermissionDenied

return term, model_admin, source_field, to_field_name

def has_perm(self, request, obj=None):
"""Check if user has permission to access the related model."""
return self.model_admin.has_view_permission(request, obj=obj)
17 changes: 10 additions & 7 deletions django/contrib/admin/widgets.py
Expand Up @@ -380,18 +380,17 @@ class AutocompleteMixin:
Renders the necessary data attributes for select2 and adds the static form
media.
"""
url_name = '%s:%s_%s_autocomplete'
url_name = '%s:autocomplete'

def __init__(self, rel, admin_site, attrs=None, choices=(), using=None):
self.rel = rel
def __init__(self, field, admin_site, attrs=None, choices=(), using=None):
self.field = field
self.admin_site = admin_site
self.db = using
self.choices = choices
self.attrs = {} if attrs is None else attrs.copy()

def get_url(self):
model = self.rel.model
return reverse(self.url_name % (self.admin_site.name, model._meta.app_label, model._meta.model_name))
return reverse(self.url_name % self.admin_site.name)

def build_attrs(self, base_attrs, extra_attrs=None):
"""
Expand All @@ -408,6 +407,9 @@ def build_attrs(self, base_attrs, extra_attrs=None):
'data-ajax--delay': 250,
'data-ajax--type': 'GET',
'data-ajax--url': self.get_url(),
'data-app-label': self.field.model._meta.app_label,
'data-model-name': self.field.model._meta.model_name,
'data-field-name': self.field.name,
'data-theme': 'admin-autocomplete',
'data-allow-clear': json.dumps(not self.is_required),
'data-placeholder': '', # Allows clearing of the input.
Expand All @@ -426,9 +428,10 @@ def optgroups(self, name, value, attr=None):
}
if not self.is_required and not self.allow_multiple_selected:
default[1].append(self.create_option(name, '', '', False, 0))
to_field_name = getattr(self.field.remote_field, 'field_name', self.field.model._meta.pk.name)
choices = (
(obj.pk, self.choices.field.label_from_instance(obj))
for obj in self.choices.queryset.using(self.db).filter(pk__in=selected_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 = (
Expand Down
6 changes: 6 additions & 0 deletions docs/releases/3.2.txt
Expand Up @@ -119,6 +119,12 @@ Minor features

* The admin now supports theming. See :ref:`admin-theming` for more details.

* :attr:`.ModelAdmin.autocomplete_fields` now respects
:attr:`ForeignKey.to_field <django.db.models.ForeignKey.to_field>` and
:attr:`ForeignKey.limit_choices_to
<django.db.models.ForeignKey.limit_choices_to>` when searching a related
model.

:mod:`django.contrib.admindocs`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
8 changes: 8 additions & 0 deletions tests/admin_views/models.py
Expand Up @@ -629,13 +629,21 @@ class Question(models.Model):
posted = models.DateField(default=datetime.date.today)
expires = models.DateTimeField(null=True, blank=True)
related_questions = models.ManyToManyField('self')
uuid = models.UUIDField(default=uuid.uuid4, unique=True)

def __str__(self):
return self.question


class Answer(models.Model):
question = models.ForeignKey(Question, models.PROTECT)
question_with_to_field = models.ForeignKey(
Question, models.SET_NULL,
blank=True, null=True, to_field='uuid',
related_name='uuid_answers',
limit_choices_to=~models.Q(question__istartswith='not'),
)
related_answers = models.ManyToManyField('self')
answer = models.CharField(max_length=20)

def __str__(self):
Expand Down

1 comment on commit 3071660

@dnk8n
Copy link

@dnk8n dnk8n commented on 3071660 Feb 8, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there some docs on how to use this?

Please sign in to comment.