diff options
| author | Artyom Kotovskiy <mrartem1927@gmail.com> | 2025-04-20 18:17:47 -0400 |
|---|---|---|
| committer | Jacob Walls <jacobtylerwalls@gmail.com> | 2026-02-27 07:45:21 -0500 |
| commit | a040f555069971192220122555f187530d679d53 (patch) | |
| tree | ab59214658c5ccb71de7df63dd6da0237b705a00 | |
| parent | 187a789f99ecbc708de517c6b54d480b68ba59fe (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.py | 6 | ||||
| -rw-r--r-- | django/contrib/auth/management/__init__.py | 122 | ||||
| -rw-r--r-- | docs/releases/6.1.txt | 3 | ||||
| -rw-r--r-- | docs/topics/auth/default.txt | 12 | ||||
| -rw-r--r-- | tests/auth_tests/operations_migrations/0001_initial.py | 14 | ||||
| -rw-r--r-- | tests/auth_tests/operations_migrations/0002_rename_oldmodel_to_newmodel.py | 14 | ||||
| -rw-r--r-- | tests/auth_tests/operations_migrations/__init__.py | 0 | ||||
| -rw-r--r-- | tests/auth_tests/test_management.py | 277 |
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.""" |
