diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index fd423b4..fa27130 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -585,7 +585,7 @@ class WaiterInviteTextForm(forms.Form): widget=forms.Textarea(attrs={'rows': 30, 'cols': 100})) -class InvitationToGroupAdmin(admin.TabularInline): +class InvitationToGroupAdmin(CommonAdminInlineMixin, admin.TabularInline): model = InvitationToGroup fields = ['group', 'date', 'status'] readonly_fields = ['group', 'date', 'status'] @@ -640,6 +640,9 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin): def has_add_permission(self, request, obj=None): return False + def has_action_permission(self, request): + return request.user.has_perm('members.change_global_memberwaitinglist') + def age(self, obj): return obj.birth_date_delta age.short_description=_('age') @@ -652,6 +655,7 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin): messages.success(request, _("Successfully asked %(name)s to confirm their waiting status.") % {'name': waiter.name}) ask_for_wait_confirmation.short_description = _('Ask selected waiters to confirm their waiting status') + ask_for_wait_confirmation.allowed_permissions = ('action',) def response_change(self, request, waiter): ret = super(MemberWaitingListAdmin, self).response_change(request, waiter) @@ -666,12 +670,14 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin): member.request_mail_confirmation() messages.success(request, _("Successfully requested mail confirmation from selected waiters.")) request_mail_confirmation.short_description = _('Request mail confirmation from selected waiters.') + request_mail_confirmation.allowed_permissions = ('action',) def request_required_mail_confirmation(self, request, queryset): for member in queryset: member.request_mail_confirmation(rerequest=False) messages.success(request, _("Successfully re-requested missing mail confirmations from selected waiters.")) request_required_mail_confirmation.short_description = _('Re-request missing mail confirmations from selected waiters.') + request_required_mail_confirmation.allowed_permissions = ('action',) def get_urls(self): urls = super().get_urls() @@ -711,6 +717,7 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin): def ask_for_registration_action(self, request, queryset): return self.invite_view(request, queryset) ask_for_registration_action.short_description = _('Offer waiter a place in a group.') + ask_for_registration_action.allowed_permissions = ('action',) def invite_view(self, request, object_id): if type(object_id) == str: diff --git a/jdav_web/members/migrations/0043_waitinglist_permissions.py b/jdav_web/members/migrations/0043_waitinglist_permissions.py new file mode 100644 index 0000000..d090357 --- /dev/null +++ b/jdav_web/members/migrations/0043_waitinglist_permissions.py @@ -0,0 +1,121 @@ +from django.utils.translation import gettext_lazy as _ +from django.db import migrations +from django.contrib.auth.management import create_permissions + +STANDARD_PERMS = [ + ('members', 'view_member'), + ('members', 'view_freizeit'), + ('members', 'add_global_freizeit'), + ('members', 'view_memberwaitinglist'), + ('members', 'view_memberunconfirmedproxy'), + ('mailer', 'view_message'), + ('mailer', 'add_global_message'), + ('finance', 'view_statementunsubmitted'), + ('finance', 'add_global_statementunsubmitted'), +] + +FINANCE_PERMS = [ + ('finance', 'view_bill'), + ('finance', 'view_ledger'), + ('finance', 'add_ledger'), + ('finance', 'change_ledger'), + ('finance', 'delete_ledger'), + ('finance', 'view_statementsubmitted'), + ('finance', 'view_global_statementsubmitted'), + ('finance', 'change_global_statementsubmitted'), + ('finance', 'view_transaction'), + ('finance', 'change_transaction'), + ('finance', 'add_transaction'), + ('finance', 'delete_transaction'), + ('finance', 'process_statementsubmitted'), + ('members', 'list_global_freizeit'), + ('members', 'view_global_freizeit'), +] + +WAITINGLIST_PERMS = [ + ('members', 'view_global_memberwaitinglist'), + ('members', 'list_global_memberwaitinglist'), + ('members', 'change_global_memberwaitinglist'), + ('members', 'delete_global_memberwaitinglist'), +] + +TRAINING_PERMS = [ + ('members', 'change_global_member'), + ('members', 'list_global_member'), + ('members', 'view_global_member'), + ('members', 'add_global_membertraining'), + ('members', 'change_global_membertraining'), + ('members', 'list_global_membertraining'), + ('members', 'view_global_membertraining'), + ('members', 'view_trainingcategory'), + ('members', 'add_trainingcategory'), + ('members', 'change_trainingcategory'), + ('members', 'delete_trainingcategory'), +] + +REGISTRATION_PERMS = [ + ('members', 'may_manage_all_registrations'), + ('members', 'change_memberunconfirmedproxy'), + ('members', 'delete_memberunconfirmedproxy'), +] + +MATERIAL_PERMS = [ + ('members', 'list_global_member'), + ('material', 'view_materialpart'), + ('material', 'change_materialpart'), + ('material', 'add_materialpart'), + ('material', 'delete_materialpart'), + ('material', 'view_materialcategory'), + ('material', 'change_materialcategory'), + ('material', 'add_materialcategory'), + ('material', 'delete_materialcategory'), + ('material', 'view_ownership'), + ('material', 'change_ownership'), + ('material', 'add_ownership'), + ('material', 'delete_ownership'), +] + + +def ensure_group_perms(apps, schema_editor, name, perm_names): + """ + Ensure the group `name` has the permissions `perm_names`. If the group does not + exist, create it with the given permissions, otherwise add the missing ones. + + This only adds permissions, already existing ones that are not listed here are not + removed. + """ + db_alias = schema_editor.connection.alias + Group = apps.get_model("auth", "Group") + Permission = apps.get_model("auth", "Permission") + perms = [ Permission.objects.get(codename=codename, content_type__app_label=app_label) for app_label, codename in perm_names ] + try: + g = Group.objects.using(db_alias).get(name=name) + for perm in perms: + g.permissions.add(perm) + g.save() + # This case is only executed if users have manually removed one of the standard groups. + except Group.DoesNotExist: # pragma: no cover + g = Group.objects.using(db_alias).create(name=name) + g.permissions.set(perms) + g.save() + + +def update_default_permission_groups(apps, schema_editor): + ensure_group_perms(apps, schema_editor, "Standard", STANDARD_PERMS) + ensure_group_perms(apps, schema_editor, "Finance", FINANCE_PERMS) + ensure_group_perms(apps, schema_editor, "Waitinglist", WAITINGLIST_PERMS) + ensure_group_perms(apps, schema_editor, "Trainings", TRAINING_PERMS) + ensure_group_perms(apps, schema_editor, "Registrations", REGISTRATION_PERMS) + ensure_group_perms(apps, schema_editor, "Material", MATERIAL_PERMS) + + +class Migration(migrations.Migration): + + dependencies = [ + ('members', '0010_create_default_permission_groups'), + ('members', '0042_member_ticket_no'), + ] + + operations = [ + migrations.RunPython(update_default_permission_groups, migrations.RunPython.noop), + ] diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index d747d45..d503aa9 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -24,7 +24,8 @@ from django.contrib.auth.models import User from django.conf import settings from django.core.validators import MinValueValidator -from .rules import may_view, may_change, may_delete, is_own_training, is_oneself, is_leader, is_leader_of_excursion +from .rules import may_view, may_change, may_delete, is_own_training, is_oneself, is_leader, is_leader_of_excursion,\ + is_leader_of_relevant_invitation from .pdf import render_tex import rules from contrib.models import CommonModel @@ -642,6 +643,8 @@ class Member(Person): return self.filter_statements_by_permissions(queryset, annotate) elif name == "Freizeit": return self.filter_excursions_by_permissions(queryset, annotate) + elif name == "MemberWaitingList": + return self.filter_waiters_by_permissions(queryset, annotate) elif name == "LJPProposal": return queryset elif name == "MemberTraining": @@ -664,6 +667,8 @@ class Member(Person): return queryset elif name == "MemberUnconfirmedProxy": return queryset + elif name == "InvitationToGroup": + return queryset else: raise ValueError(name) @@ -742,6 +747,12 @@ class Member(Person): queryset = queryset.filter(Q(groups__in=groups) | Q(jugendleiter=self)).distinct() return queryset + def filter_waiters_by_permissions(self, queryset, annotate=False): + # ignores annotate + # return waiters that have a pending, expired or rejected group invitation for a group + # led by the member + return queryset.filter(invitationtogroup__group__leiters=self) + def may_list(self, other): if self.pk == other.pk: return True @@ -930,7 +941,7 @@ def gen_key(): return uuid.uuid4().hex -class InvitationToGroup(models.Model): +class InvitationToGroup(CommonModel): """An invitation of a waiter to a group.""" waiter = models.ForeignKey('MemberWaitingList', verbose_name=_('Waiter'), on_delete=models.CASCADE) group = models.ForeignKey(Group, verbose_name=_('Group'), on_delete=models.CASCADE) @@ -943,9 +954,15 @@ class InvitationToGroup(models.Model): on_delete=models.SET_NULL, related_name='created_group_invitations') - class Meta: + class Meta(CommonModel.Meta): verbose_name = _('Invitation to group') verbose_name_plural = _('Invitations to groups') + rules_permissions = { + 'add_obj': has_global_perm('members.add_global_memberwaitinglist'), + 'view_obj': is_leader_of_relevant_invitation | has_global_perm('members.view_global_memberwaitinglist'), + 'change_obj': has_global_perm('members.change_global_memberwaitinglist'), + 'delete_obj': has_global_perm('members.delete_global_memberwaitinglist'), + } def is_expired(self): return self.date < (timezone.now() - timezone.timedelta(days=30)).date() @@ -1052,7 +1069,7 @@ class MemberWaitingList(Person): permissions = (('may_manage_waiting_list', 'Can view and manage the waiting list.'),) rules_permissions = { 'add_obj': has_global_perm('members.add_global_memberwaitinglist'), - 'view_obj': has_global_perm('members.view_global_memberwaitinglist'), + 'view_obj': is_leader_of_relevant_invitation | has_global_perm('members.view_global_memberwaitinglist'), 'change_obj': has_global_perm('members.change_global_memberwaitinglist'), 'delete_obj': has_global_perm('members.delete_global_memberwaitinglist'), } diff --git a/jdav_web/members/rules.py b/jdav_web/members/rules.py index 4193f68..6c8762d 100644 --- a/jdav_web/members/rules.py +++ b/jdav_web/members/rules.py @@ -1,4 +1,5 @@ from contrib.rules import memberize_user +from django.utils import timezone from rules import predicate @@ -73,3 +74,10 @@ def statement_not_submitted(self, excursion): if excursion.statement is None: return False return not excursion.statement.submitted + + +@predicate +@memberize_user +def is_leader_of_relevant_invitation(member, waiter): + assert waiter is not None + return waiter.invitationtogroup_set.filter(group__leiters=member).exists() diff --git a/jdav_web/members/tests/basic.py b/jdav_web/members/tests/basic.py index 65d6b4b..6bb29df 100644 --- a/jdav_web/members/tests/basic.py +++ b/jdav_web/members/tests/basic.py @@ -180,6 +180,14 @@ class MemberTestCase(BasicMemberTestCase): self.assertQuerysetEqual(self.fritz.filter_statements_by_permissions(qs), [st1, st2], ordered=False) + def test_filter_waiters_by_permissions(self): + waiter = MemberWaitingList.objects.create(**WAITER_DATA) + MemberWaitingList.objects.create(**WAITER_DATA) + InvitationToGroup.objects.create(group=self.alp, waiter=waiter) + qs = MemberWaitingList.objects.all() + self.assertQuerysetEqual(self.lise.filter_waiters_by_permissions(qs), + [waiter], ordered=False) + def test_annotate_view_permissions(self): qs = Member.objects.all() # if the model is not Member, the queryset should not change @@ -489,6 +497,7 @@ class AdminTestCase(TestCase): trainer = create_custom_user('trainer', ['Standard', 'Trainings'], 'Lise', 'Lotte') treasurer = create_custom_user('treasurer', ['Standard', 'Finance'], 'Lara', 'Litte') materialwarden = create_custom_user('materialwarden', ['Standard', 'Material'], 'Loro', 'Lutte') + waitinglistmanager = create_custom_user('waitinglistmanager', ['Standard', 'Waitinglist'], 'Liri', 'Litti') paul = standard.member @@ -537,6 +546,8 @@ class PermissionTestCase(AdminTestCase): def test_standard_permissions(self): u = User.objects.get(username='standard') self.assertTrue(u.has_perm('members.view_member')) + self.assertTrue(u.has_perm('members.view_memberwaitinglist')) + self.assertFalse(u.has_perm('members.view_memberwaitinglist_global')) def test_queryset_standard(self): u = User.objects.get(username='standard') @@ -1330,6 +1341,22 @@ class MemberWaitingListAdminTestCase(AdminTestCase): request.user = u return request + def test_has_view_permission(self): + request = self.factory.get('/') + request.user = User.objects.get(username='standard') + self.assertTrue(self.admin.has_view_permission(request)) + self.assertFalse(self.admin.has_view_permission(request, self.waiter)) + + def test_changelist(self): + c = self._login('standard') + url = reverse('admin:members_memberwaitinglist_changelist') + response = c.get(url) + self.assertEqual(response.status_code, HTTPStatus.OK) + + c = self._login('waitinglistmanager') + response = c.get(url) + self.assertEqual(response.status_code, HTTPStatus.OK) + def test_age_eq_birth_date_delta(self): queryset = self.admin.get_queryset(self._request()) today = timezone.now().date() @@ -1463,11 +1490,12 @@ class MemberUnconfirmedAdminTestCase(AdminTestCase): response = c.get(url) self.assertEqual(response.status_code, HTTPStatus.FOUND) - def test_request_registration_form_insuficient_permission(self): + def test_request_registration_form_insufficient_permission(self): c = self._login('standard') url = reverse('admin:members_memberunconfirmedproxy_request_registration_form', args=(self.reg.pk,)) response = c.get(url, follow=True) - self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, _('Insufficient permissions.')) def test_request_registration_form(self): c = self._login('superuser') @@ -1549,10 +1577,8 @@ class MemberUnconfirmedAdminTestCase(AdminTestCase): c = self._login('standard') url = reverse('admin:members_memberunconfirmedproxy_changelist') response = c.get(url) - self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) - - c = self._login('superuser') - response = c.get(url) + # By default, standard users may access the member unconfirmed listing (but only view + # the relevant registrations) self.assertEqual(response.status_code, HTTPStatus.OK) def test_response_change_confirm(self): diff --git a/jdav_web/templates/admin/members/app_index.html b/jdav_web/templates/admin/members/app_index.html index dacacea..73b9050 100644 --- a/jdav_web/templates/admin/members/app_index.html +++ b/jdav_web/templates/admin/members/app_index.html @@ -50,7 +50,7 @@ Hier siehst du alle von dir geleiteten Ausfahrten und für dich sichtbare Teilne
-{% if perms.member.may_manage_waiting_list %}
+{% if perms.members.view_global_memberwaitinglist %}
Um ein neues Mitglied anzulegen, muss sich die Person
anmelden. Daraufhin landet
sie auf der Warteliste. Eine
@@ -59,6 +59,8 @@ Diese Einladung enthält einen Registrierungslink zu einem Formular in dem die P
Stammdaten eingbit. Diese Daten landen dann unter
Unbestätigte Registrierungen.
{% else %}
+Neue Teilnehmer:innen für deine Gruppen werden auf Anfrage von der Warteliste eingeladen. Die ausstehenden
+Einladungen für deine Gruppe siehst du unter Warteliste.
Ob über die Warteliste oder über ein Registrierungspasswort,
liegt eine neue Registrierung für eine von dir geleitete Jugendgruppe vor, kannst du die hier einsehen
und die Daten prüfen. Falls die Daten vollständig sind, bestätige die Registrierung um die Person in deine
@@ -66,7 +68,7 @@ Jugendgruppe aufzunehmen.
{% endif %}
|
Warteliste
diff --git a/jdav_web/templates/admin/members/memberwaitinglist/change_form_object_tools.html b/jdav_web/templates/admin/members/memberwaitinglist/change_form_object_tools.html
index 9b1112b..f5bdf54 100644
--- a/jdav_web/templates/admin/members/memberwaitinglist/change_form_object_tools.html
+++ b/jdav_web/templates/admin/members/memberwaitinglist/change_form_object_tools.html
@@ -3,10 +3,12 @@
{% block object-tools-items %}
+{% if perms.members.change_global_memberwaitinglist %}
{% url opts|admin_urlname:'invite' original.pk|admin_urlquote as invite_url %} {% trans 'Invite to group' %} +{% endif %} {% endblock %} |
|---|