From d5b010980b6e1da604ae0b345c1fbb4c827e420c Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 1 Apr 2020 03:42:19 +0200 Subject: [PATCH] Full membership support --- apps/logs/signals.py | 6 +++ apps/member/fixtures/initial.json | 1 + apps/member/forms.py | 6 +-- apps/member/models.py | 40 +++++++++++++--- apps/member/tables.py | 38 ++++++++++++++- apps/member/urls.py | 9 ++-- apps/member/views.py | 77 +++++++++++++++++++++++++++++-- apps/note/admin.py | 7 +++ apps/note/models/transactions.py | 1 + apps/permission/backends.py | 5 +- apps/permission/models.py | 4 +- apps/permission/signals.py | 26 +++-------- note_kfet/inputs.py | 1 - templates/member/club_info.html | 11 +++-- 14 files changed, 186 insertions(+), 46 deletions(-) diff --git a/apps/logs/signals.py b/apps/logs/signals.py index 43fc1e13..37e9ba79 100644 --- a/apps/logs/signals.py +++ b/apps/logs/signals.py @@ -50,6 +50,9 @@ def save_object(sender, instance, **kwargs): if instance._meta.label_lower in EXCLUDED: return + if hasattr(instance, "_force_save"): + return + # noinspection PyProtectedMember previous = instance._previous @@ -106,6 +109,9 @@ def delete_object(sender, instance, **kwargs): if instance._meta.label_lower in EXCLUDED: return + if hasattr(instance, "_force_delete"): + return + # Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP user, ip = get_current_authenticated_user(), get_current_ip() diff --git a/apps/member/fixtures/initial.json b/apps/member/fixtures/initial.json index 094d2c3f..d72377f8 100644 --- a/apps/member/fixtures/initial.json +++ b/apps/member/fixtures/initial.json @@ -18,6 +18,7 @@ "fields": { "name": "Kfet", "email": "tresorerie.bde@example.com", + "parent_club": 1, "require_memberships": true, "membership_fee": 3500, "membership_duration": 396, diff --git a/apps/member/forms.py b/apps/member/forms.py index 9ac4ffa9..87d5322e 100644 --- a/apps/member/forms.py +++ b/apps/member/forms.py @@ -4,6 +4,7 @@ from django import forms from django.contrib.auth.forms import UserCreationForm, AuthenticationForm from django.contrib.auth.models import User +from django.utils.translation import gettext_lazy as _ from note_kfet.inputs import Autocomplete, AmountInput, DatePickerInput from permission.models import PermissionMask @@ -57,11 +58,6 @@ class ClubForm(forms.ModelForm): } -class AddMembersForm(forms.Form): - class Meta: - fields = ('',) - - class MembershipForm(forms.ModelForm): class Meta: model = Membership diff --git a/apps/member/models.py b/apps/member/models.py index 8906af9c..180839d8 100644 --- a/apps/member/models.py +++ b/apps/member/models.py @@ -4,11 +4,14 @@ import datetime from django.conf import settings -from django.core.exceptions import ValidationError +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError, PermissionDenied from django.db import models from django.urls import reverse, reverse_lazy from django.utils.translation import gettext_lazy as _ +from note.models import MembershipTransaction + class Profile(models.Model): """ @@ -91,7 +94,7 @@ class Club(models.Model): verbose_name=_('membership fee'), ) - membership_duration = models.IntegerField( + membership_duration = models.PositiveIntegerField( blank=True, null=True, verbose_name=_('membership duration'), @@ -174,7 +177,7 @@ class Membership(models.Model): """ user = models.ForeignKey( - settings.AUTH_USER_MODEL, + User, on_delete=models.PROTECT, ) @@ -185,6 +188,7 @@ class Membership(models.Model): roles = models.ManyToManyField( Role, + verbose_name=_("roles"), ) date_start = models.DateField( @@ -209,17 +213,41 @@ class Membership(models.Model): def save(self, *args, **kwargs): if self.club.parent_club is not None: if not Membership.objects.filter(user=self.user, club=self.club.parent_club).exists(): - raise ValidationError(_('User is not a member of the parent club')) + raise ValidationError(_('User is not a member of the parent club') + ' ' + self.club.parent_club.name) created = not self.pk if created: + if Membership.objects.filter( + user=self.user, + club=self.club, + date_start__lte=datetime.datetime.now().date(), + date_end__gte=datetime.datetime.now().date(), + ).exists(): + raise ValidationError(_('User is already a member of the club')) + self.fee = self.club.membership_fee - self.date_end = self.date_start + datetime.timedelta(days=self.club.membership_duration) - if self.date_end > self.club.membership_end: + if self.club.membership_duration is not None: + self.date_end = self.date_start + datetime.timedelta(days=self.club.membership_duration) + else: + self.date_end = self.date_start + datetime.timedelta(days=0x7FFFFFFF) + if self.club.membership_end is not None and self.date_end > self.club.membership_end: self.date_end = self.club.membership_end super().save(*args, **kwargs) + if created and self.fee: + try: + MembershipTransaction.objects.create( + membership=self, + source=self.user.note, + destination=self.club.note, + quantity=1, + amount=self.fee, + reason="Adhésion", + ) + except PermissionDenied: + self.delete() + class Meta: verbose_name = _('membership') verbose_name_plural = _('memberships') diff --git a/apps/member/tables.py b/apps/member/tables.py index d0c37a6e..18a2a555 100644 --- a/apps/member/tables.py +++ b/apps/member/tables.py @@ -3,8 +3,14 @@ import django_tables2 as tables from django.contrib.auth.models import User +from django.urls import reverse_lazy +from django.utils.html import format_html +from django_tables2 import A -from .models import Club +from note.templatetags.pretty_money import pretty_money +from note_kfet.middlewares import get_current_authenticated_user +from permission.backends import PermissionBackend +from .models import Club, Membership class ClubTable(tables.Table): @@ -33,3 +39,33 @@ class UserTable(tables.Table): template_name = 'django_tables2/bootstrap4.html' fields = ('last_name', 'first_name', 'username', 'email') model = User + + +class MembershipTable(tables.Table): + roles = tables.Column( + attrs={ + "td": { + "class": "text-truncate", + } + } + ) + + def render_fee(self, value): + return pretty_money(value) + + def render_roles(self, record): + roles = record.roles.all() + s = ", ".join(str(role) for role in roles) + if PermissionBackend().has_perm(get_current_authenticated_user(), "member.change_membership_roles", record): + s = format_html("" + s + "") + return s + + class Meta: + attrs = { + 'class': 'table table-condensed table-striped table-hover', + 'style': 'table-layout: fixed;' + } + template_name = 'django_tables2/bootstrap4.html' + fields = ('user', 'club', 'date_start', 'date_end', 'roles', 'fee', ) + model = Membership diff --git a/apps/member/urls.py b/apps/member/urls.py index 7f4bf0d5..2000a6e4 100644 --- a/apps/member/urls.py +++ b/apps/member/urls.py @@ -12,15 +12,16 @@ urlpatterns = [ path('club/', views.ClubListView.as_view(), name="club_list"), path('club//', views.ClubDetailView.as_view(), name="club_detail"), path('club//add_member/', views.ClubAddMemberView.as_view(), name="club_add_member"), + path('club/manage_roles//', views.ClubManageRolesView.as_view(), name="club_manage_roles"), path('club/create/', views.ClubCreateView.as_view(), name="club_create"), path('club//update/', views.ClubUpdateView.as_view(), name="club_update"), path('club//update_pic/', views.ClubPictureUpdateView.as_view(), name="club_update_pic"), path('club//aliases/', views.ClubAliasView.as_view(), name="club_alias"), path('user/', views.UserListView.as_view(), name="user_list"), - path('user/', views.UserDetailView.as_view(), name="user_detail"), - path('user//update', views.UserUpdateView.as_view(), name="user_update_profile"), - path('user//update_pic', views.ProfilePictureUpdateView.as_view(), name="user_update_pic"), - path('user//aliases', views.ProfileAliasView.as_view(), name="user_alias"), + path('user//', views.UserDetailView.as_view(), name="user_detail"), + path('user//update/', views.UserUpdateView.as_view(), name="user_update_profile"), + path('user//update_pic/', views.ProfilePictureUpdateView.as_view(), name="user_update_pic"), + path('user//aliases/', views.ProfileAliasView.as_view(), name="user_alias"), path('manage-auth-token/', views.ManageAuthTokens.as_view(), name='auth_token'), ] diff --git a/apps/member/views.py b/apps/member/views.py index 651dfc35..ad01e2a2 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -10,6 +10,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import User from django.contrib.auth.views import LoginView from django.db.models import Q +from django.forms import HiddenInput from django.shortcuts import redirect from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ @@ -27,7 +28,7 @@ from permission.views import ProtectQuerysetMixin from .filters import UserFilter, UserFilterFormHelper from .forms import SignUpForm, ProfileForm, ClubForm, MembershipForm, CustomAuthenticationForm from .models import Club, Membership -from .tables import ClubTable, UserTable +from .tables import ClubTable, UserTable, MembershipTable class CustomLoginView(LoginView): @@ -138,7 +139,7 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): context['history_list'] = HistoryTable(history_list) club_list = Membership.objects.all().filter(user=user)\ .filter(PermissionBackend.filter_queryset(self.request.user, Membership, "view")).only("club") - context['club_list'] = ClubTable(club_list) + context['club_list'] = MembershipTable(data=club_list) return context @@ -294,7 +295,19 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): date_start__lte=datetime.now().date(), date_end__gte=datetime.now().date(), ).filter(PermissionBackend.filter_queryset(self.request.user, Membership, "view")).all() - context['member_list'] = club_member + + context['member_list'] = MembershipTable(data=club_member) + + empty_membership = Membership( + club=club, + user=User.objects.first(), + date_start=datetime.now().date(), + date_end=datetime.now().date(), + fee=0, + ) + context["can_add_members"] = PermissionBackend()\ + .has_perm(self.request.user, "member.add_membership", empty_membership) + return context @@ -339,7 +352,6 @@ class ClubAddMemberView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView): .get(pk=self.kwargs["pk"]) context = super().get_context_data(**kwargs) context['club'] = club - context['no_cache'] = True return context @@ -347,6 +359,63 @@ class ClubAddMemberView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView): club = Club.objects.filter(PermissionBackend.filter_queryset(self.request.user, Club, "view"))\ .get(pk=self.kwargs["pk"]) form.instance.club = club + + if club.parent_club is not None: + if not Membership.objects.filter(user=form.instance.user, club=club.parent_club).exists(): + form.add_error('user', _('User is not a member of the parent club') + ' ' + club.parent_club.name) + return super().form_invalid(form) + + if Membership.objects.filter( + user=form.instance.user, + club=club, + date_start__lte=datetime.now().date(), + date_end__gte=datetime.now().date(), + ).exists(): + form.add_error('user', _('User is already a member of the club')) + return super().form_invalid(form) + + if form.instance.date_start < form.instance.club.membership_start: + form.add_error('user', _("The membership must start after {:%m-%d-%Y}.") + .format(form.instance.club.membership_start)) + return super().form_invalid(form) + + if form.instance.date_start > form.instance.club.membership_end: + form.add_error('user', _("The membership must end before {:%m-%d-%Y}.") + .format(form.instance.club.membership_start)) + return super().form_invalid(form) + + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy('member:club_detail', kwargs={'pk': self.object.club.id}) + + +class ClubManageRolesView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): + model = Membership + form_class = MembershipForm + template_name = 'member/add_members.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + club = self.object.club + context['club'] = club + form = context['form'] + form.fields['user'].disabled = True + form.fields['date_start'].widget = HiddenInput() + + return context + + def form_valid(self, form): + if form.instance.date_start < form.instance.club.membership_start: + form.add_error('user', _("The membership must start after {:%m-%d-%Y}.") + .format(form.instance.club.membership_start)) + return super().form_invalid(form) + + if form.instance.date_start > form.instance.club.membership_end: + form.add_error('user', _("The membership must end before {:%m-%d-%Y}.") + .format(form.instance.club.membership_start)) + return super().form_invalid(form) + return super().form_valid(form) def get_success_url(self): diff --git a/apps/note/admin.py b/apps/note/admin.py index 702d3350..7b4ba870 100644 --- a/apps/note/admin.py +++ b/apps/note/admin.py @@ -138,6 +138,13 @@ class TransactionAdmin(PolymorphicParentModelAdmin): return [] +@admin.register(MembershipTransaction) +class MembershipTransactionAdmin(PolymorphicChildModelAdmin): + """ + Admin customisation for Transaction + """ + + @admin.register(TransactionTemplate) class TransactionTemplateAdmin(admin.ModelAdmin): """ diff --git a/apps/note/models/transactions.py b/apps/note/models/transactions.py index d1dcd788..d9b860ae 100644 --- a/apps/note/models/transactions.py +++ b/apps/note/models/transactions.py @@ -140,6 +140,7 @@ class Transaction(PolymorphicModel): max_length=255, default=None, null=True, + blank=True, ) class Meta: diff --git a/apps/permission/backends.py b/apps/permission/backends.py index f478cd5d..b95f8203 100644 --- a/apps/permission/backends.py +++ b/apps/permission/backends.py @@ -1,6 +1,8 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +import datetime + from django.contrib.auth.backends import ModelBackend from django.contrib.auth.models import User, AnonymousUser from django.contrib.contenttypes.models import ContentType @@ -32,7 +34,8 @@ class PermissionBackend(ModelBackend): for permission in Permission.objects.annotate(club=F("rolepermissions__role__membership__club")) \ .filter( rolepermissions__role__membership__user=user, - rolepermissions__role__membership__valid=True, + rolepermissions__role__membership__date_start__lte=datetime.date.today(), + rolepermissions__role__membership__date_end__gte=datetime.date.today(), model__app_label=model.app_label, # For polymorphic models, we don't filter on model type type=type, ).all(): diff --git a/apps/permission/models.py b/apps/permission/models.py index d1b55090..c8df07c0 100644 --- a/apps/permission/models.py +++ b/apps/permission/models.py @@ -45,11 +45,13 @@ class InstancedPermission: else: oldpk = obj.pk # Ensure previous models are deleted - self.model.model_class().objects.filter(pk=obj.pk).delete() + self.model.model_class().objects.filter(pk=obj.pk).annotate(_force_delete=F("pk") + 1).delete() # Force insertion, no data verification, no trigger + obj._force_save = True Model.save(obj, force_insert=True) ret = self.model.model_class().objects.filter(self.query & Q(pk=obj.pk)).exists() # Delete testing object + obj._force_delete = True Model.delete(obj) # If the primary key was specified, we restore it diff --git a/apps/permission/signals.py b/apps/permission/signals.py index 1e30f56f..5ccb9c0b 100644 --- a/apps/permission/signals.py +++ b/apps/permission/signals.py @@ -29,6 +29,9 @@ def pre_save_object(sender, instance, **kwargs): if instance._meta.label_lower in EXCLUDED: return + if hasattr(instance, "_force_save"): + return + user = get_current_authenticated_user() if user is None: # Action performed on shell is always granted @@ -58,32 +61,14 @@ def pre_save_object(sender, instance, **kwargs): if not PermissionBackend().has_perm(user, app_label + ".change_" + model_name + "_" + field_name, instance): raise PermissionDenied else: - # We check if the user can add the model - - # While checking permissions, the object will be inserted in the DB, then removed. - # We disable temporary the connectors - pre_save.disconnect(pre_save_object) - pre_delete.disconnect(pre_delete_object) - # We disable also logs connectors - pre_save.disconnect(logs_signals.pre_save_object) - post_save.disconnect(logs_signals.save_object) - post_delete.disconnect(logs_signals.delete_object) - # We check if the user has right to add the object has_perm = PermissionBackend().has_perm(user, app_label + ".add_" + model_name, instance) - # Then we reconnect all - pre_save.connect(pre_save_object) - pre_delete.connect(pre_delete_object) - pre_save.connect(logs_signals.pre_save_object) - post_save.connect(logs_signals.save_object) - post_delete.connect(logs_signals.delete_object) - if not has_perm: raise PermissionDenied -def pre_delete_object(sender, instance, **kwargs): +def pre_delete_object(instance, **kwargs): """ Before a model get deleted, we check the permissions """ @@ -91,6 +76,9 @@ def pre_delete_object(sender, instance, **kwargs): if instance._meta.label_lower in EXCLUDED: return + if hasattr(instance, "_force_delete"): + return + user = get_current_authenticated_user() if user is None: # Action performed on shell is always granted diff --git a/note_kfet/inputs.py b/note_kfet/inputs.py index 81f55dee..ecd758e0 100644 --- a/note_kfet/inputs.py +++ b/note_kfet/inputs.py @@ -1,7 +1,6 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -import datetime from json import dumps as json_dumps from django.forms.widgets import DateTimeBaseInput, NumberInput, TextInput diff --git a/templates/member/club_info.html b/templates/member/club_info.html index da747ccd..23a41c1c 100644 --- a/templates/member/club_info.html +++ b/templates/member/club_info.html @@ -1,4 +1,4 @@ -{% load i18n static pretty_money %} +{% load i18n static pretty_money perms %}

Club {{ club.name }}

@@ -40,9 +40,12 @@