feat(members/admin): enable waitinglist and memberunoconfirmed view for standard users

We change the default permissions for the standard user group to allow viewing the unconfirmed registrations
and waitinglist views (Most projects already manually added the permissions for unconfirmed registrations).
Standard users only see waiters that have a group invitation (active, expired or rejected) to a group
they are a youth leader of.
pull/174/head
Christian Merten 3 months ago
parent 342227624a
commit 6d542456fa
Signed by: christian.merten
GPG Key ID: D953D69721B948B3

@ -585,7 +585,7 @@ class WaiterInviteTextForm(forms.Form):
widget=forms.Textarea(attrs={'rows': 30, 'cols': 100})) widget=forms.Textarea(attrs={'rows': 30, 'cols': 100}))
class InvitationToGroupAdmin(admin.TabularInline): class InvitationToGroupAdmin(CommonAdminInlineMixin, admin.TabularInline):
model = InvitationToGroup model = InvitationToGroup
fields = ['group', 'date', 'status'] fields = ['group', 'date', 'status']
readonly_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): def has_add_permission(self, request, obj=None):
return False return False
def has_action_permission(self, request):
return request.user.has_perm('members.change_global_memberwaitinglist')
def age(self, obj): def age(self, obj):
return obj.birth_date_delta return obj.birth_date_delta
age.short_description=_('age') age.short_description=_('age')
@ -652,6 +655,7 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin):
messages.success(request, messages.success(request,
_("Successfully asked %(name)s to confirm their waiting status.") % {'name': waiter.name}) _("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.short_description = _('Ask selected waiters to confirm their waiting status')
ask_for_wait_confirmation.allowed_permissions = ('action',)
def response_change(self, request, waiter): def response_change(self, request, waiter):
ret = super(MemberWaitingListAdmin, self).response_change(request, waiter) ret = super(MemberWaitingListAdmin, self).response_change(request, waiter)
@ -666,12 +670,14 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin):
member.request_mail_confirmation() member.request_mail_confirmation()
messages.success(request, _("Successfully requested mail confirmation from selected waiters.")) messages.success(request, _("Successfully requested mail confirmation from selected waiters."))
request_mail_confirmation.short_description = _('Request 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): def request_required_mail_confirmation(self, request, queryset):
for member in queryset: for member in queryset:
member.request_mail_confirmation(rerequest=False) member.request_mail_confirmation(rerequest=False)
messages.success(request, _("Successfully re-requested missing mail confirmations from selected waiters.")) 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.short_description = _('Re-request missing mail confirmations from selected waiters.')
request_required_mail_confirmation.allowed_permissions = ('action',)
def get_urls(self): def get_urls(self):
urls = super().get_urls() urls = super().get_urls()
@ -711,6 +717,7 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin):
def ask_for_registration_action(self, request, queryset): def ask_for_registration_action(self, request, queryset):
return self.invite_view(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.short_description = _('Offer waiter a place in a group.')
ask_for_registration_action.allowed_permissions = ('action',)
def invite_view(self, request, object_id): def invite_view(self, request, object_id):
if type(object_id) == str: if type(object_id) == str:

@ -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),
]

