members: add permission system

v1-0-stable
Christian Merten 3 years ago
parent b2b1c77043
commit 01ef2c43cc
Signed by: christian.merten
GPG Key ID: D953D69721B948B3

@ -195,6 +195,8 @@ ADMINS = (('admin', 'christian@merten-moser.de'),)
# Celery and Redis setup
BROKER_URL = os.environ.get('BROKER_URL', 'redis://localhost:6379/0')
CLOUD_LINK = 'https://cloud.jdav-ludwigsburg.de/index.php/s/qxQCTR8JqYSXXCQ'
# JET options (admin interface)
JET_SIDE_MENU_COMPACT = True
@ -214,36 +216,36 @@ JET_SIDE_MENU_ITEMS = [
{'name': 'solarschedule'},
]},
{'app_label': 'ludwigsburgalpin', 'permissions': ['ludwigsburgalpin'], 'items': [
{'name': 'termin'},
{'name': 'termin', 'permissions': ['ludwigsburgalpin.view_termin']},
]},
{'app_label': 'mailer', 'items': [
{'name': 'message'},
{'name': 'emailaddress'},
{'name': 'message', 'permissions': ['mailer.view_message']},
{'name': 'emailaddress', 'permissions': ['mailer.view_emailaddress']},
]},
{'app_label': 'finance', 'items': [
{'name': 'statementunsubmitted'},
{'name': 'statementsubmitted'},
{'name': 'statementconfirmed'},
{'name': 'ledger'},
{'name': 'bill'},
{'name': 'transaction'},
{'name': 'statementunsubmitted', 'permissions': ['finance.view_statementunsubmitted']},
{'name': 'statementsubmitted', 'permissions': ['finance.view_statementsubmitted']},
{'name': 'statementconfirmed', 'permissions': ['finance.view_statementconfirmed']},
{'name': 'ledger', 'permissions': ['finance.view_ledger']},
{'name': 'bill', 'permissions': ['finance.view_bill', 'finance.view_bill_admin']},
{'name': 'transaction', 'permissions': ['finance.view_transaction']},
]},
{'app_label': 'members', 'items': [
{'name': 'member'},
{'name': 'group'},
{'name': 'membernotelist'},
{'name': 'freizeit'},
{'name': 'klettertreff'},
{'name': 'member', 'permissions': ['members.view_member']},
{'name': 'group', 'permissions': ['members.view_group']},
{'name': 'membernotelist', 'permissions': ['members.view_membernotelist']},
{'name': 'freizeit', 'permissions': ['members.view_freizeit']},
{'name': 'klettertreff', 'permissions': ['members.view_klettertreff']},
{'name': 'activitycategory', 'permissions': ['members.view_activitycategory']},
{'name': 'memberunconfirmedproxy', 'permissions': ['members.view_memberunconfirmedproxy']},
{'name': 'memberwaitinglist', 'permissions': ['members.view_memberwaitinglist']},
]},
{'app_label': 'material', 'items': [
{'app_label': 'material', 'permissions': ['material'], 'items': [
{'name': 'materialcategory', 'permissions': ['material.view_materialcategory']},
{'name': 'materialpart'},
{'name': 'materialpart', 'permissions': ['material.view_materialpart']},
]},
{'label': 'Externe Links', 'items' : [
{ 'label': 'Packlisten und Co.', 'url': 'https://cloud.jdav-ludwigsburg.de/index.php/s/qxQCTR8JqYSXXCQ'}
{ 'label': 'Packlisten und Co.', 'url': CLOUD_LINK }
]},
]
@ -405,3 +407,7 @@ CONGRATULATE_MEMBERS_MAX = 10
ALLOWANCE_PER_DAY = 10
MAX_NIGHT_COST = 11
# testing
TEST_MAIL = "post@flavigny.de"

@ -8,6 +8,7 @@ import unicodedata
import random
import string
from functools import partial, update_wrapper
from django.forms.models import BaseInlineFormSet
from django.contrib.admin.templatetags.admin_urls import add_preserved_filters
from django.template.loader import get_template
@ -24,12 +25,14 @@ from django.db.models import TextField, ManyToManyField, ForeignKey, Count,\
Sum, Case, Q, F, When, Value, IntegerField, Subquery, OuterRef
from django.forms import Textarea, RadioSelect, TypedChoiceField
from django.shortcuts import render
from django.core.exceptions import PermissionDenied
from .pdf import render_tex
import nested_admin
from .models import (Member, Group, Freizeit, MemberNoteList, NewMemberOnList, Klettertreff,
MemberWaitingList, LJPProposal, Intervention,
MemberWaitingList, LJPProposal, Intervention, PermissionMember,
PermissionGroup,
KlettertreffAttendee, ActivityCategory, OldMemberOnList, MemberList,
annotate_activity_score, RegistrationPassword, MemberUnconfirmedProxy)
from finance.models import Statement, Bill
@ -38,6 +41,54 @@ from django.conf import settings
#from easy_select2 import apply_select2
class FilteredMemberFieldMixin:
def formfield_for_foreignkey(self, db_field, request=None, **kwargs):
"""
Override the queryset for member foreign key fields.
"""
field = super().formfield_for_foreignkey(db_field, request, **kwargs)
if db_field.related_model != Member:
return field
if request is None:
field.queryset = Member.objects.none()
elif request.user.has_perm('members.may_list_everyone'):
field.queryset = Member.objects.all()
elif not hasattr(request.user, 'member'):
field.queryset = Member.objects.none()
else:
field.queryset = request.user.member.filter_queryset_by_permissions()
return field
def formfield_for_manytomany(self, db_field, request=None, **kwargs):
"""
Override the queryset for member many to many fields.
"""
field = super().formfield_for_foreignkey(db_field, request, **kwargs)
if db_field.related_model != Member:
return field
if request is None:
field.queryset = Member.objects.none()
elif request.user.has_perm('members.may_list_everyone'):
field.queryset = Member.objects.all()
elif not hasattr(request.user, 'member'):
field.queryset = Member.objects.none()
else:
field.queryset = request.user.member.filter_queryset_by_permissions()
return field
class PermissionOnGroupInline(admin.StackedInline):
model = PermissionGroup
extra = 1
class PermissionOnMemberInline(admin.StackedInline):
model = PermissionMember
extra = 1
class RegistrationFilter(admin.SimpleListFilter):
title = _('Registration complete')
parameter_name = 'registered'
@ -82,10 +133,12 @@ class MemberAdmin(admin.ModelAdmin):
fields = ['prename', 'lastname', 'email', 'email_parents', 'cc_email_parents', 'street', 'plz',
'town', 'phone_number', 'phone_number_parents', 'birth_date', 'group', 'iban',
'gets_newsletter', 'registered', 'registration_form', 'active', 'echoed', 'comments']
list_display = ('name', 'birth_date', 'age', 'get_group', 'gets_newsletter',
list_display = ('name_text_or_link', 'birth_date', 'age', 'get_group', 'gets_newsletter',
'registered', 'active', 'echoed', 'comments', 'activity_score')
search_fields = ('prename', 'lastname', 'email')
list_filter = ('group', 'gets_newsletter', RegistrationFilter, 'active')
list_display_links = None
inlines = [PermissionOnMemberInline]
#formfield_overrides = {
# ManyToManyField: {'widget': forms.CheckboxSelectMultiple},
# ForeignKey: {'widget': apply_select2(forms.Select)}
@ -94,6 +147,32 @@ class MemberAdmin(admin.ModelAdmin):
#ordering = ('activity_score',)
actions = ['send_mail_to', 'request_echo']
sensitive_fields = ['iban', 'registration_form', 'comments']
def has_view_permission(self, request, obj=None):
user = request.user
if request.user.has_perm('members.may_view_everyone'):
return True
if not hasattr(user, 'member'):
return False
if obj is None:
return True
return request.user.member.may_view(obj)
def has_change_permission(self, request, obj=None):
user = request.user
if request.user.has_perm('members.may_change_everyone'):
return True
if not hasattr(user, 'member'):
return False
if obj is None:
return True
return request.user.member.may_change(obj)
def get_fields(self, request, obj=None):
if request.user.has_perm('members.may_set_auth_user'):
if 'user' not in self.fields:
@ -105,9 +184,17 @@ class MemberAdmin(admin.ModelAdmin):
def get_queryset(self, request):
queryset = super().get_queryset(request)
if request.user.has_perm('members.may_list_everyone'):
return annotate_activity_score(queryset)
if not hasattr(request.user, 'member'):
return Member.objects.none()
queryset = request.user.member.filter_queryset_by_permissions(queryset, annotate=True)
return annotate_activity_score(queryset)
def change_view(self, request, object_id, form_url="", extra_context=None):
try:
extra_context = extra_context or {}
extra_context['qualities'] =\
Member.objects.get(pk=object_id).get_skills()
@ -116,6 +203,13 @@ class MemberAdmin(admin.ModelAdmin):
return super(MemberAdmin, self).change_view(request, object_id,
form_url=form_url,
extra_context=extra_context)
except Member.DoesNotExist:
return super().change_view(request, object_id)
except PermissionDenied:
member = Member.objects.get(pk=object_id)
messages.error(request,
_("You are not allowed to view %(name)s.") % {'name': member.name})
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name)))
def send_mail_to(self, request, queryset):
member_pks = [m.pk for m in queryset]
@ -152,6 +246,16 @@ class MemberAdmin(admin.ModelAdmin):
activity_score.admin_order_field = '_activity_score'
activity_score.short_description = _('activity')
def name_text_or_link(self, obj):
name = obj.name
if not hasattr(obj, '_viewable') or obj._viewable:
return format_html('<a href="{link}">{name}</a>'.format(
link=reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(obj.pk,)),
name=obj.name))
else:
return obj.name
name_text_or_link.short_description = _('Name')
class MemberUnconfirmedAdmin(admin.ModelAdmin):
fields = ['prename', 'lastname', 'email', 'email_parents', 'cc_email_parents', 'street', 'plz',
@ -355,6 +459,7 @@ class GroupAdminForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super(GroupAdminForm, self).__init__(*args, **kwargs)
if 'leiters' in self.fields:
self.fields['leiters'].queryset = Member.objects.filter(group__name='Jugendleiter')
@ -362,7 +467,7 @@ class GroupAdmin(admin.ModelAdmin):
fields = ['name', 'year_from', 'year_to', 'leiters']
form = GroupAdminForm
list_display = ('name', 'year_from', 'year_to')
inlines = [RegistrationPasswordInline]
inlines = [RegistrationPasswordInline, PermissionOnGroupInline]
class ActivityCategoryAdmin(admin.ModelAdmin):
@ -386,11 +491,12 @@ class FreizeitAdminForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super(FreizeitAdminForm, self).__init__(*args, **kwargs)
self.fields['jugendleiter'].queryset = Member.objects.filter(group__name='Jugendleiter')
q = self.fields['jugendleiter'].queryset
self.fields['jugendleiter'].queryset = q.filter(group__name='Jugendleiter')
#self.fields['add_member'].queryset = Member.objects.filter(prename__startswith='F')
class BillOnStatementInline(admin.TabularInline):
class BillOnStatementInline(FilteredMemberFieldMixin, admin.TabularInline):
model = Bill
extra = 0
sortable_options = []
@ -434,7 +540,7 @@ class LJPOnListInline(nested_admin.NestedStackedInline):
inlines = [InterventionOnLJPInline]
class MemberOnListInline(GenericTabularInline):
class MemberOnListInline(FilteredMemberFieldMixin, GenericTabularInline):
model = NewMemberOnList
extra = 0
formfield_overrides = {
@ -579,7 +685,8 @@ class MemberListAdmin(admin.ModelAdmin):
messages.info(request, "Teilnehmerlist(en) erfolgreich erstellt.")
migrate_to_notelist.short_description = "Aus Teilnehmerliste(n) Notizliste erstellen"
class FreizeitAdmin(nested_admin.NestedModelAdmin):
class FreizeitAdmin(FilteredMemberFieldMixin, nested_admin.NestedModelAdmin):
inlines = [MemberOnListInline, LJPOnListInline, StatementOnListInline]
form = FreizeitAdminForm
list_display = ['__str__', 'date']
@ -598,22 +705,54 @@ class FreizeitAdmin(nested_admin.NestedModelAdmin):
def __init__(self, *args, **kwargs):
super(FreizeitAdmin, self).__init__(*args, **kwargs)
def get_queryset(self, request):
queryset = super().get_queryset(request)
if request.user.has_perm('members.may_list_all_excursions'):
return queryset
if not hasattr(request.user, 'member'):
return Member.objects.none()
groups = request.user.member.leited_groups.all()
# one may view all leited groups and oneself
queryset = queryset.filter(Q(groups__in=groups) | Q(jugendleiter__pk=request.user.member.pk)).distinct()
return queryset
def may_view_excursion(self, request, memberlist):
return request.user.has_perm('members.may_view_everyone') or \
( hasattr(request.user, 'member') and \
all([request.user.member.may_view(m.member) for m in memberlist.membersonlist.all()]) )
def not_allowed_view(self, request, memberlist):
messages.error(request,
_("You are not allowed to view all members on excursion %(name)s.") % {'name': memberlist.name})
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name)))
def crisis_intervention_list(self, request, queryset):
for memberlist in queryset:
context = dict(memberlist=memberlist)
# this ensures legacy compatibilty
memberlist = queryset[0]
if not self.may_view_excursion(request, memberlist):
return self.not_allowed_view(request, memberlist)
context = dict(memberlist=memberlist, settings=settings)
return render_tex(memberlist.name + "_Krisenliste", 'members/crisis_intervention_list.tex', context)
crisis_intervention_list.short_description = _('Generate crisis intervention list')
def notes_list(self, request, queryset):
for memberlist in queryset:
# this ensures legacy compatibilty
memberlist = queryset[0]
if not self.may_view_excursion(request, memberlist):
return self.not_allowed_view(request, memberlist)
people, skills = memberlist.skill_summary
context = dict(memberlist=memberlist, people=people, skills=skills)
context = dict(memberlist=memberlist, people=people, skills=skills, settings=settings)
return render_tex(memberlist.name + "_Notizen", 'members/notes_list.tex', context)
notes_list.short_description = _('Generate overview')
def seminar_report(self, request, queryset):
for memberlist in queryset:
context = dict(memberlist=memberlist)
# this ensures legacy compatibilty
memberlist = queryset[0]
if not self.may_view_excursion(request, memberlist):
return self.not_allowed_view(request, memberlist)
context = dict(memberlist=memberlist, settings=settings)
title = memberlist.ljpproposal.title if hasattr(memberlist, 'ljpproposal') else memberlist.name
return render_tex(title + "_Seminarbericht", 'members/seminar_report.tex', context)
seminar_report.short_description = _('Generate seminar report')

