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 9f1dd85ea..993fe5eaf 100644
Binary files a/ESSArch_Core/auth/locale/sv/LC_MESSAGES/django.mo and b/ESSArch_Core/auth/locale/sv/LC_MESSAGES/django.mo differ
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..1801475f5
--- /dev/null
+++ b/ESSArch_Core/auth/migrations/0028_groupmember_is_locally_managed_and_more.py
@@ -0,0 +1,92 @@
+# 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.
+ """
+ GroupMember = apps.get_model('essauth', 'GroupMember')
+ GroupMemberRoleAssignment = apps.get_model(
+ 'essauth',
+ 'GroupMemberRoleAssignment',
+ )
+
+ # 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
+
+ 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=[
+ migrations.RunSQL(
+ sql='DROP TABLE IF EXISTS essauth_groupmember_roles',
+ reverse_sql=migrations.RunSQL.noop,
+ ),
+ ],
+ 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/auth/tests/test_admin.py b/ESSArch_Core/auth/tests/test_admin.py
index 32fdf3a22..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, 'Assigned roles', status_code=200)
+ self.assertContains(response, 'Group Memberships', status_code=200)
class GroupAdminTestCase(TestCase):
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