@ -24,7 +24,8 @@ from django.contrib.auth.models import User
from django.conf import settings from django.conf import settings
from django.core.validators import MinValueValidator 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 from .pdf import render_tex
import rules import rules
from contrib.models import CommonModel from contrib.models import CommonModel
@ -642,6 +643,8 @@ class Member(Person):
return self.filter_statements_by_permissions(queryset, annotate) return self.filter_statements_by_permissions(queryset, annotate)
elif name == "Freizeit": elif name == "Freizeit":
return self.filter_excursions_by_permissions(queryset, annotate) return self.filter_excursions_by_permissions(queryset, annotate)
elif name == "MemberWaitingList":
return self.filter_waiters_by_permissions(queryset, annotate)
elif name == "LJPProposal": elif name == "LJPProposal":
return queryset return queryset
elif name == "MemberTraining": elif name == "MemberTraining":
@ -664,6 +667,8 @@ class Member(Person):
return queryset return queryset
elif name == "MemberUnconfirmedProxy": elif name == "MemberUnconfirmedProxy":
return queryset return queryset
elif name == "InvitationToGroup":
return queryset
else: else:
raise ValueError(name) raise ValueError(name)
@ -742,6 +747,12 @@ class Member(Person):
queryset = queryset.filter(Q(groups__in=groups) | Q(jugendleiter=self)).distinct() queryset = queryset.filter(Q(groups__in=groups) | Q(jugendleiter=self)).distinct()
return queryset 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): def may_list(self, other):
if self.pk == other.pk: if self.pk == other.pk:
return True return True
@ -930,7 +941,7 @@ def gen_key():
return uuid.uuid4().hex return uuid.uuid4().hex
class InvitationToGroup(models.Model): class InvitationToGroup(CommonModel):
"""An invitation of a waiter to a group.""" """An invitation of a waiter to a group."""
waiter = models.ForeignKey('MemberWaitingList', verbose_name=_('Waiter'), on_delete=models.CASCADE) waiter = models.ForeignKey('MemberWaitingList', verbose_name=_('Waiter'), on_delete=models.CASCADE)
group = models.ForeignKey(Group, verbose_name=_('Group'), 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, on_delete=models.SET_NULL,
related_name='created_group_invitations') related_name='created_group_invitations')
class Meta: class Meta(CommonModel.Meta):
verbose_name = _('Invitation to group') verbose_name = _('Invitation to group')
verbose_name_plural = _('Invitations to groups') 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): def is_expired(self):
return self.date < (timezone.now() - timezone.timedelta(days=30)).date() 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.'),) permissions = (('may_manage_waiting_list', 'Can view and manage the waiting list.'),)
rules_permissions = { rules_permissions = {
'add_obj': has_global_perm('members.add_global_memberwaitinglist'), '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'), 'change_obj': has_global_perm('members.change_global_memberwaitinglist'),
'delete_obj': has_global_perm('members.delete_global_memberwaitinglist'), 'delete_obj': has_global_perm('members.delete_global_memberwaitinglist'),
} }

@ -1,4 +1,5 @@
from contrib.rules import memberize_user from contrib.rules import memberize_user
from django.utils import timezone
from rules import predicate from rules import predicate
@ -73,3 +74,10 @@ def statement_not_submitted(self, excursion):
if excursion.statement is None: if excursion.statement is None:
return False return False
return not excursion.statement.submitted 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()

@ -180,6 +180,14 @@ class MemberTestCase(BasicMemberTestCase):
self.assertQuerysetEqual(self.fritz.filter_statements_by_permissions(qs), self.assertQuerysetEqual(self.fritz.filter_statements_by_permissions(qs),
[st1, st2], ordered=False) [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): def test_annotate_view_permissions(self):
qs = Member.objects.all() qs = Member.objects.all()
# if the model is not Member, the queryset should not change # 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') trainer = create_custom_user('trainer', ['Standard', 'Trainings'], 'Lise', 'Lotte')
treasurer = create_custom_user('treasurer', ['Standard', 'Finance'], 'Lara', 'Litte') treasurer = create_custom_user('treasurer', ['Standard', 'Finance'], 'Lara', 'Litte')
materialwarden = create_custom_user('materialwarden', ['Standard', 'Material'], 'Loro', 'Lutte') materialwarden = create_custom_user('materialwarden', ['Standard', 'Material'], 'Loro', 'Lutte')
waitinglistmanager = create_custom_user('waitinglistmanager', ['Standard', 'Waitinglist'], 'Liri', 'Litti')
paul = standard.member paul = standard.member
@ -537,6 +546,8 @@ class PermissionTestCase(AdminTestCase):
def test_standard_permissions(self): def test_standard_permissions(self):
u = User.objects.get(username='standard') u = User.objects.get(username='standard')
self.assertTrue(u.has_perm('members.view_member')) 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): def test_queryset_standard(self):
u = User.objects.get(username='standard') u = User.objects.get(username='standard')
@ -1330,6 +1341,22 @@ class MemberWaitingListAdminTestCase(AdminTestCase):
request.user = u request.user = u
return request 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): def test_age_eq_birth_date_delta(self):
queryset = self.admin.get_queryset(self._request()) queryset = self.admin.get_queryset(self._request())
today = timezone.now().date() today = timezone.now().date()
@ -1463,11 +1490,12 @@ class MemberUnconfirmedAdminTestCase(AdminTestCase):
response = c.get(url) response = c.get(url)
self.assertEqual(response.status_code, HTTPStatus.FOUND) 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') c = self._login('standard')
url = reverse('admin:members_memberunconfirmedproxy_request_registration_form', args=(self.reg.pk,)) url = reverse('admin:members_memberunconfirmedproxy_request_registration_form', args=(self.reg.pk,))
response = c.get(url, follow=True) 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): def test_request_registration_form(self):
c = self._login('superuser') c = self._login('superuser')
@ -1549,10 +1577,8 @@ class MemberUnconfirmedAdminTestCase(AdminTestCase):
c = self._login('standard') c = self._login('standard')
url = reverse('admin:members_memberunconfirmedproxy_changelist') url = reverse('admin:members_memberunconfirmedproxy_changelist')
response = c.get(url) response = c.get(url)
self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) # By default, standard users may access the member unconfirmed listing (but only view
# the relevant registrations)
c = self._login('superuser')
response = c.get(url)
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
def test_response_change_confirm(self): def test_response_change_confirm(self):