@ -309,6 +309,104 @@ class Member(Person):
settings.DEFAULT_SENDING_MAIL,
jl.email)
def filter_queryset_by_permissions(self, queryset=None, annotate=False):
if queryset is None:
queryset = Member.objects.all()
# every member may list themself
pks = [self.pk]
view_pks = [self.pk]
if hasattr(self, 'permissions'):
pks += [ m.pk for m in self.permissions.list_members.all() ]
view_pks += [ m.pk for m in self.permissions.view_members.all() ]
for group in self.permissions.list_groups.all():
pks += [ m.pk for m in group.member_set.all() ]
for group in self.permissions.view_groups.all():
view_pks += [ m.pk for m in group.member_set.all() ]
for group in self.group.all():
if hasattr(group, 'permissions'):
pks += [ m.pk for m in group.permissions.list_members.all() ]
view_pks += [ m.pk for m in group.permissions.view_members.all() ]
for gr in group.permissions.list_groups.all():
pks += [ m.pk for m in gr.member_set.all()]
for gr in group.permissions.view_groups.all():
view_pks += [ m.pk for m in gr.member_set.all()]
filtered = queryset.filter(pk__in=pks)
if not annotate:
return filtered
return filtered.annotate(_viewable=Case(When(pk__in=view_pks, then=Value(True)), default=Value(False), output_field=models.BooleanField()))
def may_list(self, other):
if self.pk == other.pk:
return True
if hasattr(self, 'permissions'):
if other in self.permissions.list_members.all():
return True
if any([gr in other.group.all() for gr in self.permissions.list_groups.all()]):
return True
for group in self.group.all():
if hasattr(group, 'permissions'):
if other in group.permissions.list_members.all():
return True
if any([gr in other.group.all() for gr in group.permissions.list_groups.all()]):
return True
return False
def may_view(self, other):
if self.pk == other.pk:
return True
if hasattr(self, 'permissions'):
if other in self.permissions.view_members.all():
return True
if any([gr in other.group.all() for gr in self.permissions.view_groups.all()]):
return True
for group in self.group.all():
if hasattr(group, 'permissions'):
if other in group.permissions.view_members.all():
return True
if any([gr in other.group.all() for gr in group.permissions.view_groups.all()]):
return True
return False
def may_change(self, other):
if self.pk == other.pk:
return True
if hasattr(self, 'permissions'):
if other in self.permissions.change_members.all():
return True
if any([gr in other.group.all() for gr in self.permissions.change_groups.all()]):
return True
for group in self.group.all():
if hasattr(group, 'permissions'):
if other in group.permissions.change_members.all():
return True
if any([gr in other.group.all() for gr in group.permissions.change_groups.all()]):
return True
return False
class MemberUnconfirmedManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(confirmed=False)
@ -866,3 +964,33 @@ def annotate_activity_score(queryset):
+ F('_jugendleiter_klettertreff_score') + 3 * F('_jugendleiter_freizeit_score'))
)
return queryset
class PermissionMember(models.Model):
member = models.OneToOneField(Member, on_delete=models.CASCADE, related_name='permissions')
# every member of view_members may view this member
list_members = models.ManyToManyField(Member, related_name='listable_by', blank=True)
view_members = models.ManyToManyField(Member, related_name='viewable_by', blank=True)
change_members = models.ManyToManyField(Member, related_name='changeable_by', blank=True)
delete_members = models.ManyToManyField(Member, related_name='deletable_by', blank=True)
# every member in any view_group may view this member
list_groups = models.ManyToManyField(Group, related_name='listable_by', blank=True)
view_groups = models.ManyToManyField(Group, related_name='viewable_by', blank=True)
change_groups = models.ManyToManyField(Group, related_name='changeable_by', blank=True)
delete_groups = models.ManyToManyField(Group, related_name='deletable_by', blank=True)
class PermissionGroup(models.Model):
group = models.OneToOneField(Group, on_delete=models.CASCADE, related_name='permissions')
# every member of view_members may view all members of group
list_members = models.ManyToManyField(Member, related_name='group_members_listable_by', blank=True)
view_members = models.ManyToManyField(Member, related_name='group_members_viewable_by', blank=True)
change_members = models.ManyToManyField(Member, related_name='group_members_changeable_by_group', blank=True)
delete_members = models.ManyToManyField(Member, related_name='group_members_deletable_by', blank=True)
# every member in any view_group may view all members of group
list_groups = models.ManyToManyField(Group, related_name='group_members_listable_by', blank=True)
view_groups = models.ManyToManyField(Group, related_name='group_members_viewable_by', blank=True)
change_groups = models.ManyToManyField(Group, related_name='group_members_changeable_by', blank=True)
delete_groups = models.ManyToManyField(Group, related_name='group_members_deletable_by', blank=True)

