diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 6c202c8e613c..9a32d4e0d4f6 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -414,11 +414,35 @@ def get_ordering(self, request): """ return self.ordering or () # otherwise we might try to *None, which is bad ;) + def _get_readonly_manual_pk_fields(self, obj): + """ + Returns a list of primary key field names that should be readonly if: + - we're editing an existing object, + - the PK was manually defined (not auto-created), + - and it's not already in self.readonly_fields. + Supports both single and composite PKs. + """ + if not obj or hasattr(self, "parent_model"): + return [] + + readonly = [] + pk_fields = self.model._meta.pk_fields + + for pk in pk_fields: + if ( + pk.editable + and not pk.auto_created + and pk.name not in self.readonly_fields + ): + readonly.append(pk.name) + + return readonly + def get_readonly_fields(self, request, obj=None): """ Hook for specifying custom readonly fields. """ - return self.readonly_fields + return list(self.readonly_fields) + self._get_readonly_manual_pk_fields(obj) def get_prepopulated_fields(self, request, obj=None): """ @@ -2549,6 +2573,18 @@ def has_view_permission(self, request, obj=None): return self._has_any_perms_for_target_model(request, ["view", "change"]) return super().has_view_permission(request) + def get_readonly_fields(self, request, obj=None): + """ + Make manually specified (non-auto-created) primary key fields readonly + when editing an existing inline object. + """ + readonly = list(self.readonly_fields) + if obj: + for pk in self.model._meta.pk_fields: + if pk.editable and not pk.auto_created and pk.name not in readonly: + readonly.append(pk.name) + return readonly + class StackedInline(InlineModelAdmin): template = "admin/edit_inline/stacked.html" diff --git a/tests/admin_custom_urls/tests.py b/tests/admin_custom_urls/tests.py index d401976ebb40..6eda92b33b71 100644 --- a/tests/admin_custom_urls/tests.py +++ b/tests/admin_custom_urls/tests.py @@ -95,7 +95,11 @@ def test_admin_URLs_no_clash(self): ) response = self.client.get(url) self.assertContains(response, "Change action") - self.assertContains(response, 'value="path/to/html/document.html"') + self.assertContains( + response, + '
path/to/html/document.html
', + html=True, + ) def test_post_save_add_redirect(self): """ diff --git a/tests/admin_inlines/tests.py b/tests/admin_inlines/tests.py index 0b7308995025..bc0978138875 100644 --- a/tests/admin_inlines/tests.py +++ b/tests/admin_inlines/tests.py @@ -19,6 +19,7 @@ Child, ChildModel1, ChildModel2, + EditablePKBook, ExtraTerrestrial, Fashionista, FootNote, @@ -708,6 +709,21 @@ def test_inline_editable_pk(self): count=1, ) + def test_inline_manual_pk_is_readonly_when_editing(self): + author = Author.objects.create(name="Jane Austen") + EditablePKBook.objects.create( + author=author, manual_pk=101, title="Pride and Prejudice" + ) + + response = self.client.get( + reverse("admin:admin_inlines_author_change", args=[author.pk]) + ) + + self.assertContains( + response, 'name="editablepkbook_set-0-manual_pk"', html=False + ) + self.assertContains(response, "readonly", html=False) + def test_stacked_inline_edit_form_contains_has_original_class(self): holder = Holder.objects.create(dummy=1) holder.inner_set.create(dummy=1)