Repository: django/django

Issue:
# Fixed #36921 -- Fixed KeyError when adding inline instances of models not registered with admin.

#### Trac ticket number
ticket-36921

#### Branch description
Added an if statement to avoid key error if model not registered in admin.

Note this bug was introduced in another pull request I worked on [here](https://github.com/django/django/pull/18934/).

I added the example Jacob shared to the same example project that had been used with that issue [here](https://github.com/reergymerej/ticket_13883).

#### AI Assistance Disclosure (REQUIRED)
- [ ] **No AI tools were used** in preparing this PR.
- [x] **If AI tools were used** - Claude Code plugin in VS Code

#### Checklist
- [x] This PR follows the [contribution guidelines](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/submitting-patches/).
- [x] This PR **does not** disclose a security vulnerability (see [vulnerability reporting](https://docs.djangoproject.com/en/stable/internals/security/)).
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [x] I have checked the "Has patch" ticket flag in the Trac system.
- [x] I have added or updated relevant tests.
- [x] I have added or updated relevant docs, including release notes if applicable.
- [x] I have attached screenshots in both light and dark modes for any UI changes.


Implementation patch:
diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
index 9c787d232912..b67b023bd313 100644
--- a/django/contrib/admin/options.py
+++ b/django/contrib/admin/options.py
@@ -1419,7 +1419,7 @@ def response_add(self, request, obj, post_url_continue=None):
 
             # Find the optgroup for the new item, if available
             source_model_name = request.POST.get(SOURCE_MODEL_VAR)
-
+            source_admin = None
             if source_model_name:
                 app_label, model_name = source_model_name.split(".", 1)
                 try:
@@ -1428,21 +1428,23 @@ def response_add(self, request, obj, post_url_continue=None):
                     msg = _('The app "%s" could not be found.') % source_model_name
                     self.message_user(request, msg, messages.ERROR)
                 else:
-                    source_admin = self.admin_site._registry[source_model]
-                    form = source_admin.get_form(request)()
-                    if self.opts.verbose_name_plural in form.fields:
-                        field = form.fields[self.opts.verbose_name_plural]
-                        for option_value, option_label in field.choices:
-                            # Check if this is an optgroup (label is a sequence
-                            # of choices rather than a single string value).
-                            if isinstance(option_label, (list, tuple)):
-                                # It's an optgroup:
-                                # (group_name, [(value, label), ...])
-                                optgroup_label = option_value
-                                for choice_value, choice_display in option_label:
-                                    if choice_display == str(obj):
-                                        popup_response["optgroup"] = str(optgroup_label)
-                                        break
+                    source_admin = self.admin_site._registry.get(source_model)
+
+            if source_admin:
+                form = source_admin.get_form(request)()
+                if self.opts.verbose_name_plural in form.fields:
+                    field = form.fields[self.opts.verbose_name_plural]
+                    for option_value, option_label in field.choices:
+                        # Check if this is an optgroup (label is a sequence
+                        # of choices rather than a single string value).
+                        if isinstance(option_label, (list, tuple)):
+                            # It's an optgroup:
+                            # (group_name, [(value, label), ...])
+                            optgroup_label = option_value
+                            for choice_value, choice_display in option_label:
+                                if choice_display == str(obj):
+                                    popup_response["optgroup"] = str(optgroup_label)
+                                    break
 
             popup_response_data = json.dumps(popup_response)
             return TemplateResponse(


Test patch:
diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py
index 7f35d7c2f455..fa9d9a2dc6f5 100644
--- a/tests/admin_views/tests.py
+++ b/tests/admin_views/tests.py
@@ -589,6 +589,31 @@ def test_popup_add_POST_with_invalid_source_model(self):
         self.assertIn("admin_views.nonexistent", str(messages[0]))
         self.assertIn("could not be found", str(messages[0]))
 
+    def test_popup_add_POST_with_unregistered_source_model(self):
+        """
+        Popup add where source_model is a valid Django model but is not
+        registered in the admin site (e.g. a model only used as an inline)
+        should succeed without raising a KeyError.
+        """
+        post_data = {
+            IS_POPUP_VAR: "1",
+            # Chapter exists as a model but is not registered in site (only
+            # in site6), simulating a model used only as an inline.
+            SOURCE_MODEL_VAR: "admin_views.chapter",
+            "title": "Test Article",
+            "content": "some content",
+            "date_0": "2010-09-10",
+            "date_1": "14:55:39",
+        }
+        response = self.client.post(reverse("admin:admin_views_article_add"), post_data)
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "data-popup-response")
+        # No error messages - unregistered model is silently skipped.
+        messages = list(response.wsgi_request._messages)
+        self.assertEqual(len(messages), 0)
+        # No optgroup in the response.
+        self.assertNotContains(response, "&quot;optgroup&quot;")
+
     def test_basic_edit_POST(self):
         """
         A smoke test to ensure POST on edit_view works.