@ -1,3 +1,89 @@
from django.test import TestCase
from django.utils import timezone
from django.conf import settings
from .models import Member, Group, PermissionMember, PermissionGroup
# Create your tests here.
class MemberTestCase(TestCase):
def setUp(self):
self.jl = Group.objects.create(name="Jugendleiter")
self.alp = Group.objects.create(name="Alpenfuechse")
self.spiel = Group.objects.create(name="Spielkinder")
self.fritz = Member.objects.create(prename="Fritz", lastname="Wulter", birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
self.fritz.group.add(self.jl)
self.fritz.group.add(self.alp)
self.fritz.save()
self.lara = Member.objects.create(prename="Lara", lastname="Wallis", birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
self.lara.group.add(self.alp)
self.lara.save()
self.fridolin = Member.objects.create(prename="Fridolin", lastname="Spargel", birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
self.fridolin.group.add(self.alp)
self.fridolin.group.add(self.spiel)
self.fridolin.save()
self.lise = Member.objects.create(prename="Lise", lastname="Lotte", birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
p1 = PermissionMember.objects.create(member=self.fritz)
p1.view_members.add(self.lara)
p1.change_members.add(self.lara)
p1.view_groups.add(self.spiel)
self.ja = Group.objects.create(name="Jugendausschuss")
self.peter = Member.objects.create(prename="Peter", lastname="Keks", birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
self.anna = Member.objects.create(prename="Anna", lastname="Keks", birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
self.lisa = Member.objects.create(prename="Lisa", lastname="Keks", birth_date=timezone.now().date(),
email=settings.TEST_MAIL)
self.peter.group.add(self.ja)
self.anna.group.add(self.ja)
self.lisa.group.add(self.ja)
p2 = PermissionGroup.objects.create(group=self.ja)
p2.list_groups.add(self.ja)
def test_may(self):
self.assertTrue(self.fritz.may_view(self.lara))
self.assertTrue(self.fritz.may_change(self.lara))
self.assertTrue(self.fritz.may_view(self.fridolin))
self.assertFalse(self.fritz.may_change(self.fridolin))
# every member should be able to list, view and change themselves
for member in Member.objects.all():
self.assertTrue(member.may_list(member))
self.assertTrue(member.may_view(member))
self.assertTrue(member.may_change(member))
# every member of Jugendausschuss should be able to view every other member of Jugendausschuss
for member in self.ja.member_set.all():
for other in self.ja.member_set.all():
self.assertTrue(member.may_list(other))
if member != other:
self.assertFalse(member.may_view(other))
self.assertFalse(member.may_change(other))
def test_filter_queryset(self):
# lise may only list herself
self.assertEqual(set(self.lise.filter_queryset_by_permissions()), set([self.lise]))
for member in Member.objects.all():
# passing the empty queryset as starting queryset, should give the empty queryset back
self.assertEqual(member.filter_queryset_by_permissions(Member.objects.none()).count(), 0)
# passing all objects as start queryset should give the same result as not giving any start queryset
self.assertEqual(set(member.filter_queryset_by_permissions(Member.objects.all())),
set(member.filter_queryset_by_permissions()))
def test_compare_filter_queryset_may_list(self):
# filter_queryset and filtering manually by may_list should be the same
for member in Member.objects.all():
s1 = set(member.filter_queryset_by_permissions())
s2 = set(other for other in Member.objects.all() if member.may_list(other))
self.assertEqual(s1, s2)

Loading…
Cancel
Save