diff --git a/django/contrib/admin/views/autocomplete.py b/django/contrib/admin/views/autocomplete.py
index 6c52ecaeea..3903e4c98c 100644
--- a/django/contrib/admin/views/autocomplete.py
+++ b/django/contrib/admin/views/autocomplete.py
@@ -90,7 +90,8 @@ class AutocompleteJsonView(BaseListView):
type(model_admin).__qualname__
)
- to_field_name = getattr(source_field.remote_field, 'field_name', model_admin.model._meta.pk.name)
+ to_field_name = getattr(source_field.remote_field, 'field_name', remote_model._meta.pk.attname)
+ to_field_name = remote_model._meta.get_field(to_field_name).attname
if not model_admin.to_field_allowed(request, to_field_name):
raise PermissionDenied
diff --git a/django/contrib/admin/widgets.py b/django/contrib/admin/widgets.py
index 1f438daf2d..aeb74773ac 100644
--- a/django/contrib/admin/widgets.py
+++ b/django/contrib/admin/widgets.py
@@ -428,7 +428,9 @@ class AutocompleteMixin:
}
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)
+ remote_model_opts = self.field.remote_field.model._meta
+ to_field_name = getattr(self.field.remote_field, 'field_name', remote_model_opts.pk.attname)
+ to_field_name = remote_model_opts.get_field(to_field_name).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})
diff --git a/tests/admin_views/models.py b/tests/admin_views/models.py
index 4ab2bc8eee..284a4cfacb 100644
--- a/tests/admin_views/models.py
+++ b/tests/admin_views/models.py
@@ -338,6 +338,24 @@ class Child(models.Model):
raise ValidationError('invalid')
+class PKChild(models.Model):
+ """
+ Used to check autocomplete to_field resolution when ForeignKey is PK.
+ """
+ parent = models.ForeignKey(Parent, models.CASCADE, primary_key=True)
+ name = models.CharField(max_length=128)
+
+ class Meta:
+ ordering = ['parent']
+
+ def __str__(self):
+ return self.name
+
+
+class Toy(models.Model):
+ child = models.ForeignKey(PKChild, models.CASCADE)
+
+
class EmptyModel(models.Model):
def __str__(self):
return "Primary key = %s" % self.id
@@ -615,12 +633,26 @@ class Song(models.Model):
class Employee(Person):
code = models.CharField(max_length=20)
+ class Meta:
+ ordering = ['name']
+
class WorkHour(models.Model):
datum = models.DateField()
employee = models.ForeignKey(Employee, models.CASCADE)
+class Manager(Employee):
+ """
+ A multi-layer MTI child.
+ """
+ pass
+
+
+class Bonus(models.Model):
+ recipient = models.ForeignKey(Manager, on_delete=models.CASCADE)
+
+
class Question(models.Model):
big_id = models.BigAutoField(primary_key=True)
question = models.CharField(max_length=20)
diff --git a/tests/admin_views/test_autocomplete_view.py b/tests/admin_views/test_autocomplete_view.py
index c57fb106ed..aa978f7a83 100644
--- a/tests/admin_views/test_autocomplete_view.py
+++ b/tests/admin_views/test_autocomplete_view.py
@@ -12,7 +12,10 @@ from django.test import RequestFactory, override_settings
from django.urls import reverse, reverse_lazy
from .admin import AnswerAdmin, QuestionAdmin
-from .models import Answer, Author, Authorship, Book, Question
+from .models import (
+ Answer, Author, Authorship, Bonus, Book, Employee, Manager, Parent,
+ PKChild, Question, Toy, WorkHour,
+)
from .tests import AdminViewBasicTestCase
PAGINATOR_SIZE = AutocompleteJsonView.paginate_by
@@ -37,6 +40,12 @@ site.register(Question, QuestionAdmin)
site.register(Answer, AnswerAdmin)
site.register(Author, AuthorAdmin)
site.register(Book, BookAdmin)
+site.register(Employee, search_fields=['name'])
+site.register(WorkHour, autocomplete_fields=['employee'])
+site.register(Manager, search_fields=['name'])
+site.register(Bonus, autocomplete_fields=['recipient'])
+site.register(PKChild, search_fields=['name'])
+site.register(Toy, autocomplete_fields=['child'])
@contextmanager
@@ -118,6 +127,51 @@ class AutocompleteJsonViewTests(AdminViewBasicTestCase):
'pagination': {'more': False},
})
+ def test_to_field_resolution_with_mti(self):
+ """
+ to_field resolution should correctly resolve for target models using
+ MTI. Tests for single and multi-level cases.
+ """
+ tests = [
+ (Employee, WorkHour, 'employee'),
+ (Manager, Bonus, 'recipient'),
+ ]
+ for Target, Remote, related_name in tests:
+ with self.subTest(target_model=Target, remote_model=Remote, related_name=related_name):
+ o = Target.objects.create(name="Frida Kahlo", gender=2, code="painter", alive=False)
+ opts = {
+ 'app_label': Remote._meta.app_label,
+ 'model_name': Remote._meta.model_name,
+ 'field_name': related_name,
+ }
+ request = self.factory.get(self.url, {'term': 'frida', **opts})
+ request.user = self.superuser
+ response = AutocompleteJsonView.as_view(**self.as_view_args)(request)
+ self.assertEqual(response.status_code, 200)
+ data = json.loads(response.content.decode('utf-8'))
+ self.assertEqual(data, {
+ 'results': [{'id': str(o.pk), 'text': o.name}],
+ 'pagination': {'more': False},
+ })
+
+ def test_to_field_resolution_with_fk_pk(self):
+ p = Parent.objects.create(name="Bertie")
+ c = PKChild.objects.create(parent=p, name="Anna")
+ opts = {
+ 'app_label': Toy._meta.app_label,
+ 'model_name': Toy._meta.model_name,
+ 'field_name': 'child',
+ }
+ request = self.factory.get(self.url, {'term': 'anna', **opts})
+ request.user = self.superuser
+ response = AutocompleteJsonView.as_view(**self.as_view_args)(request)
+ self.assertEqual(response.status_code, 200)
+ data = json.loads(response.content.decode('utf-8'))
+ self.assertEqual(data, {
+ 'results': [{'id': str(c.pk), 'text': c.name}],
+ 'pagination': {'more': False},
+ })
+
def test_field_does_not_exist(self):
request = self.factory.get(self.url, {'term': 'is', **self.opts, 'field_name': 'does_not_exist'})
request.user = self.superuser
diff --git a/tests/admin_widgets/models.py b/tests/admin_widgets/models.py
index 85f71749fd..85ba6c4f36 100644
--- a/tests/admin_widgets/models.py
+++ b/tests/admin_widgets/models.py
@@ -18,7 +18,11 @@ class Member(models.Model):
return self.name
-class Band(models.Model):
+class Artist(models.Model):
+ pass
+
+
+class Band(Artist):
uuid = models.UUIDField(unique=True, default=uuid.uuid4)
name = models.CharField(max_length=100)
style = models.CharField(max_length=20)
@@ -47,6 +51,25 @@ class Album(models.Model):
return self.name
+class ReleaseEvent(models.Model):
+ """
+ Used to check that autocomplete widget correctly resolves attname for FK as
+ PK example.
+ """
+ album = models.ForeignKey(Album, models.CASCADE, primary_key=True)
+ name = models.CharField(max_length=100)
+
+ class Meta:
+ ordering = ['name']
+
+ def __str__(self):
+ return self.name
+
+
+class VideoStream(models.Model):
+ release_event = models.ForeignKey(ReleaseEvent, models.CASCADE)
+
+
class HiddenInventoryManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(hidden=False)
diff --git a/tests/admin_widgets/test_autocomplete_widget.py b/tests/admin_widgets/test_autocomplete_widget.py
index d8ee7e9f3a..279acfe615 100644
--- a/tests/admin_widgets/test_autocomplete_widget.py
+++ b/tests/admin_widgets/test_autocomplete_widget.py
@@ -5,7 +5,7 @@ from django.forms import ModelChoiceField
from django.test import TestCase, override_settings
from django.utils import translation
-from .models import Album, Band
+from .models import Album, Band, ReleaseEvent, VideoStream
class AlbumForm(forms.ModelForm):
@@ -41,6 +41,18 @@ class RequiredBandForm(forms.Form):
)
+class VideoStreamForm(forms.ModelForm):
+ class Meta:
+ model = VideoStream
+ fields = ['release_event']
+ widgets = {
+ 'release_event': AutocompleteSelect(
+ VideoStream._meta.get_field('release_event'),
+ admin.site,
+ ),
+ }
+
+
@override_settings(ROOT_URLCONF='admin_widgets.urls')
class AutocompleteMixinTests(TestCase):
empty_option = ''
@@ -114,6 +126,15 @@ class AutocompleteMixinTests(TestCase):
output = form.as_table()
self.assertNotIn(self.empty_option, output)
+ def test_render_options_fk_as_pk(self):
+ beatles = Band.objects.create(name='The Beatles', style='rock')
+ rubber_soul = Album.objects.create(name='Rubber Soul', band=beatles)
+ release_event = ReleaseEvent.objects.create(name='Test Target', album=rubber_soul)
+ form = VideoStreamForm(initial={'release_event': release_event.pk})
+ output = form.as_table()
+ selected_option = '' % release_event.pk
+ self.assertIn(selected_option, output)
+
def test_media(self):
rel = Album._meta.get_field('band').remote_field
base_files = (
diff --git a/tests/admin_widgets/tests.py b/tests/admin_widgets/tests.py
index 3c41d9cdfa..f701f1abff 100644
--- a/tests/admin_widgets/tests.py
+++ b/tests/admin_widgets/tests.py
@@ -24,7 +24,7 @@ from django.utils import translation
from .models import (
Advisor, Album, Band, Bee, Car, Company, Event, Honeycomb, Individual,
Inventory, Member, MyFileField, Profile, School, Student,
- UnsafeLimitChoicesTo,
+ UnsafeLimitChoicesTo, VideoStream,
)
from .widgetadmin import site as widget_admin_site
@@ -624,7 +624,17 @@ class ForeignKeyRawIdWidgetTest(TestCase):
self.assertHTMLEqual(
w.render('test', None),
'\n'
- ''
+ )
+
+ def test_render_fk_as_pk_model(self):
+ rel = VideoStream._meta.get_field('release_event').remote_field
+ w = widgets.ForeignKeyRawIdWidget(rel, widget_admin_site)
+ self.assertHTMLEqual(
+ w.render('test', None),
+ '\n'
+ ''
)
diff --git a/tests/admin_widgets/widgetadmin.py b/tests/admin_widgets/widgetadmin.py
index a471a362fb..a025bc82a7 100644
--- a/tests/admin_widgets/widgetadmin.py
+++ b/tests/admin_widgets/widgetadmin.py
@@ -2,7 +2,7 @@ from django.contrib import admin
from .models import (
Advisor, Album, Band, Bee, Car, CarTire, Event, Inventory, Member, Profile,
- School, User,
+ ReleaseEvent, School, User, VideoStream,
)
@@ -47,6 +47,8 @@ site.register(Member)
site.register(Band)
site.register(Event, EventAdmin)
site.register(Album, AlbumAdmin)
+site.register(ReleaseEvent, search_fields=['name'])
+site.register(VideoStream, autocomplete_fields=['release_event'])
site.register(Inventory)