Skip to content

Commit cd90535

Browse files
committed
Initial version
0 parents  commit cd90535

File tree

6 files changed

+279
-0
lines changed

6 files changed

+279
-0
lines changed

.gitignore

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Python
2+
*.pyc
3+
__pycache__
4+
5+
# Virtual Environments / build
6+
/.env
7+
/.venv
8+
/bin/
9+
/include/
10+
/lib/
11+
/lib64
12+
/pyvenv.cfg
13+
/share/
14+
/squashed*.egg-info/
15+
/dist/
16+
17+
# vim
18+
*.swo
19+
*.swp

LICENSE

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
Copyright 2025 Bugsink B.V.
2+
3+
Redistribution and use in source and binary forms, with or without
4+
modification, are permitted provided that the following conditions are met:
5+
6+
1. Redistributions of source code must retain the above copyright notice, this
7+
list of conditions and the following disclaimer.
8+
9+
2. Redistributions in binary form must reproduce the above copyright notice,
10+
this list of conditions and the following disclaimer in the documentation
11+
and/or other materials provided with the distribution.
12+
13+
3. Neither the name of the copyright holder nor the names of its contributors
14+
may be used to endorse or promote products derived from this software without
15+
specific prior written permission.
16+
17+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND
18+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
19+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
20+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
21+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
22+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
23+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
24+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
25+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Squash migrations in django.contrib.auth for better performance and nicer output
2+
3+
As requested on the [Django Ticket Tracker](https://code.djangoproject.com/ticket/35707), but as a separate package.
4+
5+
### Why?
6+
7+
* Faster migrations (for every (test) run)!
8+
* Less pollution on screen
9+
10+
Counter-arguments about supposed breaking changes, stability etc. can be simply refuted by the fact that all such
11+
arguments would equally apply to _any_ use of squashed migrations.
12+
13+
I believe in `squashmigrations`, so let's just "squash all the things"!
14+
15+
### How it's made
16+
17+
On Django 4.2, the following was run:
18+
19+
```
20+
pyton manage.py squashmigrations auth 0001 0012
21+
```
22+
23+
The comments in the generated file were followed (copying RunMigration code over).
24+
25+
Then, the file was moved from my virtualenv to this package.
26+
27+
### A Magic Wheel A.K.A. package-level Monkey-patching
28+
29+
`django/contrib/auth/migrations/0001_squashed_0012_alter_user_first_name_max_length.py` is the only file in this
30+
"package", no `__init__.py` in any of the directories, and nothing else.
31+
32+
Surprisingly, such a setup "works" in the sense that this squashed migration ends up in the right location, whether you
33+
install this package or Django first. Django then sees the file, and the usual migration magic kicks in from there.
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import django.contrib.auth.models
2+
import django.contrib.auth.validators
3+
from django.db import migrations, models, IntegrityError, transaction
4+
import django.db.models.deletion
5+
import django.utils.timezone
6+
7+
import sys
8+
9+
from django.core.management.color import color_style
10+
from django.db.models import Q
11+
12+
13+
WARNING = """
14+
A problem arose migrating proxy model permissions for {old} to {new}.
15+
16+
Permission(s) for {new} already existed.
17+
Codenames Q: {query}
18+
19+
Ensure to audit ALL permissions for {old} and {new}.
20+
"""
21+
22+
23+
def update_proxy_model_permissions(apps, schema_editor, reverse=False):
24+
"""
25+
Update the content_type of proxy model permissions to use the ContentType
26+
of the proxy model.
27+
"""
28+
style = color_style()
29+
Permission = apps.get_model("auth", "Permission")
30+
ContentType = apps.get_model("contenttypes", "ContentType")
31+
alias = schema_editor.connection.alias
32+
for Model in apps.get_models():
33+
opts = Model._meta
34+
if not opts.proxy:
35+
continue
36+
proxy_default_permissions_codenames = [
37+
"%s_%s" % (action, opts.model_name) for action in opts.default_permissions
38+
]
39+
permissions_query = Q(codename__in=proxy_default_permissions_codenames)
40+
for codename, name in opts.permissions:
41+
permissions_query |= Q(codename=codename, name=name)
42+
content_type_manager = ContentType.objects.db_manager(alias)
43+
concrete_content_type = content_type_manager.get_for_model(
44+
Model, for_concrete_model=True
45+
)
46+
proxy_content_type = content_type_manager.get_for_model(
47+
Model, for_concrete_model=False
48+
)
49+
old_content_type = proxy_content_type if reverse else concrete_content_type
50+
new_content_type = concrete_content_type if reverse else proxy_content_type
51+
try:
52+
with transaction.atomic(using=alias):
53+
Permission.objects.using(alias).filter(
54+
permissions_query,
55+
content_type=old_content_type,
56+
).update(content_type=new_content_type)
57+
except IntegrityError:
58+
old = "{}_{}".format(old_content_type.app_label, old_content_type.model)
59+
new = "{}_{}".format(new_content_type.app_label, new_content_type.model)
60+
sys.stdout.write(
61+
style.WARNING(WARNING.format(old=old, new=new, query=permissions_query))
62+
)
63+
64+
65+
def revert_proxy_model_permissions(apps, schema_editor):
66+
"""
67+
Update the content_type of proxy model permissions to use the ContentType
68+
of the concrete model.
69+
"""
70+
update_proxy_model_permissions(apps, schema_editor, reverse=True)
71+
72+
73+
class Migration(migrations.Migration):
74+
75+
replaces = [
76+
('auth', '0001_initial'),
77+
('auth', '0002_alter_permission_name_max_length'),
78+
('auth', '0003_alter_user_email_max_length'),
79+
('auth', '0004_alter_user_username_opts'),
80+
('auth', '0005_alter_user_last_login_null'),
81+
('auth', '0006_require_contenttypes_0002'),
82+
('auth', '0007_alter_validators_add_error_messages'),
83+
('auth', '0008_alter_user_username_max_length'),
84+
('auth', '0009_alter_user_last_name_max_length'),
85+
('auth', '0010_alter_group_name_max_length'),
86+
('auth', '0011_update_proxy_permissions'),
87+
('auth', '0012_alter_user_first_name_max_length'),
88+
]
89+
90+
initial = True
91+
92+
dependencies = [
93+
('contenttypes', '0002_remove_content_type_name'),
94+
('contenttypes', '__first__'),
95+
]
96+
97+
operations = [
98+
migrations.CreateModel(
99+
name='Permission',
100+
fields=[
101+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
102+
('name', models.CharField(max_length=255, verbose_name='name')),
103+
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='content type')),
104+
('codename', models.CharField(max_length=100, verbose_name='codename')),
105+
],
106+
options={
107+
'ordering': ['content_type__app_label', 'content_type__model', 'codename'],
108+
'unique_together': {('content_type', 'codename')},
109+
'verbose_name': 'permission',
110+
'verbose_name_plural': 'permissions',
111+
},
112+
managers=[
113+
('objects', django.contrib.auth.models.PermissionManager()),
114+
],
115+
),
116+
migrations.CreateModel(
117+
name='Group',
118+
fields=[
119+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
120+
('name', models.CharField(max_length=150, unique=True, verbose_name='name')),
121+
('permissions', models.ManyToManyField(blank=True, to='auth.permission', verbose_name='permissions')),
122+
],
123+
options={
124+
'verbose_name': 'group',
125+
'verbose_name_plural': 'groups',
126+
},
127+
managers=[
128+
('objects', django.contrib.auth.models.GroupManager()),
129+
],
130+
),
131+
migrations.CreateModel(
132+
name='User',
133+
fields=[
134+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
135+
('password', models.CharField(max_length=128, verbose_name='password')),
136+
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
137+
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
138+
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
139+
('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')),
140+
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
141+
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
142+
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
143+
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
144+
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
145+
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
146+
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
147+
],
148+
options={
149+
'swappable': 'AUTH_USER_MODEL',
150+
'verbose_name': 'user',
151+
'verbose_name_plural': 'users',
152+
},
153+
managers=[
154+
('objects', django.contrib.auth.models.UserManager()),
155+
],
156+
),
157+
158+
migrations.RunPython(
159+
code=update_proxy_model_permissions,
160+
reverse_code=revert_proxy_model_permissions,
161+
),
162+
163+
migrations.AlterField(
164+
model_name='user',
165+
name='first_name',
166+
field=models.CharField(blank=True, max_length=150, verbose_name='first name'),
167+
),
168+
]

pyproject.toml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
[build-system]
2+
requires = ["setuptools>=64"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "squashed-users"
7+
authors = [
8+
{name = "Bugsink B.V.", email = "[email protected]"},
9+
]
10+
description = "django.contrib.auth migrations squashed into a single migration"
11+
readme = "README.md"
12+
requires-python = ">=3.9"
13+
license = {file = "LICENSE"}
14+
classifiers = [
15+
"Programming Language :: Python :: 3",
16+
]
17+
dynamic = ["dependencies"]
18+
version = "1.0.0"
19+
20+
[tool.setuptools]
21+
include-package-data = true # this is the default, but explicit is better than implicit
22+
23+
[tool.setuptools.packages.find]
24+
where = ["."]
25+
include = [
26+
"django*",
27+
]

tox.ini

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[flake8]
2+
max-line-length=120
3+
exclude=venv,.venv
4+
extend-ignore=
5+
# E741: ambiguous variable name
6+
E741

0 commit comments

Comments
 (0)