Skip to content

Commit d2fc87e

Browse files
artirix1927sarahboyce
authored andcommitted
Fixed #27489 -- Renamed permissions upon model renaming in migrations.
1 parent dca8284 commit d2fc87e

File tree

6 files changed

+294
-5
lines changed

6 files changed

+294
-5
lines changed

django/contrib/auth/apps.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
from django.apps import AppConfig
22
from django.core import checks
33
from django.db.models.query_utils import DeferredAttribute
4-
from django.db.models.signals import post_migrate
4+
from django.db.models.signals import post_migrate, pre_migrate
55
from django.utils.translation import gettext_lazy as _
66

77
from . import get_user_model
88
from .checks import check_middleware, check_models_permissions, check_user_model
9-
from .management import create_permissions
9+
from .management import create_permissions, rename_permissions
1010
from .signals import user_logged_in
1111

1212

@@ -20,6 +20,10 @@ def ready(self):
2020
create_permissions,
2121
dispatch_uid="django.contrib.auth.management.create_permissions",
2222
)
23+
pre_migrate.connect(
24+
rename_permissions,
25+
dispatch_uid="django.contrib.auth.management.rename_permissions",
26+
)
2327
last_login_field = getattr(get_user_model(), "last_login", None)
2428
# Register the handler only if UserModel.last_login is a field.
2529
if isinstance(last_login_field, DeferredAttribute):

django/contrib/auth/management/__init__.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
from django.contrib.auth import get_permission_codename
1010
from django.contrib.contenttypes.management import create_contenttypes
1111
from django.core import exceptions
12-
from django.db import DEFAULT_DB_ALIAS, router
12+
from django.db import DEFAULT_DB_ALIAS, migrations, router, transaction
13+
from django.db.utils import IntegrityError
14+
from django.utils.text import camel_case_to_spaces
1315

1416

