summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorArtyom Kotovskiy <mrartem1927@gmail.com>2025-04-20 18:17:47 -0400
committerJacob Walls <jacobtylerwalls@gmail.com>2026-02-27 07:45:21 -0500
commita040f555069971192220122555f187530d679d53 (patch)
treeab59214658c5ccb71de7df63dd6da0237b705a00
parent187a789f99ecbc708de517c6b54d480b68ba59fe (diff)
Fixed #27489 -- Renamed permissions upon model renaming in migrations.
Co-authored-by: Jacob Walls <jacobtylerwalls@gmail.com>
-rw-r--r--django/contrib/auth/apps.py6
-rw-r--r--django/contrib/auth/management/__init__.py122
-rw-r--r--docs/releases/6.1.txt3
-rw-r--r--docs/topics/auth/default.txt12
-rw-r--r--tests/auth_tests/operations_migrations/0001_initial.py14
-rw-r--r--tests/auth_tests/operations_migrations/0002_rename_oldmodel_to_newmodel.py14
-rw-r--r--tests/auth_tests/operations_migrations/__init__.py0
-rw-r--r--tests/auth_tests/test_management.py277
8 files changed, 444 insertions, 4 deletions
diff --git a/django/contrib/auth/apps.py b/django/contrib/auth/apps.py
index ad6f816809..d25d67fe22 100644
--- a/django/contrib/auth/apps.py
+++ b/django/contrib/auth/apps.py
@@ -6,7 +6,7 @@ from django.utils.translation import gettext_lazy as _
from . import get_user_model
from .checks import check_middleware, check_models_permissions, check_user_model
-from .management import create_permissions
+from .management import create_permissions, rename_permissions_after_model_rename
from .signals import user_logged_in
@@ -17,6 +17,10 @@ class AuthConfig(AppConfig):
def ready(self):
post_migrate.connect(
+ rename_permissions_after_model_rename,
+ dispatch_uid="django.contrib.auth.management.rename_permissions",
+ )
+ post_migrate.connect(
create_permissions,
dispatch_uid="django.contrib.auth.management.create_permissions",
)
diff --git a/django/contrib/auth/management/__init__.py b/django/contrib/auth/management/__init__.py
index e816357b2b..a91fbab396 100644
--- a/django/contrib/auth/management/__init__.py
+++ b/django/contrib/auth/management/__init__.py
@@ -1,15 +1,18 @@
"""
-Creates permissions for all installed apps that need permissions.
+Creates permissions for all installed apps that need permissions, and renames
+them on model renames.
"""
import getpass
+import sys
import unicodedata
from django.apps import apps as global_apps
from django.contrib.auth import get_permission_codename
from django.contrib.contenttypes.management import create_contenttypes
from django.core import exceptions
-from django.db import DEFAULT_DB_ALIAS, router
+from django.core.management.color import color_style
+from django.db import DEFAULT_DB_ALIAS, migrations, router, transaction
def _get_all_permissions(opts):
@@ -108,6 +111,121 @@ def create_permissions(
print("Adding permission '%s'" % perm)
+def _get_permission_metadata(apps, app_label, model_name):
+ try:
+ model = apps.get_model(app_label, model_name)
+ except LookupError:
+ # Model does not exist in this migration state, e.g. zero.
+ Permission = apps.get_model("auth", "Permission")
+ return Permission._meta.default_permissions, model_name
+ return (
+ model._meta.default_permissions,
+ model._meta.verbose_name_raw,
+ )
+
+
+def rename_permissions_after_model_rename(
+ app_config,
+ verbosity=2,
+ plan=None,
+ using=DEFAULT_DB_ALIAS,
+ apps=global_apps,
+ stdout=sys.stdout,
+ **kwargs,
+):
+ if not app_config.models_module:
+ return
+
+ # This handler is connected to the global post_migrate signal, which is
+ # emitted for *all* apps — including test configurations where
+ # django.contrib.auth is NOT installed.
+ try:
+ Permission = apps.get_model("auth", "Permission")
+ except LookupError:
+ return
+ if not router.allow_migrate_model(using, Permission):
+ return
+
+ db = using or router.db_for_write(Permission)
+
+ app_label = app_config.label
+
+ # Collect (from_model, to_model) pairs
+ renames = [
+ (op.new_name, op.old_name) if backward else (op.old_name, op.new_name)
+ for migration, backward in (plan or [])
+ for op in migration.operations
+ if isinstance(op, migrations.RenameModel)
+ and migration.app_label == app_config.label
+ ]
+
+ if not renames:
+ return
+
+ planned = []
+ conflicts = []
+
+ for old_name, new_name in renames:
+ old_suffix = f"_{old_name.lower()}"
+ new_suffix = f"_{new_name.lower()}"
+
+ actions, verbose_name_raw = _get_permission_metadata(apps, app_label, new_name)
+ perms = Permission.objects.using(db).filter(
+ content_type__app_label=app_label,
+ codename__in=[f"{action}{old_suffix}" for action in actions],
+ )
+
+ for perm in perms:
+ for action in actions:
+ if not perm.codename.startswith(action + "_"):
+ continue
+
+ old_codename = perm.codename
+ new_codename = f"{action}{new_suffix}"
+ new_name_str = f"Can {action} {verbose_name_raw}"
+
+ planned.append((perm, old_codename, new_codename, new_name_str))
+
+ existing = {
+ p.codename
+ for p in Permission.objects.using(db).filter(
+ content_type__app_label=app_label,
+ codename__in=[new for _, _, new, _ in planned],
+ )
+ }
+
+ # Look for conflicts
+ for perm, old, new, _ in planned:
+ if new in existing and perm.codename != new:
+ conflicts.append((perm.pk, old, new))
+
+ # Raise error if conflicts found
+ if conflicts:
+ if verbosity:
+ style = color_style()
+ for pk, old, new in conflicts:
+ msg = (
+ f"Failed to rename permission {pk} from '{old}' to '{new}'. "
+ f"Please resolve the conflict manually.\n"
+ )
+ stdout.write(style.WARNING(msg))
+ error_message = f"{len(conflicts)} permission rename conflict(s) detected."
+ raise RuntimeError(error_message)
+
+ with transaction.atomic(using=db):
+ for perm, _, new_codename, new_name_str in planned:
+ perm.codename = new_codename
+ perm.name = new_name_str
+ perm.save(update_fields={"codename", "name"}, using=db)
+
+ for _, from_codename, to_codename, _ in planned:
+ if verbosity >= 2:
+ stdout.write(
+ f"Renamed permission(s): "
+ f"{app_label}.{from_codename} → {to_codename}\n"
+ )
+
+
def get_system_username():
"""
Return the current system user's username, or an empty string if the
diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt
index ef6ae1d424..d10225dfdf 100644
--- a/docs/releases/6.1.txt
+++ b/docs/releases/6.1.txt
@@ -123,6 +123,9 @@ Minor features
* The default iteration count for the PBKDF2 password hasher is increased from
1,200,000 to 1,500,000.
+* :attr:`.Permission.name` and :attr:`.Permission.codename` values are now
+ renamed when renaming models via a migration.
+
:mod:`django.contrib.contenttypes`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/docs/topics/auth/default.txt b/docs/topics/auth/default.txt
index f9d216e1df..77888febb3 100644
--- a/docs/topics/auth/default.txt
+++ b/docs/topics/auth/default.txt
@@ -219,6 +219,18 @@ permissions for new models each time you run :djadmin:`manage.py migrate
<migrate>` (the function that creates permissions is connected to the
:data:`~django.db.models.signals.post_migrate` signal).
+When a model is renamed in an installed application, Django automatically
+updates the associated default permissions to match the new model name when
+you run :djadmin:`manage.py migrate <migrate>`.
+
+If a permission with the new codename already exists
+(for example, due to a leftover permission from a previous migration),
+Django raises an error with next steps.
+
+.. versionchanged:: 6.1
+
+ Updating permissions when models are renamed was added.
+
Assuming you have an application with an
:attr:`~django.db.models.Options.app_label` ``foo`` and a model named ``Bar``,
to test for basic permissions you should use:
diff --git a/tests/auth_tests/operations_migrations/0001_initial.py b/tests/auth_tests/operations_migrations/0001_initial.py
new file mode 100644
index 0000000000..49a475653c
--- /dev/null
+++ b/tests/auth_tests/operations_migrations/0001_initial.py
@@ -0,0 +1,14 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ operations = [
+ migrations.CreateModel(
+ name="OldModel",
+ fields=[
+ ("id", models.AutoField(primary_key=True)),
+ ],
+ ),
+ ]
diff --git a/tests/auth_tests/operations_migrations/0002_rename_oldmodel_to_newmodel.py b/tests/auth_tests/operations_migrations/0002_rename_oldmodel_to_newmodel.py
new file mode 100644
index 0000000000..a23761e8fd
--- /dev/null
+++ b/tests/auth_tests/operations_migrations/0002_rename_oldmodel_to_newmodel.py
@@ -0,0 +1,14 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("auth_tests", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.RenameModel(
+ old_name="OldModel",
+ new_name="NewModel",
+ ),
+ ]
diff --git a/tests/auth_tests/operations_migrations/__init__.py b/tests/auth_tests/operations_migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/tests/auth_tests/operations_migrations/__init__.py
diff --git a/tests/auth_tests/test_management.py b/tests/auth_tests/test_management.py
index f5027da4e5..985dfd79f8 100644
--- a/tests/auth_tests/test_management.py
+++ b/tests/auth_tests/test_management.py
@@ -7,8 +7,12 @@ from io import StringIO
from unittest import mock
from django.apps import apps
+from django.conf import settings
from django.contrib.auth import get_permission_codename, management
-from django.contrib.auth.management import create_permissions, get_default_username
+from django.contrib.auth.management import (
+ create_permissions,
+ get_default_username,
+)
from django.contrib.auth.management.commands import changepassword, createsuperuser
from django.contrib.auth.models import Group, Permission, User
from django.contrib.contenttypes.models import ContentType
@@ -16,6 +20,7 @@ from django.core.management import call_command
from django.core.management.base import CommandError
from django.db import migrations
from django.test import TestCase, override_settings
+from django.test.testcases import TransactionTestCase
from django.utils.translation import gettext_lazy as _
from .models import (
@@ -1528,6 +1533,276 @@ class CreatePermissionsTests(TestCase):
)
+@override_settings(
+ MIGRATION_MODULES=dict(
+ settings.MIGRATION_MODULES,
+ auth_tests="auth_tests.operations_migrations",
+ ),
+)
+class PermissionRenameOperationsTests(TransactionTestCase):
+ available_apps = [
+ "django.contrib.contenttypes",
+ "django.contrib.auth",
+ "auth_tests",
+ ]
+
+ databases = {"default", "other"}
+
+ def setUp(self):
+ self.stdout = StringIO()
+ self.addCleanup(self.stdout.close)
+
+ def test_permission_rename(self):
+ # Create initial content type and permissions for OldModel.
+ call_command("migrate", "auth_tests", "0001", verbosity=0)
+ # Apply the migration that renames OldModel to NewModel.
+ call_command("migrate", "auth_tests", "0002", verbosity=0)
+
+ actions = ContentType._meta.default_permissions
+
+ for action in actions:
+ self.assertFalse(
+ Permission.objects.filter(codename=f"{action}_oldmodel").exists()
+ )
+ self.assertTrue(
+ Permission.objects.filter(codename=f"{action}_newmodel").exists()
+ )
+
+ # Unapply that migration, renaming NewModel back to OldModel.
+ call_command(
+ "migrate",
+ "auth_tests",
+ "0001",
+ database="default",
+ interactive=False,
+ verbosity=0,
+ )
+
+ for action in actions:
+ self.assertTrue(
+ Permission.objects.filter(codename=f"{action}_oldmodel").exists()
+ )
+ self.assertFalse(
+ Permission.objects.filter(codename=f"{action}_newmodel").exists()
+ )
+
+ call_command(
+ "migrate",
+ "auth_tests",
+ "zero",
+ database="default",
+ interactive=False,
+ verbosity=0,
+ )
+
+ @mock.patch(
+ "django.db.router.allow_migrate_model",
+ return_value=False,
+ )
+ def test_rename_skipped_if_router_disallows(self, _):
+ # Create them manually, auto permissions won't create
+ # since router disallows
+
+ ct = ContentType.objects.create(app_label="auth_tests", model="oldmodel")
+ Permission.objects.create(
+ codename="change_oldmodel",
+ name="Can change old model",
+ content_type=ct,
+ )
+
+ # Create initial content type and permissions for OldModel.
+ call_command("migrate", "auth_tests", "0001", verbosity=0)
+ # Apply the migration that renames OldModel to NewModel.
+ call_command("migrate", "auth_tests", "0002", verbosity=0)
+
+ self.assertTrue(Permission.objects.filter(codename="change_oldmodel").exists())
+ self.assertFalse(Permission.objects.filter(codename="change_newmodel").exists())
+
+ call_command(
+ "migrate",
+ "auth_tests",
+ "zero",
+ database="default",
+ interactive=False,
+ verbosity=0,
+ )
+
+ def test_rename_backward_without_permissions(self):
+ """
+ Backward migration handles the case where permissions
+ don't exist (e.g., they were manually deleted).
+ """
+ # Create initial content type and permissions for OldModel.
+ call_command("migrate", "auth_tests", "0001", verbosity=0)
+ # Apply the migration that renames OldModel to NewModel.
+ call_command("migrate", "auth_tests", "0002", verbosity=0)
+
+ Permission.objects.filter(content_type__app_label="auth_tests").delete()
+
+ call_command(
+ "migrate",
+ "auth_tests",
+ "zero",
+ database="default",
+ interactive=False,
+ verbosity=0,
+ )
+ self.assertFalse(
+ Permission.objects.filter(
+ codename__in=["change_oldmodel", "change_newmodel"]
+ ).exists()
+ )
+
+ call_command(
+ "migrate",
+ "auth_tests",
+ "zero",
+ database="default",
+ interactive=False,
+ verbosity=0,
+ )
+
+ def test_rename_permission_conflict(self):
+ # Create initial content type and permissions for OldModel.
+ call_command("migrate", "auth_tests", "0001", verbosity=0)
+
+ ct = ContentType.objects.get(app_label="auth_tests", model="oldmodel")
+ old_perm = Permission.objects.get(
+ codename="change_oldmodel",
+ name="Can change old model",
+ )
+ conflicting_perm = Permission.objects.create(
+ codename="change_newmodel",
+ name="Can change new model",
+ content_type=ct,
+ )
+
+ with self.assertRaises(RuntimeError):
+ # Apply the migration that renames OldModel to NewModel.
+ call_command(
+ "migrate",
+ "auth_tests",
+ "0002",
+ database="default",
+ interactive=False,
+ stdout=self.stdout,
+ )
+
+ command_output = self.stdout.getvalue()
+
+ self.assertIn(
+ f"Failed to rename permission {old_perm.pk} "
+ f"from '{old_perm.codename}' to '{conflicting_perm.codename}'. "
+ f"Please resolve the conflict manually.",
+ command_output,
+ )
+
+ self.assertTrue(Permission.objects.filter(codename="change_oldmodel").exists())
+
+ with self.assertRaises(RuntimeError):
+ call_command(
+ "migrate",
+ "auth_tests",
+ "zero",
+ database="default",
+ interactive=False,
+ verbosity=0,
+ )
+
+ def test_permission_rename_respects_other_db(self):
+ # Create initial content type and permissions for OldModel.
+ call_command("migrate", "auth_tests", "0001", verbosity=0)
+
+ permission = Permission.objects.using("default").get(
+ codename="add_oldmodel",
+ name="Can add old model",
+ )
+
+ # Create initial content type and permissions for OldModel.
+ call_command("migrate", "auth_tests", "0001", verbosity=0, database="other")
+ # Apply the migration that renames OldModel to NewModel.
+ call_command("migrate", "auth_tests", "0002", verbosity=0, database="other")
+
+ permission.refresh_from_db()
+ self.assertEqual(permission.codename, "add_oldmodel")
+ self.assertFalse(
+ Permission.objects.using("other").filter(codename="add_oldmodel").exists()
+ )
+ self.assertTrue(
+ Permission.objects.using("other").filter(codename="add_newmodel").exists()
+ )
+
+ call_command(
+ "migrate",
+ "auth_tests",
+ "zero",
+ database="other",
+ interactive=False,
+ verbosity=0,
+ )
+ call_command(
+ "migrate",
+ "auth_tests",
+ "zero",
+ database="default",
+ interactive=False,
+ verbosity=0,
+ )
+
+ def test_verbosity_prints(self):
+ # Create initial content type and permissions for OldModel.
+ call_command("migrate", "auth_tests", "0001", verbosity=0)
+ # Apply the migration that renames OldModel to NewModel.
+ call_command("migrate", "auth_tests", "0002", verbosity=2, stdout=self.stdout)
+
+ command_output = self.stdout.getvalue()
+ self.assertIn(
+ "Renamed permission(s): auth_tests.add_oldmodel → add_newmodel",
+ command_output,
+ )
+ self.assertIn(
+ "Renamed permission(s): auth_tests.change_oldmodel → change_newmodel",
+ command_output,
+ )
+ self.assertIn(
+ "Renamed permission(s): auth_tests.view_oldmodel → view_newmodel",
+ command_output,
+ )
+ self.assertIn(
+ "Renamed permission(s): auth_tests.delete_oldmodel → delete_newmodel",
+ command_output,
+ )
+
+ call_command("migrate", "auth_tests", "0001", verbosity=2, stdout=self.stdout)
+
+ command_output = self.stdout.getvalue()
+ self.assertIn(
+ "Renamed permission(s): auth_tests.add_newmodel → add_oldmodel",
+ command_output,
+ )
+ self.assertIn(
+ "Renamed permission(s): auth_tests.change_newmodel → change_oldmodel",
+ command_output,
+ )
+ self.assertIn(
+ "Renamed permission(s): auth_tests.view_newmodel → view_oldmodel",
+ command_output,
+ )
+ self.assertIn(
+ "Renamed permission(s): auth_tests.delete_newmodel → delete_oldmodel",
+ command_output,
+ )
+
+ call_command(
+ "migrate",
+ "auth_tests",
+ "zero",
+ database="default",
+ interactive=False,
+ verbosity=0,
+ )
+
+
class DefaultDBRouter:
"""Route all writes to default."""