@ -50,7 +50,7 @@ Hier siehst du alle von dir geleiteten Ausfahrten und für dich sichtbare Teilne
<div class="app-members module current-app"> <div class="app-members module current-app">
<h2>Neue Mitglieder</h2> <h2>Neue Mitglieder</h2>
<p> <p>
{% if perms.member.may_manage_waiting_list %} {% if perms.members.view_global_memberwaitinglist %}
Um ein neues Mitglied anzulegen, muss sich die Person Um ein neues Mitglied anzulegen, muss sich die Person
<a href="{% url 'members:register_waiting_list' %}">anmelden</a>. Daraufhin landet <a href="{% url 'members:register_waiting_list' %}">anmelden</a>. Daraufhin landet
sie auf der <a href="{% url 'admin:members_memberwaitinglist_changelist' %}">Warteliste</a>. Eine sie auf der <a href="{% url 'admin:members_memberwaitinglist_changelist' %}">Warteliste</a>. 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 Stammdaten eingbit. Diese Daten landen dann unter
<a href="{% url 'admin:members_memberunconfirmedproxy_changelist' %}">Unbestätigte Registrierungen</a>. <a href="{% url 'admin:members_memberunconfirmedproxy_changelist' %}">Unbestätigte Registrierungen</a>.
{% else %} {% 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.<br>
Ob über die Warteliste oder über ein Registrierungspasswort, 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 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 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 %} {% endif %}
</p> </p>
<table> <table>
{% if perms.member.may_manage_waiting_list %} {% if perms.members.view_memberwaitinglist %}
<tr> <tr>
<th scope="row"> <th scope="row">
<a href="{% url 'admin:members_memberwaitinglist_changelist' %}">Warteliste</a> <a href="{% url 'admin:members_memberwaitinglist_changelist' %}">Warteliste</a>

@ -3,10 +3,12 @@
{% block object-tools-items %} {% block object-tools-items %}
{% if perms.members.change_global_memberwaitinglist %}
<li> <li>
{% url opts|admin_urlname:'invite' original.pk|admin_urlquote as invite_url %} {% url opts|admin_urlname:'invite' original.pk|admin_urlquote as invite_url %}
<a class="historylink" href="{% add_preserved_filters invite_url %}">{% trans 'Invite to group' %}</a> <a class="historylink" href="{% add_preserved_filters invite_url %}">{% trans 'Invite to group' %}</a>
</li> </li>
{% endif %}
{{block.super}} {{block.super}}

@ -4,10 +4,12 @@
{% block submit-row %} {% block submit-row %}
{{block.super}} {{block.super}}
{% if perms.members.change_global_memberwaitinglist %}
<p class="deletelink-box"> <p class="deletelink-box">
{% url opts|admin_urlname:'invite' original.pk|admin_urlquote as invite_url %} {% url opts|admin_urlname:'invite' original.pk|admin_urlquote as invite_url %}
<a class="button" style="" href="{% add_preserved_filters invite_url %}">{% trans 'Invite to group' %}</a> <a class="button" style="" href="{% add_preserved_filters invite_url %}">{% trans 'Invite to group' %}</a>
</p> </p>
{% endif %}
{% endblock %} {% endblock %}

Loading…
Cancel
Save