1517
def _get_all_permissions(opts):
@@ -108,6 +110,87 @@ def create_permissions(
108110
print("Adding permission '%s'" % perm)
109111

110112

113+
class RenamePermission(migrations.RunPython):
114+
def __init__(self, app_label, old_model, new_model):
115+
self.app_label = app_label
116+
self.old_model = old_model
117+
self.new_model = new_model
118+
super(RenamePermission, self).__init__(
119+
self.rename_forward, self.rename_backward
120+
)
121+
122+
def _rename(self, apps, schema_editor, old_model, new_model):
123+
ContentType = apps.get_model("contenttypes", "ContentType")
124+
# Use the live Permission model instead of the frozen one, since frozen
125+
# models do not retain foreign key constraints.
126+
from django.contrib.auth.models import Permission
127+
128+
db = schema_editor.connection.alias
129+
if not router.allow_migrate_model(db, Permission):
130+
return
131+
132+
ctypes = ContentType.objects.filter(
133+
app_label=self.app_label, model__contains=old_model
134+
)
135+
for permission in Permission.objects.filter(
136+
content_type_id__in=ctypes.values("id")
137+
):
138+
prefix = permission.codename.split("_")[0]
139+
default_verbose_name = camel_case_to_spaces(new_model)
140+
141+
new_codename = f"{prefix}_{new_model.lower()}"
142+
new_name = f"Can {prefix} {default_verbose_name}"
143+
144+
if permission.codename != new_codename or permission.name != new_name:
145+
permission.codename = new_codename
146+
permission.name = new_name
147+
try:
148+
with transaction.atomic(using=db):
149+
permission.save(update_fields={"name", "codename"})
150+
except IntegrityError:
151+
pass
152+
153+
def rename_forward(self, apps, schema_editor):
154+
self._rename(apps, schema_editor, self.old_model, self.new_model)
155+
156+
def rename_backward(self, apps, schema_editor):
157+
self._rename(apps, schema_editor, self.new_model, self.old_model)
158+
159+
160+
def rename_permissions(
161+
plan,
162+
verbosity=2,
163+
interactive=True,
164+
using=DEFAULT_DB_ALIAS,
165+
apps=global_apps,
166+
**kwargs,
167+
):
168+
"""
169+
Insert a `RenamePermissionType` operation after every planned `RenameModel`
170+
operation.
171+
"""
172+
try:
173+
Permission = apps.get_model("auth", "Permission")
174+
except LookupError:
175+
return
176+
else:
177+
if not router.allow_migrate_model(using, Permission):
178+
return
179+
180+
for migration, backward in plan:
181+
inserts = []
182+
for index, operation in enumerate(migration.operations):
183+
if isinstance(operation, migrations.RenameModel):
184+
operation = RenamePermission(
185+
migration.app_label,
186+
operation.old_name,
187+
operation.new_name,
188+
)
189+
inserts.append((index + 1, operation))
190+
for inserted, (index, operation) in enumerate(inserts):
191+
migration.operations.insert(inserted + index, operation)
192+
193+
111194
def get_system_username():
112195
"""
113196
Return the current system user's username, or an empty string if the
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
initial = True
6+
7+
operations = [
8+
migrations.CreateModel(
9+
name="OldModel",
10+
fields=[
11+
("id", models.AutoField(primary_key=True)),
12+
],
13+
),
14+
]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from django.db import migrations
2+
3+
4+
class Migration(migrations.Migration):
5+
dependencies = [
6+
("auth_tests", "0001_initial"),
7+
]
8+
9+
operations = [
10+
migrations.RenameModel(
11+
old_name="OldModel",
12+
new_name="NewModel",
13+
),
14+
]

tests/auth_tests/operations_migrations/__init__.py

Whitespace-only changes.

tests/auth_tests/test_management.py

Lines changed: 176 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,21 @@
77
from unittest import mock
88

99
from django.apps import apps
10+
from django.conf import settings
1011
from django.contrib.auth import get_permission_codename, management
11-
from django.contrib.auth.management import create_permissions, get_default_username
12+
from django.contrib.auth.management import (
13+
RenamePermission,
14+
create_permissions,
15+
get_default_username,
16+
)
1217
from django.contrib.auth.management.commands import changepassword, createsuperuser
1318
from django.contrib.auth.models import Group, Permission, User
1419
from django.contrib.contenttypes.models import ContentType
1520
from django.core.management import call_command
1621
from django.core.management.base import CommandError
17-
from django.db import migrations
22+
from django.db import migrations, models
1823
from django.test import TestCase, override_settings
24+
from django.test.testcases import TransactionTestCase
1925
from django.utils.translation import gettext_lazy as _
2026

2127
from .models import (
@@ -1528,6 +1534,174 @@ def test_permission_with_proxy_content_type_created(self):
15281534
)
15291535

15301536

1537+
@override_settings(
1538+
MIGRATION_MODULES=dict(
1539+
settings.MIGRATION_MODULES,
1540+
auth_tests="auth_tests.operations_migrations",
1541+
),
1542+
)
1543+
class PermissionRenameOperationsTests(TransactionTestCase):
1544+
available_apps = [
1545+
"django.contrib.contenttypes",
1546+
"django.contrib.auth",
1547+
"auth_tests",
1548+
]
1549+
1550+
def setUp(self):
1551+
app_config = apps.get_app_config("auth_tests")
1552+
models.signals.post_migrate.connect(
1553+
self.assertOperationsInjected, sender=app_config
1554+
)
1555+
self.addCleanup(
1556+
models.signals.post_migrate.disconnect,
1557+
self.assertOperationsInjected,
1558+
sender=app_config,
1559+
)
1560+
1561+
def assertOperationsInjected(self, plan, **kwargs):
1562+
for migration, _backward in plan:
1563+
operations = iter(migration.operations)
1564+
for operation in operations:
1565+
if isinstance(operation, migrations.RenameModel):
1566+
next_operation = next(operations)
1567+
self.assertIsInstance(next_operation, RenamePermission)
1568+
self.assertEqual(next_operation.app_label, migration.app_label)
1569+
self.assertEqual(next_operation.old_model, operation.old_name)
1570+
self.assertEqual(next_operation.new_model, operation.new_name)
1571+
1572+
def test_permission_rename(self):
1573+
ct = ContentType.objects.create(app_label="auth_tests", model="oldmodel")
1574+
actions = ["add", "change", "delete", "view"]
1575+
for action in actions:
1576+
Permission.objects.create(
1577+
codename=f"{action}_oldmodel",
1578+
name=f"Can {action} old model",
1579+
content_type=ct,
1580+
)
1581+
1582+
call_command("migrate", "auth_tests", verbosity=0)
1583+
for action in actions:
1584+
self.assertFalse(
1585+
Permission.objects.filter(codename=f"{action}_oldmodel").exists()
1586+
)
1587+
self.assertTrue(
1588+
Permission.objects.filter(codename=f"{action}_newmodel").exists()
1589+
)
1590+
1591+
call_command(
1592+
"migrate",
1593+
"auth_tests",
1594+
"zero",
1595+
database="default",
1596+
interactive=False,
1597+
verbosity=0,
1598+
)
1599+
1600+
for action in actions:
1601+
self.assertTrue(
1602+
Permission.objects.filter(codename=f"{action}_oldmodel").exists()
1603+
)
1604+
self.assertFalse(
1605+
Permission.objects.filter(codename=f"{action}_newmodel").exists()
1606+
)
1607+
1608+
@mock.patch(
1609+
"django.db.router.allow_migrate_model",
1610+
return_value=False,
1611+
)
1612+
def test_rename_skipped_if_router_disallows(self, _):
1613+
ct = ContentType.objects.create(app_label="auth_tests", model="oldmodel")
1614+
Permission.objects.create(
1615+
codename="change_oldmodel",
1616+
name="Can change old model",
1617+
content_type=ct,
1618+
)
1619+
# The rename operation should not be there when disallowed by router.
1620+
app_config = apps.get_app_config("auth_tests")
1621+
models.signals.post_migrate.disconnect(
1622+
self.assertOperationsInjected, sender=app_config
1623+
)
1624+
1625+
call_command(
1626+
"migrate",
1627+
"auth_tests",
1628+
database="default",
1629+
interactive=False,
1630+
verbosity=0,
1631+
)
1632+
self.assertTrue(Permission.objects.filter(codename="change_oldmodel").exists())
1633+
self.assertFalse(Permission.objects.filter(codename="change_newmodel").exists())
1634+
1635+
call_command(
1636+
"migrate",
1637+
"auth_tests",
1638+
"zero",
1639+
database="default",
1640+
interactive=False,
1641+
verbosity=0,
1642+
)
1643+
1644+
def test_rename_backward_does_nothing_if_no_permissions(self):
1645+
Permission.objects.filter(content_type__app_label="auth_tests").delete()
1646+
1647+
call_command(
1648+
"migrate",
1649+
"auth_tests",
1650+
"zero",
1651+
database="default",
1652+
interactive=False,
1653+
verbosity=0,
1654+
)
1655+
self.assertFalse(
1656+
Permission.objects.filter(
1657+
codename__in=["change_oldmodel", "change_newmodel"]
1658+
).exists()
1659+
)
1660+
1661+
def test_rename_permission_conflict(self):
1662+
ct = ContentType.objects.create(app_label="auth_tests", model="oldmodel")
1663+
Permission.objects.create(
1664+
codename="change_newmodel",
1665+
name="Can change new model",
1666+
content_type=ct,
1667+
)
1668+
Permission.objects.create(
1669+
codename="change_oldmodel",
1670+
name="Can change old model",
1671+
content_type=ct,
1672+
)
1673+
1674+
call_command(
1675+
"migrate",
1676+
"auth_tests",
1677+
database="default",
1678+
interactive=False,
1679+
verbosity=0,
1680+
)
1681+
self.assertTrue(
1682+
Permission.objects.filter(
1683+
codename="change_oldmodel",
1684+
name="Can change old model",
1685+
).exists()
1686+
)
1687+
self.assertEqual(
1688+
Permission.objects.filter(
1689+
codename="change_newmodel",
1690+
name="Can change new model",
1691+
).count(),
1692+
1,
1693+
)
1694+
1695+
call_command(
1696+
"migrate",
1697+
"auth_tests",
1698+
"zero",
1699+
database="default",
1700+
interactive=False,
1701+
verbosity=0,
1702+
)
1703+
1704+
15311705
class DefaultDBRouter:
15321706
"""Route all writes to default."""
15331707

0 commit comments

Comments
 (0)