From 41a128bf13b2c6f194ec50b3ef41c4cef3fda963 Mon Sep 17 00:00:00 2001 From: henrikek Date: Wed, 19 Aug 2026 16:49:55 +0200 Subject: [PATCH 1/4] Add flag locally_managed for roles and groups --- ESSArch_Core/auth/admin.py | 86 +++++++++++-- .../auth/locale/sv/LC_MESSAGES/django.mo | Bin 1887 -> 2065 bytes .../auth/locale/sv/LC_MESSAGES/django.po | 116 ++++++++++-------- ...groupmember_is_locally_managed_and_more.py | 89 ++++++++++++++ ESSArch_Core/auth/models.py | 46 ++++++- ESSArch_Core/static/essauth/css/admin.css | 105 ++++++++++++++++ requirements/base.txt | 6 +- 7 files changed, 383 insertions(+), 65 deletions(-) create mode 100644 ESSArch_Core/auth/migrations/0028_groupmember_is_locally_managed_and_more.py create mode 100644 ESSArch_Core/static/essauth/css/admin.css diff --git a/ESSArch_Core/auth/admin.py b/ESSArch_Core/auth/admin.py index 63c1e763a..c3e865611 100644 --- a/ESSArch_Core/auth/admin.py +++ b/ESSArch_Core/auth/admin.py @@ -14,6 +14,7 @@ from django.db.models import Q from django.db.models.functions import Lower from django.utils.decorators import method_decorator +from django.utils.html import format_html from django.utils.text import capfirst from django.utils.translation import gettext_lazy as _ from django.views.decorators.csrf import csrf_protect @@ -25,13 +26,18 @@ GroupType as GroupManagerGroupType, Member as GroupManagerMember, ) -from nested_inline.admin import NestedModelAdmin, NestedTabularInline +from nested_inline.admin import ( + NestedModelAdmin, + NestedStackedInline, + NestedTabularInline, +) from ESSArch_Core.admin import NestedStackedInlineWithoutHeader from ESSArch_Core.auth.models import ( Group, GroupMember, GroupMemberRole, + GroupMemberRoleAssignment, GroupType, Member, ProxyGroup, @@ -65,21 +71,85 @@ class GroupMemberForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['expiration_date'].required = False - self.fields['roles'].required = False - self.fields['group'].disabled = True class Meta: model = GroupMember - fields = '__all__' + # fields = '__all__' + fields = [ + # 'group', + 'member', + 'expiration_date', + 'is_locally_managed', + ] + + +class GroupMemberRoleAssignmentForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['expiration_date'].required = False + + class Meta: + model = GroupMemberRoleAssignment + fields = [ + 'role', + 'expiration_date', + 'is_locally_managed', + ] + + +class GroupMemberRoleAssignmentInline(NestedTabularInline): + model = GroupMemberRoleAssignment + form = GroupMemberRoleAssignmentForm + extra = 0 + fields = [ + 'role', + 'expiration_date', + 'is_locally_managed', + ] + verbose_name = _('role') + verbose_name_plural = _('roles') + + def has_add_permission(self, request, obj=None): + return True + + def has_change_permission(self, request, obj=None): + return True + + def has_delete_permission(self, request, obj=None): + return True -class GroupMemberInline(NestedTabularInline): +class GroupMemberInline(NestedStackedInline): form = GroupMemberForm - filter_horizontal = ['roles'] - fields = ['group', 'member', 'expiration_date', 'roles'] model = GroupMember extra = 0 - verbose_name_plural = _('Assigned roles') + fields = [ + 'group_heading', + 'expiration_date', + 'is_locally_managed', + ] + + readonly_fields = ['group_heading'] + inlines = [GroupMemberRoleAssignmentInline] + + verbose_name = _('Group membership') + verbose_name_plural = _('Group memberships') + + class Media: + css = { + 'all': ('essauth/css/admin.css',) + } + + def group_heading(self, obj): + if not obj.pk: + return '' + + return format_html( + '
{}
', + obj.group.name, + ) + + group_heading.short_description = _('Group') def has_add_permission(self, request, obj=None): return False diff --git a/ESSArch_Core/auth/locale/sv/LC_MESSAGES/django.mo b/ESSArch_Core/auth/locale/sv/LC_MESSAGES/django.mo index 9f1dd85ea7afbd81132dc8de98bf0bef6fba1146..993fe5eaf8872b0c42b84a535471fb3e863b58a5 100644 GIT binary patch delta 863 zcmYk)J!lj`6u|M9yUQhhok`TFcZpdSBzN&;)b%SaegienYB?T=+;M@s zIM1mc;v^HkG3zjHVgqNW;^A@h@d9eWb>}M_Vf+qH;z!hjjT1bIGpOSQ)O?q)MSTf) zpr)+23D;0hSfK9o9_kx;f%3;y~qY?-e1l-NeAwl z35!hAxklMWZ_>4CqCIrId)Ac&p-jk)l?ip+(m&e77IF}E(|V=a_R)27dUE}4wDFys zE?;%|;StaeNKd?%t|$82&h^r;?VH?snGLO<+rVq*HY(!ElW8xq(W32UOYKw^x=Ann zFOU_@>a9^f32ba4TdY;*8;NhiaM_DYY_8Z~{b#j1G`W@Dc8%9JVPL#u@v80QMSu83 rGqOQwqpV{zc`r@>jf-RT$2HQ%#fSQ-#*&G15@cSObWB)Gjhy`h6;E@B delta 707 zcmXxhJuE{}6u|LYeOjN@7WL6anj!|l#3F`P3>H&oQF|rZC`Oz}5H(_$c=GXJEHx+< z!W{PCCbr`~4&X6ryukp*{Ys5u3OjHCrS2lO;ttCGJ?vGgtSbDFg_mZ*9X2w5#1?!p z`6^0-5A**QTFh%E|Bcc>l9F%ZA$1~V-ecwo%y76AM(D3Jt5Pi7LOJOPCh-DS@DYPJ zPWMTiLOFqhBe-qmrzrccP`>89$yZSh@`;pEHEhBk$8s=Z#sETa&NZO>_~mPMYy?>emPLA@!toxu1u;_5YB@&4iqQE0sH{kC?F& z!-O=J5hdD)KAD&iG4ek36RWiA+N%YfPZl>E?W(!4^_=Y_Z8ur)9vaTP1OK5nWTma# svUaXrtLTelZO6%LCzsc<#nDCYxAE3{4V-(s!Rdf5*=xGwJqKt105;D^%>V!Z diff --git a/ESSArch_Core/auth/locale/sv/LC_MESSAGES/django.po b/ESSArch_Core/auth/locale/sv/LC_MESSAGES/django.po index e2b241894..01eb5cb10 100644 --- a/ESSArch_Core/auth/locale/sv/LC_MESSAGES/django.po +++ b/ESSArch_Core/auth/locale/sv/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-14 16:48+0200\n" +"POT-Creation-Date: 2026-08-19 15:31+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,119 +18,137 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: auth/admin.py:81 -msgid "Assigned roles" -msgstr "Tilldelade roller" +#: admin.py:110 models.py:179 +msgid "role" +msgstr "roll" + +#: admin.py:111 models.py:180 models.py:435 +msgid "roles" +msgstr "roller" + +#: admin.py:142 +msgid "Group membership" +msgstr "Gruppmedlemskap" + +#: admin.py:143 +msgid "Group memberships" +msgstr "Gruppmedlemskap" + +#: admin.py:166 +#, fuzzy +#| msgid "Groups" +msgid "Group" +msgstr "Grupper" -#: auth/admin.py:103 +#: admin.py:188 msgid "Groups added above appears here when saving" msgstr "Grupper tillagda ovan visas här efter de har sparats" -#: auth/admin.py:122 +#: admin.py:207 msgid "Personal info" msgstr "Personlig info" -#: auth/admin.py:123 +#: admin.py:208 msgid "Permissions" msgstr "Behörigheter" -#: auth/admin.py:125 +#: admin.py:210 msgid "Important dates" msgstr "Viktiga datum" -#: auth/admin.py:126 +#: admin.py:211 msgid "Groups" msgstr "Grupper" -#: auth/admin.py:226 auth/models.py:280 auth/models.py:288 +#: admin.py:315 models.py:272 models.py:280 msgid "group type" msgstr "grupptyp" -#: auth/admin.py:230 auth/models.py:295 +#: admin.py:319 models.py:287 msgid "parent" msgstr "överordnad" -#: auth/admin.py:309 +#: admin.py:323 models.py:175 models.py:288 +msgid "external id" +msgstr "externt id" + +#: admin.py:363 models.py:173 +msgid "name" +msgstr "namn" + +#: admin.py:391 +msgid "Duplicate selected items" +msgstr "Duplicera valda objekt" + +#: admin.py:437 msgid "content type" msgstr "" -#: auth/apps.py:7 +#: apps.py:7 msgid "Authentication and Authorization" msgstr "" -#: auth/models.py:173 -msgid "name" -msgstr "namn" - -#: auth/models.py:174 -msgid "label" -msgstr "etikett" - -#: auth/models.py:175 auth/models.py:248 +#: models.py:174 models.py:240 msgid "permissions" msgstr "behörigheter" -#: auth/models.py:176 auth/models.py:296 -msgid "external id" -msgstr "externt id" - -#: auth/models.py:187 -msgid "role" -msgstr "roll" - -#: auth/models.py:188 auth/models.py:428 -msgid "roles" -msgstr "roller" +#: models.py:176 +msgid "codename" +msgstr "namn" -#: auth/models.py:231 auth/models.py:407 auth/models.py:420 +#: models.py:223 models.py:410 models.py:423 msgid "group" msgstr "grupp" -#: auth/models.py:232 auth/models.py:408 +#: models.py:224 models.py:411 msgid "groups" msgstr "grupper" -#: auth/models.py:239 +#: models.py:231 msgid "user" msgstr "användare" -#: auth/models.py:240 +#: models.py:232 msgid "users" msgstr "användare" -#: auth/models.py:247 +#: models.py:239 msgid "permission" msgstr "" -#: auth/models.py:281 +#: models.py:273 msgid "group types" msgstr "grupptyper" -#: auth/models.py:297 +#: models.py:289 msgid "properties" msgstr "" -#: auth/models.py:426 +#: models.py:429 msgid "member" msgstr "" -#: auth/models.py:429 +#: models.py:437 models.py:460 msgid "expiration date" msgstr "utgångsdatum" -#: auth/serializers.py:246 +#: models.py:438 models.py:461 +msgid "locally managed" +msgstr "hanteras lokalt" + +#: serializers.py:286 msgid "User account is disabled" msgstr "Användaren är inaktiverad" -#: auth/serializers.py:249 +#: serializers.py:289 msgid "Invalid username or password" msgstr "Felaktigt användarnamn eller lösenord" -#: auth/serializers.py:258 +#: serializers.py:298 msgid "E-mail is not verified." msgstr "E-postadressen är inte verifierad" -#: auth/templates/essauth/admin/group/add_form.html:6 +#: templates/essauth/admin/group/add_form.html:6 msgid "" "First, enter a name and optionally some permissions. Then, you'll be able to " "edit more group options." @@ -138,16 +156,16 @@ msgstr "" "Ange först ett namn och eventuella rättigheter. Efter det kommer du att få " "fler gruppalternativ." -#: auth/templates/essauth/admin/group/add_form.html:8 +#: templates/essauth/admin/group/add_form.html:8 msgid "Enter a name." msgstr "Ange ett namn." -#: auth/templates/essauth/admin/user/add_form.html:6 +#: templates/essauth/admin/user/add_form.html:6 msgid "" "First, enter a username and password. Then, you'll be able to edit more user " "options." msgstr "" -#: auth/templates/essauth/admin/user/add_form.html:8 +#: templates/essauth/admin/user/add_form.html:8 msgid "Enter a username and password." msgstr "Ange användarnamn och lösenord" diff --git a/ESSArch_Core/auth/migrations/0028_groupmember_is_locally_managed_and_more.py b/ESSArch_Core/auth/migrations/0028_groupmember_is_locally_managed_and_more.py new file mode 100644 index 000000000..5e60c8de7 --- /dev/null +++ b/ESSArch_Core/auth/migrations/0028_groupmember_is_locally_managed_and_more.py @@ -0,0 +1,89 @@ +# Generated by Django 5.2.17 on 2026-08-19 08:50 + +import django.db.models.deletion +from django.db import migrations, models + + +def copy_group_member_roles(apps, schema_editor): + """ + Copy the existing automatically-created GroupMember.roles M2M + relationships into the new explicit GroupMemberRoleAssignment model. + + At this point in the migration, the historical GroupMember model + still has the old automatically-created M2M through model. + """ + GroupMember = apps.get_model('essauth', 'GroupMember') + GroupMemberRoleAssignment = apps.get_model( + 'essauth', + 'GroupMemberRoleAssignment', + ) + + # Get the OLD automatically-created M2M through model. + old_through = GroupMember._meta.get_field( + 'roles' + ).remote_field.through + + assignments = [] + + for relation in old_through.objects.all().iterator(): + assignments.append( + GroupMemberRoleAssignment( + group_member_id=relation.groupmember_id, + role_id=relation.groupmemberrole_id, + ) + ) + + GroupMemberRoleAssignment.objects.bulk_create( + assignments, + ignore_conflicts=True, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('essauth', '0027_alter_userprofile_language'), + ] + + operations = [ + migrations.AddField( + model_name='groupmember', + name='is_locally_managed', + field=models.BooleanField(default=False, verbose_name='locally managed'), + ), + migrations.CreateModel( + name='GroupMemberRoleAssignment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('expiration_date', models.DateTimeField(default=None, null=True, verbose_name='expiration date')), + ('is_locally_managed', models.BooleanField(default=False, verbose_name='locally managed')), + ('group_member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + related_name='role_assignments', to='essauth.groupmember')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + related_name='group_member_assignments', to='essauth.groupmemberrole')), + ], + options={ + 'unique_together': {('group_member', 'role')}, + }, + ), + migrations.RunPython( + copy_group_member_roles, + migrations.RunPython.noop, + ), + + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.AlterField( + model_name='groupmember', + name='roles', + field=models.ManyToManyField( + related_name='group_memberships', + through='essauth.GroupMemberRoleAssignment', + to='essauth.groupmemberrole', + verbose_name='roles', + ), + ), + ], + ), + ] diff --git a/ESSArch_Core/auth/models.py b/ESSArch_Core/auth/models.py index 7810552ae..3ac09dab2 100644 --- a/ESSArch_Core/auth/models.py +++ b/ESSArch_Core/auth/models.py @@ -336,7 +336,7 @@ def remove_object(self, obj): kwargs['content_object'] = obj return group_objs_model.objects.filter(**kwargs).delete() - def add_user(self, user, roles=None, expiration_date=None): + def add_user(self, user, roles=None, expiration_date=None, is_locally_managed=False): """Add a user to the group. :Parameters: @@ -348,11 +348,15 @@ def add_user(self, user, roles=None, expiration_date=None): from the group but is only an indicator to an external application to check if the membership still is valid (optional, default: ``None``) + - `is_locally_managed`: Whether the membership is managed locally and + should not be overridden by the central permission system + (optional, default: ``False``) """ - return self.add_member(user.essauth_member, roles=roles, expiration_date=expiration_date) + return self.add_member(user.essauth_member, roles=roles, expiration_date=expiration_date, + is_locally_managed=is_locally_managed) - def add_member(self, member, roles=None, expiration_date=None): + def add_member(self, member, roles=None, expiration_date=None, is_locally_managed=False,): """Add a member to the group. :Parameters: @@ -364,6 +368,9 @@ def add_member(self, member, roles=None, expiration_date=None): from the group but is only an indicator to an external application to check if the membership still is valid (optional, default: ``None``) + - `is_locally_managed`: Whether the membership is managed locally and + should not be overridden by the central permission system + (optional, default: ``False``) """ if roles is None: roles = [] @@ -375,7 +382,11 @@ def add_member(self, member, roles=None, expiration_date=None): "You must save the member before to create a relation with groups") group_member_model = self.group_member_model group_member, _ = group_member_model.objects.get_or_create( - member=member, group=self, expiration_date=expiration_date, + member=member, group=self, + defaults={ + 'expiration_date': expiration_date, + 'is_locally_managed': is_locally_managed, + }, ) if roles: for role in roles: @@ -417,8 +428,14 @@ class GroupMember(GroupMemberMixin): on_delete=models.CASCADE, verbose_name=_('member'), ) - roles = models.ManyToManyField(GroupMemberRole, related_name='group_memberships', verbose_name=_('roles')) + roles = models.ManyToManyField( + GroupMemberRole, + through='GroupMemberRoleAssignment', + related_name='group_memberships', + verbose_name=_('roles'), + ) expiration_date = models.DateTimeField(_('expiration date'), null=True, default=None) + is_locally_managed = models.BooleanField(_('locally managed'), default=False) def __str__(self): return self.group.name @@ -429,6 +446,25 @@ class Meta(GroupMemberMixin.Meta): default_permissions = [] +class GroupMemberRoleAssignment(models.Model): + group_member = models.ForeignKey( + GroupMember, + on_delete=models.CASCADE, + related_name='role_assignments', + ) + role = models.ForeignKey( + GroupMemberRole, + on_delete=models.CASCADE, + related_name='group_member_assignments', + verbose_name=_('role'), + ) + expiration_date = models.DateTimeField(_('expiration date'), null=True, default=None) + is_locally_managed = models.BooleanField(_('locally managed'), default=False) + + class Meta: + unique_together = ('group_member', 'role') + + class UserProfile(models.Model): AIC = 'aic' IP = 'ip' diff --git a/ESSArch_Core/static/essauth/css/admin.css b/ESSArch_Core/static/essauth/css/admin.css new file mode 100644 index 000000000..acf3ac880 --- /dev/null +++ b/ESSArch_Core/static/essauth/css/admin.css @@ -0,0 +1,105 @@ +/* ========================================================= + Group memberships + ========================================================= */ + +.group-membership-heading { + box-sizing: border-box; + width: fit-content; + min-width: 160px; + + display: inline-block; + + padding: 10px 16px; + + margin: 0 0 12px 0; + + background: #f5f7f9; + border-left: 5px solid #417690; + + color: #333; + font-size: 18px; + font-weight: 600; +} + + +/* Hide the "Group" label generated for group_heading */ +.field-group_heading label { + display: none !important; +} + +/* Hide Django's automatic Group Membership: header */ +.inline-group .inline-related h3 { + display: none; +} + + +/* Remove the space around the readonly field */ + +.field-group_heading { + padding: 0 !important; + margin: 0 !important; +} + +.field-group_heading > div { + padding: 0 !important; + margin: 0 !important; +} + +.field-group_heading .readonly { + padding: 0 !important; + margin: 0 !important; +} + + +/* ========================================================= + Group membership container + ========================================================= */ + +.inline-group .inline-related { + padding-top: 0; +} + + +/* ========================================================= + Assigned roles + ========================================================= */ + +.inline-group .inline-group { + margin-left: 40px; + border-left: 3px solid #ddd; +} + + +/* Assigned roles heading */ + +.inline-group .inline-group h2 { + font-size: 14px; +} + + +/* Put date and time on the same line */ +.inline-group .datetime { + display: flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} + +.inline-group .datetime br { + display: none; +} + +.inline-group .datetime .vDateField { + width: 80px !important; + margin-right: 2px; +} + +.inline-group .datetime .vTimeField { + width: 60px !important; + margin-left: 6px; +} + +.inline-group .datetime .datetimeshortcuts { + display: inline-flex; + align-items: center; +} diff --git a/requirements/base.txt b/requirements/base.txt index d1f53f413..245315224 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,10 +1,10 @@ asgiref==3.9.1 -boto3==1.43.67 +boto3==1.43.74 celery[tblib]==5.6.3 cffi==2.1.1 channels==4.3.2 channels-redis==4.3.0 -chardet==7.5.1 +chardet==7.6.0 click==8.4.2 cryptography==45.0.7 daphne==4.2.3 @@ -40,7 +40,7 @@ elasticsearch-dsl==7.4.1 gevent==24.11.1 ; platform_system=='Windows' glob2==0.7 jsonfield==3.2.0 -lxml==6.1.1 +lxml==6.1.2 msoffcrypto-tool==5.4.2 natsort==8.4.0 opf-fido==1.6.1 From 64f28268e163cd236602ee8567cae999dd3b3e2b Mon Sep 17 00:00:00 2001 From: henrikek Date: Wed, 19 Aug 2026 17:11:59 +0200 Subject: [PATCH 2/4] Fix tests --- ESSArch_Core/auth/tests/test_admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ESSArch_Core/auth/tests/test_admin.py b/ESSArch_Core/auth/tests/test_admin.py index 32fdf3a22..246999079 100644 --- a/ESSArch_Core/auth/tests/test_admin.py +++ b/ESSArch_Core/auth/tests/test_admin.py @@ -23,7 +23,7 @@ def test_changelist_view(self): def test_change_view(self): response = self.client.get(reverse('admin:essauth_proxyuser_change', args=(self.user.pk,))) - self.assertContains(response, 'Assigned roles', status_code=200) + self.assertContains(response, 'Group memberships', status_code=200) class GroupAdminTestCase(TestCase): From d376e5203642f6ea8e0a89f65643cb5917d9e90e Mon Sep 17 00:00:00 2001 From: henrikek Date: Wed, 19 Aug 2026 17:42:24 +0200 Subject: [PATCH 3/4] Fix tests 2 --- ESSArch_Core/auth/tests/test_admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ESSArch_Core/auth/tests/test_admin.py b/ESSArch_Core/auth/tests/test_admin.py index 246999079..6115535c1 100644 --- a/ESSArch_Core/auth/tests/test_admin.py +++ b/ESSArch_Core/auth/tests/test_admin.py @@ -23,7 +23,7 @@ def test_changelist_view(self): def test_change_view(self): response = self.client.get(reverse('admin:essauth_proxyuser_change', args=(self.user.pk,))) - self.assertContains(response, 'Group memberships', status_code=200) + self.assertContains(response, 'Group Memberships', status_code=200) class GroupAdminTestCase(TestCase): From 663c0028592d3320d3882397c4edcbfa5d1ab6c2 Mon Sep 17 00:00:00 2001 From: henrikek Date: Thu, 20 Aug 2026 11:13:19 +0200 Subject: [PATCH 4/4] Fix tests 3 --- .../0028_groupmember_is_locally_managed_and_more.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/ESSArch_Core/auth/migrations/0028_groupmember_is_locally_managed_and_more.py b/ESSArch_Core/auth/migrations/0028_groupmember_is_locally_managed_and_more.py index 5e60c8de7..1801475f5 100644 --- a/ESSArch_Core/auth/migrations/0028_groupmember_is_locally_managed_and_more.py +++ b/ESSArch_Core/auth/migrations/0028_groupmember_is_locally_managed_and_more.py @@ -8,9 +8,6 @@ def copy_group_member_roles(apps, schema_editor): """ Copy the existing automatically-created GroupMember.roles M2M relationships into the new explicit GroupMemberRoleAssignment model. - - At this point in the migration, the historical GroupMember model - still has the old automatically-created M2M through model. """ GroupMember = apps.get_model('essauth', 'GroupMember') GroupMemberRoleAssignment = apps.get_model( @@ -18,7 +15,8 @@ def copy_group_member_roles(apps, schema_editor): 'GroupMemberRoleAssignment', ) - # Get the OLD automatically-created M2M through model. + # At this point the historical model state still uses the old + # automatically-created M2M through model. old_through = GroupMember._meta.get_field( 'roles' ).remote_field.through @@ -72,7 +70,12 @@ class Migration(migrations.Migration): ), migrations.SeparateDatabaseAndState( - database_operations=[], + database_operations=[ + migrations.RunSQL( + sql='DROP TABLE IF EXISTS essauth_groupmember_roles', + reverse_sql=migrations.RunSQL.noop, + ), + ], state_operations=[ migrations.AlterField( model_name='groupmember',