Repository: django/django

Issue:
# Fixed #36839 -- Warned when model renames encounter conflicts from stale ContentTypes.

#### Trac ticket number

ticket-36839

#### Branch description

Fixed #36839 -- Warn when ContentType rename conflicts with stale entries.

When renaming a model, Django automatically injects a `RenameContentType` operation to update the content type. If a stale content type with the target name already exists, the operation catches the `IntegrityError` and silently reverts without notifying the user. This results in the migration appearing successful while the content type rename actually failed, leading to broken GenericForeignKey references and stale data.

This change adds a `RuntimeWarning` when the rename conflict occurs, informing users to run the `remove_stale_contenttypes` management command to resolve the issue. The existing fallback behavior is preserved—no data is deleted or overwritten automatically.

#### AI Assistance Disclosure (REQUIRED)

- [ ] **No AI tools were used** in preparing this PR.
- [x] **If AI tools were used**, I have disclosed which ones, and fully reviewed and verified their output.

Claude Sonnet 4.5 was used for assistance with adding tests. All output was thoroughly reviewed and verified by me.

#### 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.
- [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.
- [ ] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.

Implementation patch:
diff --git a/django/contrib/contenttypes/management/__init__.py b/django/contrib/contenttypes/management/__init__.py
index 929e44f390db..55b08870d83e 100644
--- a/django/contrib/contenttypes/management/__init__.py
+++ b/django/contrib/contenttypes/management/__init__.py
@@ -1,3 +1,5 @@
+import warnings
+
 from django.apps import apps as global_apps
 from django.db import DEFAULT_DB_ALIAS, IntegrityError, migrations, router, transaction
 
@@ -28,8 +30,14 @@ def _rename(self, apps, schema_editor, old_model, new_model):
                     content_type.save(using=db, update_fields={"model"})
             except IntegrityError:
                 # Gracefully fallback if a stale content type causes a
-                # conflict as remove_stale_contenttypes will take care of
-                # asking the user what should be done next.
+                # conflict. Warn the user so they can run the
+                # remove_stale_contenttypes management command.
+                warnings.warn(
+                    f"Could not rename content type '{self.app_label}.{old_model}' "
+                    f"to '{new_model}' due to an existing conflicting content type. "
+                    "Run 'remove_stale_contenttypes' to clean up stale entries.",
+                    RuntimeWarning,
+                )
                 content_type.model = old_model
             else:
                 # Clear the cache as the `get_by_natural_key()` call will cache


Test patch:
diff --git a/tests/contenttypes_tests/test_operations.py b/tests/contenttypes_tests/test_operations.py
index d44648d9fe6e..9f6506640ad6 100644
--- a/tests/contenttypes_tests/test_operations.py
+++ b/tests/contenttypes_tests/test_operations.py
@@ -154,13 +154,19 @@ def test_missing_content_type_rename_ignore(self):
     def test_content_type_rename_conflict(self):
         ContentType.objects.create(app_label="contenttypes_tests", model="foo")
         ContentType.objects.create(app_label="contenttypes_tests", model="renamedfoo")
-        call_command(
-            "migrate",
-            "contenttypes_tests",
-            database="default",
-            interactive=False,
-            verbosity=0,
-        )
+        msg = (
+            "Could not rename content type 'contenttypes_tests.foo' to "
+            "'renamedfoo' due to an existing conflicting content type. "
+            "Run 'remove_stale_contenttypes' to clean up stale entries."
+        )
+        with self.assertWarnsMessage(RuntimeWarning, msg):
+            call_command(
+                "migrate",
+                "contenttypes_tests",
+                database="default",
+                interactive=False,
+                verbosity=0,
+            )
         self.assertTrue(
             ContentType.objects.filter(
                 app_label="contenttypes_tests", model="foo"
@@ -171,14 +177,20 @@ def test_content_type_rename_conflict(self):
                 app_label="contenttypes_tests", model="renamedfoo"
             ).exists()
         )
-        call_command(
-            "migrate",
-            "contenttypes_tests",
-            "zero",
-            database="default",
-            interactive=False,
-            verbosity=0,
-        )
+        msg = (
+            "Could not rename content type 'contenttypes_tests.renamedfoo' to "
+            "'foo' due to an existing conflicting content type. "
+            "Run 'remove_stale_contenttypes' to clean up stale entries."
+        )
+        with self.assertWarnsMessage(RuntimeWarning, msg):
+            call_command(
+                "migrate",
+                "contenttypes_tests",
+                "zero",
+                database="default",
+                interactive=False,
+                verbosity=0,
+            )
         self.assertTrue(
             ContentType.objects.filter(
                 app_label="contenttypes_tests", model="foo"