diff --git a/README.md b/README.md index 8f7caa61..9b0c927e 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ On supposera pour la suite que vous utilisez Debian/Ubuntu sur un serveur tout n $ python3 -m venv env $ source env/bin/activate (env)$ pip3 install -r requirements/base.txt + (env)$ pip3 install -r requirements/prod.txt # uniquement en prod, nécessite un base postgres (env)$ deactivate 4. uwsgi et Nginx @@ -105,18 +106,18 @@ On supposera pour la suite que vous utilisez Debian/Ubuntu sur un serveur tout n On copie le fichier `.env_example` vers le fichier `.env` à la racine du projet et on renseigne des secrets et des paramètres : - DJANGO_APP_STAGE="dev" - DJANGO_DEV_STORE_METHOD="sqllite" + DJANGO_APP_STAGE="dev" # ou "prod" + DJANGO_DEV_STORE_METHOD="sqllite" # ou "postgres" DJANGO_DB_HOST="localhost" DJANGO_DB_NAME="note_db" DJANGO_DB_USER="note" - DJANGO_DB_PASSWORD="CHANGE_ME" + DJANGO_DB_PASSWORD="CHANGE_ME" DJANGO_DB_PORT="" DJANGO_SECRET_KEY="CHANGE_ME" DJANGO_SETTINGS_MODULE="note_kfet.settings" - DOMAIN="localhost" + DOMAIN="localhost" # note.example.com CONTACT_EMAIL="tresorerie.bde@localhost" - NOTE_URL="localhost" + NOTE_URL="localhost" # serveur cas note.example.com si auto-hébergé. Ensuite on (re)bascule dans l'environement virtuel et on lance les migrations @@ -171,7 +172,7 @@ un serveur de développement par exemple sur son ordinateur. $ python3 -m venv venv $ source venv/bin/activate - (env)$ pip install -r requirements.txt + (env)$ pip install -r requirements/base.txt 3. Copier le fichier `.env_example` vers `.env` à la racine du projet et mettre à jour ce qu'il faut diff --git a/apps/activity/api/views.py b/apps/activity/api/views.py index 4ee2194d..76b2b333 100644 --- a/apps/activity/api/views.py +++ b/apps/activity/api/views.py @@ -1,14 +1,15 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later + from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import viewsets from rest_framework.filters import SearchFilter +from api.viewsets import ReadProtectedModelViewSet from .serializers import ActivityTypeSerializer, ActivitySerializer, GuestSerializer from ..models import ActivityType, Activity, Guest -class ActivityTypeViewSet(viewsets.ModelViewSet): +class ActivityTypeViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `ActivityType` objects, serialize it to JSON with the given serializer, @@ -20,7 +21,7 @@ class ActivityTypeViewSet(viewsets.ModelViewSet): filterset_fields = ['name', 'can_invite', ] -class ActivityViewSet(viewsets.ModelViewSet): +class ActivityViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Activity` objects, serialize it to JSON with the given serializer, @@ -32,7 +33,7 @@ class ActivityViewSet(viewsets.ModelViewSet): filterset_fields = ['name', 'description', 'activity_type', ] -class GuestViewSet(viewsets.ModelViewSet): +class GuestViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Guest` objects, serialize it to JSON with the given serializer, diff --git a/apps/api/urls.py b/apps/api/urls.py index 95ed5f99..b275a0b8 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -5,12 +5,15 @@ from django.conf.urls import url, include from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import routers, serializers, viewsets +from rest_framework import routers, serializers from rest_framework.filters import SearchFilter +from rest_framework.viewsets import ReadOnlyModelViewSet from activity.api.urls import register_activity_urls +from api.viewsets import ReadProtectedModelViewSet from member.api.urls import register_members_urls from note.api.urls import register_note_urls from logs.api.urls import register_logs_urls +from permission.api.urls import register_permission_urls class UserSerializer(serializers.ModelSerializer): @@ -39,7 +42,7 @@ class ContentTypeSerializer(serializers.ModelSerializer): fields = '__all__' -class UserViewSet(viewsets.ModelViewSet): +class UserViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer, @@ -52,7 +55,8 @@ class UserViewSet(viewsets.ModelViewSet): search_fields = ['$username', '$first_name', '$last_name', ] -class ContentTypeViewSet(viewsets.ReadOnlyModelViewSet): +# This ViewSet is the only one that is accessible from all authenticated users! +class ContentTypeViewSet(ReadOnlyModelViewSet): """ REST API View set. The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer, @@ -70,6 +74,7 @@ router.register('user', UserViewSet) register_members_urls(router, 'members') register_activity_urls(router, 'activity') register_note_urls(router, 'note') +register_permission_urls(router, 'permission') register_logs_urls(router, 'logs') app_name = 'api' diff --git a/apps/api/viewsets.py b/apps/api/viewsets.py new file mode 100644 index 00000000..f7532beb --- /dev/null +++ b/apps/api/viewsets.py @@ -0,0 +1,31 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.contrib.contenttypes.models import ContentType +from permission.backends import PermissionBackend +from rest_framework import viewsets +from note_kfet.middlewares import get_current_authenticated_user + + +class ReadProtectedModelViewSet(viewsets.ModelViewSet): + """ + Protect a ModelViewSet by filtering the objects that the user cannot see. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class() + user = get_current_authenticated_user() + self.queryset = model.objects.filter(PermissionBackend.filter_queryset(user, model, "view")) + + +class ReadOnlyProtectedModelViewSet(viewsets.ReadOnlyModelViewSet): + """ + Protect a ReadOnlyModelViewSet by filtering the objects that the user cannot see. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class() + user = get_current_authenticated_user() + self.queryset = model.objects.filter(PermissionBackend.filter_queryset(user, model, "view")) diff --git a/apps/logs/api/views.py b/apps/logs/api/views.py index 2c47b7a2..b3b9b166 100644 --- a/apps/logs/api/views.py +++ b/apps/logs/api/views.py @@ -2,14 +2,14 @@ # SPDX-License-Identifier: GPL-3.0-or-later from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import viewsets from rest_framework.filters import OrderingFilter +from api.viewsets import ReadOnlyProtectedModelViewSet from .serializers import ChangelogSerializer from ..models import Changelog -class ChangelogViewSet(viewsets.ReadOnlyModelViewSet): +class ChangelogViewSet(ReadOnlyProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer, diff --git a/apps/logs/middlewares.py b/apps/logs/middlewares.py deleted file mode 100644 index 77f749b9..00000000 --- a/apps/logs/middlewares.py +++ /dev/null @@ -1,55 +0,0 @@ -# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay -# SPDX-License-Identifier: GPL-3.0-or-later - -from django.conf import settings -from django.contrib.auth.models import AnonymousUser - -from threading import local - - -USER_ATTR_NAME = getattr(settings, 'LOCAL_USER_ATTR_NAME', '_current_user') -IP_ATTR_NAME = getattr(settings, 'LOCAL_IP_ATTR_NAME', '_current_ip') - -_thread_locals = local() - - -def _set_current_user_and_ip(user=None, ip=None): - setattr(_thread_locals, USER_ATTR_NAME, user) - setattr(_thread_locals, IP_ATTR_NAME, ip) - - -def get_current_user(): - return getattr(_thread_locals, USER_ATTR_NAME, None) - - -def get_current_ip(): - return getattr(_thread_locals, IP_ATTR_NAME, None) - - -def get_current_authenticated_user(): - current_user = get_current_user() - if isinstance(current_user, AnonymousUser): - return None - return current_user - - -class LogsMiddleware(object): - """ - This middleware get the current user with his or her IP address on each request. - """ - - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - user = request.user - if 'HTTP_X_FORWARDED_FOR' in request.META: - ip = request.META.get('HTTP_X_FORWARDED_FOR') - else: - ip = request.META.get('REMOTE_ADDR') - - _set_current_user_and_ip(user, ip) - response = self.get_response(request) - _set_current_user_and_ip(None, None) - - return response diff --git a/apps/logs/signals.py b/apps/logs/signals.py index fb17157a..43fc1e13 100644 --- a/apps/logs/signals.py +++ b/apps/logs/signals.py @@ -4,14 +4,13 @@ from django.contrib.contenttypes.models import ContentType from rest_framework.renderers import JSONRenderer from rest_framework.serializers import ModelSerializer +from note.models import NoteUser, Alias +from note_kfet.middlewares import get_current_authenticated_user, get_current_ip + +from .models import Changelog import getpass -from note.models import NoteUser, Alias - -from .middlewares import get_current_authenticated_user, get_current_ip -from .models import Changelog - # Ces modèles ne nécessitent pas de logs EXCLUDED = [ diff --git a/apps/member/api/serializers.py b/apps/member/api/serializers.py index 962841ae..a956a46b 100644 --- a/apps/member/api/serializers.py +++ b/apps/member/api/serializers.py @@ -15,6 +15,7 @@ class ProfileSerializer(serializers.ModelSerializer): class Meta: model = Profile fields = '__all__' + read_only_fields = ('user', ) class ClubSerializer(serializers.ModelSerializer): diff --git a/apps/member/api/views.py b/apps/member/api/views.py index c85df903..57c216a1 100644 --- a/apps/member/api/views.py +++ b/apps/member/api/views.py @@ -1,14 +1,14 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -from rest_framework import viewsets from rest_framework.filters import SearchFilter +from api.viewsets import ReadProtectedModelViewSet from .serializers import ProfileSerializer, ClubSerializer, RoleSerializer, MembershipSerializer from ..models import Profile, Club, Role, Membership -class ProfileViewSet(viewsets.ModelViewSet): +class ProfileViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Profile` objects, serialize it to JSON with the given serializer, @@ -18,7 +18,7 @@ class ProfileViewSet(viewsets.ModelViewSet): serializer_class = ProfileSerializer -class ClubViewSet(viewsets.ModelViewSet): +class ClubViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Club` objects, serialize it to JSON with the given serializer, @@ -30,7 +30,7 @@ class ClubViewSet(viewsets.ModelViewSet): search_fields = ['$name', ] -class RoleViewSet(viewsets.ModelViewSet): +class RoleViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Role` objects, serialize it to JSON with the given serializer, @@ -42,7 +42,7 @@ class RoleViewSet(viewsets.ModelViewSet): search_fields = ['$name', ] -class MembershipViewSet(viewsets.ModelViewSet): +class MembershipViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Membership` objects, serialize it to JSON with the given serializer, diff --git a/apps/member/forms.py b/apps/member/forms.py index d2134cdd..5f2d5838 100644 --- a/apps/member/forms.py +++ b/apps/member/forms.py @@ -6,12 +6,21 @@ from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout from dal import autocomplete from django import forms -from django.contrib.auth.forms import UserCreationForm +from django.contrib.auth.forms import UserCreationForm, AuthenticationForm from django.contrib.auth.models import User +from permission.models import PermissionMask from .models import Profile, Club, Membership +class CustomAuthenticationForm(AuthenticationForm): + permission_mask = forms.ModelChoiceField( + label="Masque de permissions", + queryset=PermissionMask.objects.order_by("rank"), + empty_label=None, + ) + + class SignUpForm(UserCreationForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/apps/member/models.py b/apps/member/models.py index b6d17a08..cdbb9332 100644 --- a/apps/member/models.py +++ b/apps/member/models.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.conf import settings from django.db import models from django.urls import reverse, reverse_lazy @@ -150,16 +152,13 @@ class Membership(models.Model): verbose_name=_('fee'), ) + def valid(self): + if self.date_end is not None: + return self.date_start.toordinal() <= datetime.datetime.now().toordinal() < self.date_end.toordinal() + else: + return self.date_start.toordinal() <= datetime.datetime.now().toordinal() + class Meta: verbose_name = _('membership') verbose_name_plural = _('memberships') indexes = [models.Index(fields=['user'])] - -# @receiver(post_save, sender=settings.AUTH_USER_MODEL) -# def save_user_profile(instance, created, **_kwargs): -# """ -# Hook to save an user profile when an user is updated -# """ -# if created: -# Profile.objects.create(user=instance) -# instance.profile.save() diff --git a/apps/member/views.py b/apps/member/views.py index 82c15b99..0ba76d6a 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -9,6 +9,7 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import User +from django.contrib.auth.views import LoginView from django.core.exceptions import ValidationError from django.db.models import Q from django.http import HttpResponseRedirect @@ -23,13 +24,23 @@ from note.forms import AliasForm, ImageForm from note.models import Alias, NoteUser from note.models.transactions import Transaction from note.tables import HistoryTable, AliasTable +from permission.backends import PermissionBackend from .filters import UserFilter, UserFilterFormHelper -from .forms import SignUpForm, ProfileForm, ClubForm, MembershipForm, MemberFormSet, FormSetHelper +from .forms import SignUpForm, ProfileForm, ClubForm, MembershipForm, MemberFormSet, FormSetHelper, \ + CustomAuthenticationForm from .models import Club, Membership from .tables import ClubTable, UserTable +class CustomLoginView(LoginView): + form_class = CustomAuthenticationForm + + def form_valid(self, form): + self.request.session['permission_mask'] = form.cleaned_data['permission_mask'].rank + return super().form_valid(form) + + class UserCreateView(CreateView): """ Une vue pour inscrire un utilisateur et lui créer un profile @@ -120,11 +131,14 @@ class UserDetailView(LoginRequiredMixin, DetailView): context_object_name = "user_object" template_name = "member/profile_detail.html" + def get_queryset(self, **kwargs): + return super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, User, "view")) + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) user = context['user_object'] history_list = \ - Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note)) + Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note)).order_by("-id") context['history_list'] = HistoryTable(history_list) club_list = \ Membership.objects.all().filter(user=user).only("club") @@ -147,7 +161,7 @@ class UserListView(LoginRequiredMixin, SingleTableView): formhelper_class = UserFilterFormHelper def get_queryset(self, **kwargs): - qs = super().get_queryset() + qs = super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, User, "view")) self.filter = self.filter_class(self.request.GET, queryset=qs) self.filter.form.helper = self.formhelper_class() return self.filter.qs @@ -203,7 +217,6 @@ class DeleteAliasView(LoginRequiredMixin, DeleteView): return HttpResponseRedirect(self.get_success_url()) def get_success_url(self): - print(self.request) return reverse_lazy('member:user_alias', kwargs={'pk': self.object.note.user.pk}) def get(self, request, *args, **kwargs): @@ -297,7 +310,7 @@ class UserAutocomplete(autocomplete.Select2QuerySetView): if not self.request.user.is_authenticated: return User.objects.none() - qs = User.objects.all() + qs = User.objects.filter(PermissionBackend.filter_queryset(self.request.user, User, "view")).all() if self.q: qs = qs.filter(username__regex="^" + self.q) @@ -328,11 +341,17 @@ class ClubListView(LoginRequiredMixin, SingleTableView): model = Club table_class = ClubTable + def get_queryset(self, **kwargs): + return super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, Club, "view")) + class ClubDetailView(LoginRequiredMixin, DetailView): model = Club context_object_name = "club" + def get_queryset(self, **kwargs): + return super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, Club, "view")) + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) club = context["club"] @@ -351,6 +370,11 @@ class ClubAddMemberView(LoginRequiredMixin, CreateView): form_class = MembershipForm template_name = 'member/add_members.html' + def get_queryset(self, **kwargs): + return super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, Membership, "view") + | PermissionBackend.filter_queryset(self.request.user, Membership, + "change")) + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['formset'] = MemberFormSet() diff --git a/apps/note/admin.py b/apps/note/admin.py index a0928641..702d3350 100644 --- a/apps/note/admin.py +++ b/apps/note/admin.py @@ -8,7 +8,7 @@ from polymorphic.admin import PolymorphicChildModelAdmin, \ from .models.notes import Alias, Note, NoteClub, NoteSpecial, NoteUser from .models.transactions import Transaction, TemplateCategory, TransactionTemplate, \ - TemplateTransaction, MembershipTransaction + RecurrentTransaction, MembershipTransaction class AliasInlines(admin.TabularInline): @@ -102,7 +102,7 @@ class TransactionAdmin(PolymorphicParentModelAdmin): """ Admin customisation for Transaction """ - child_models = (TemplateTransaction, MembershipTransaction) + child_models = (RecurrentTransaction, MembershipTransaction) list_display = ('created_at', 'poly_source', 'poly_destination', 'quantity', 'amount', 'valid') list_filter = ('valid',) diff --git a/apps/note/api/serializers.py b/apps/note/api/serializers.py index 73beead1..a51b4263 100644 --- a/apps/note/api/serializers.py +++ b/apps/note/api/serializers.py @@ -6,7 +6,7 @@ from rest_polymorphic.serializers import PolymorphicSerializer from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory, \ - TemplateTransaction + RecurrentTransaction, SpecialTransaction class NoteSerializer(serializers.ModelSerializer): @@ -18,12 +18,7 @@ class NoteSerializer(serializers.ModelSerializer): class Meta: model = Note fields = '__all__' - extra_kwargs = { - 'url': { - 'view_name': 'project-detail', - 'lookup_field': 'pk' - }, - } + read_only_fields = [f.name for f in model._meta.get_fields()] # Notes are read-only protected class NoteClubSerializer(serializers.ModelSerializer): @@ -31,10 +26,15 @@ class NoteClubSerializer(serializers.ModelSerializer): REST API Serializer for Club's notes. The djangorestframework plugin will analyse the model `NoteClub` and parse all fields in the API. """ + name = serializers.SerializerMethodField() class Meta: model = NoteClub fields = '__all__' + read_only_fields = ('note', 'club', ) + + def get_name(self, obj): + return str(obj) class NoteSpecialSerializer(serializers.ModelSerializer): @@ -42,10 +42,15 @@ class NoteSpecialSerializer(serializers.ModelSerializer): REST API Serializer for special notes. The djangorestframework plugin will analyse the model `NoteSpecial` and parse all fields in the API. """ + name = serializers.SerializerMethodField() class Meta: model = NoteSpecial fields = '__all__' + read_only_fields = ('note', ) + + def get_name(self, obj): + return str(obj) class NoteUserSerializer(serializers.ModelSerializer): @@ -53,10 +58,15 @@ class NoteUserSerializer(serializers.ModelSerializer): REST API Serializer for User's notes. The djangorestframework plugin will analyse the model `NoteUser` and parse all fields in the API. """ + name = serializers.SerializerMethodField() class Meta: model = NoteUser fields = '__all__' + read_only_fields = ('note', 'user', ) + + def get_name(self, obj): + return str(obj) class AliasSerializer(serializers.ModelSerializer): @@ -68,6 +78,7 @@ class AliasSerializer(serializers.ModelSerializer): class Meta: model = Alias fields = '__all__' + read_only_fields = ('note', ) class NotePolymorphicSerializer(PolymorphicSerializer): @@ -78,6 +89,9 @@ class NotePolymorphicSerializer(PolymorphicSerializer): NoteSpecial: NoteSpecialSerializer } + class Meta: + model = Note + class TemplateCategorySerializer(serializers.ModelSerializer): """ @@ -112,14 +126,14 @@ class TransactionSerializer(serializers.ModelSerializer): fields = '__all__' -class TemplateTransactionSerializer(serializers.ModelSerializer): +class RecurrentTransactionSerializer(serializers.ModelSerializer): """ REST API Serializer for Transactions. - The djangorestframework plugin will analyse the model `TemplateTransaction` and parse all fields in the API. + The djangorestframework plugin will analyse the model `RecurrentTransaction` and parse all fields in the API. """ class Meta: - model = TemplateTransaction + model = RecurrentTransaction fields = '__all__' @@ -134,9 +148,24 @@ class MembershipTransactionSerializer(serializers.ModelSerializer): fields = '__all__' +class SpecialTransactionSerializer(serializers.ModelSerializer): + """ + REST API Serializer for Special transactions. + The djangorestframework plugin will analyse the model `SpecialTransaction` and parse all fields in the API. + """ + + class Meta: + model = SpecialTransaction + fields = '__all__' + + class TransactionPolymorphicSerializer(PolymorphicSerializer): model_serializer_mapping = { Transaction: TransactionSerializer, - TemplateTransaction: TemplateTransactionSerializer, + RecurrentTransaction: RecurrentTransactionSerializer, MembershipTransaction: MembershipTransactionSerializer, + SpecialTransaction: SpecialTransactionSerializer, } + + class Meta: + model = Transaction diff --git a/apps/note/api/views.py b/apps/note/api/views.py index 14f64003..f230a646 100644 --- a/apps/note/api/views.py +++ b/apps/note/api/views.py @@ -3,57 +3,16 @@ from django.db.models import Q from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import viewsets -from rest_framework.filters import SearchFilter +from rest_framework.filters import OrderingFilter, SearchFilter +from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet -from .serializers import NoteSerializer, NotePolymorphicSerializer, NoteClubSerializer, NoteSpecialSerializer, \ - NoteUserSerializer, AliasSerializer, \ - TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer -from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias +from .serializers import NotePolymorphicSerializer, AliasSerializer, TemplateCategorySerializer, \ + TransactionTemplateSerializer, TransactionPolymorphicSerializer +from ..models.notes import Note, Alias from ..models.transactions import TransactionTemplate, Transaction, TemplateCategory -class NoteViewSet(viewsets.ModelViewSet): - """ - REST API View set. - The djangorestframework plugin will get all `Note` objects, serialize it to JSON with the given serializer, - then render it on /api/note/note/ - """ - queryset = Note.objects.all() - serializer_class = NoteSerializer - - -class NoteClubViewSet(viewsets.ModelViewSet): - """ - REST API View set. - The djangorestframework plugin will get all `NoteClub` objects, serialize it to JSON with the given serializer, - then render it on /api/note/club/ - """ - queryset = NoteClub.objects.all() - serializer_class = NoteClubSerializer - - -class NoteSpecialViewSet(viewsets.ModelViewSet): - """ - REST API View set. - The djangorestframework plugin will get all `NoteSpecial` objects, serialize it to JSON with the given serializer, - then render it on /api/note/special/ - """ - queryset = NoteSpecial.objects.all() - serializer_class = NoteSpecialSerializer - - -class NoteUserViewSet(viewsets.ModelViewSet): - """ - REST API View set. - The djangorestframework plugin will get all `NoteUser` objects, serialize it to JSON with the given serializer, - then render it on /api/note/user/ - """ - queryset = NoteUser.objects.all() - serializer_class = NoteUserSerializer - - -class NotePolymorphicViewSet(viewsets.ModelViewSet): +class NotePolymorphicViewSet(ReadOnlyProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Note` objects (with polymorhism), serialize it to JSON with the given serializer, @@ -61,36 +20,27 @@ class NotePolymorphicViewSet(viewsets.ModelViewSet): """ queryset = Note.objects.all() serializer_class = NotePolymorphicSerializer + filter_backends = [SearchFilter, OrderingFilter] + search_fields = ['$alias__normalized_name', '$alias__name', '$polymorphic_ctype__model', ] + ordering_fields = ['alias__name', 'alias__normalized_name'] def get_queryset(self): """ Parse query and apply filters. :return: The filtered set of requested notes """ - queryset = Note.objects.all() + queryset = super().get_queryset() alias = self.request.query_params.get("alias", ".*") queryset = queryset.filter( Q(alias__name__regex="^" + alias) + | Q(alias__normalized_name__regex="^" + Alias.normalize(alias)) | Q(alias__normalized_name__regex="^" + alias.lower())) - note_type = self.request.query_params.get("type", None) - if note_type: - types = str(note_type).lower() - if "user" in types: - queryset = queryset.filter(polymorphic_ctype__model="noteuser") - elif "club" in types: - queryset = queryset.filter(polymorphic_ctype__model="noteclub") - elif "special" in types: - queryset = queryset.filter( - polymorphic_ctype__model="notespecial") - else: - queryset = queryset.none() - - return queryset + return queryset.distinct() -class AliasViewSet(viewsets.ModelViewSet): +class AliasViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Alias` objects, serialize it to JSON with the given serializer, @@ -98,6 +48,9 @@ class AliasViewSet(viewsets.ModelViewSet): """ queryset = Alias.objects.all() serializer_class = AliasSerializer + filter_backends = [SearchFilter, OrderingFilter] + search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ] + ordering_fields = ['name', 'normalized_name'] def get_queryset(self): """ @@ -105,35 +58,18 @@ class AliasViewSet(viewsets.ModelViewSet): :return: The filtered set of requested aliases """ - queryset = Alias.objects.all() + queryset = super().get_queryset() alias = self.request.query_params.get("alias", ".*") queryset = queryset.filter( - Q(name__regex="^" + alias) | Q(normalized_name__regex="^" + alias.lower())) - - note_id = self.request.query_params.get("note", None) - if note_id: - queryset = queryset.filter(id=note_id) - - note_type = self.request.query_params.get("type", None) - if note_type: - types = str(note_type).lower() - if "user" in types: - queryset = queryset.filter( - note__polymorphic_ctype__model="noteuser") - elif "club" in types: - queryset = queryset.filter( - note__polymorphic_ctype__model="noteclub") - elif "special" in types: - queryset = queryset.filter( - note__polymorphic_ctype__model="notespecial") - else: - queryset = queryset.none() + Q(name__regex="^" + alias) + | Q(normalized_name__regex="^" + Alias.normalize(alias)) + | Q(normalized_name__regex="^" + alias.lower())) return queryset -class TemplateCategoryViewSet(viewsets.ModelViewSet): +class TemplateCategoryViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `TemplateCategory` objects, serialize it to JSON with the given serializer, @@ -145,7 +81,7 @@ class TemplateCategoryViewSet(viewsets.ModelViewSet): search_fields = ['$name', ] -class TransactionTemplateViewSet(viewsets.ModelViewSet): +class TransactionTemplateViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `TransactionTemplate` objects, serialize it to JSON with the given serializer, @@ -157,7 +93,7 @@ class TransactionTemplateViewSet(viewsets.ModelViewSet): filterset_fields = ['name', 'amount', 'display', 'category', ] -class TransactionViewSet(viewsets.ModelViewSet): +class TransactionViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Transaction` objects, serialize it to JSON with the given serializer, diff --git a/apps/note/forms.py b/apps/note/forms.py index 2e8e4456..ac6adaaf 100644 --- a/apps/note/forms.py +++ b/apps/note/forms.py @@ -6,7 +6,7 @@ from django import forms from django.utils.translation import gettext_lazy as _ from .models import Alias -from .models import Transaction, TransactionTemplate +from .models import TransactionTemplate class AliasForm(forms.ModelForm): @@ -50,52 +50,3 @@ class TransactionTemplateForm(forms.ModelForm): }, ), } - - -class TransactionForm(forms.ModelForm): - def save(self, commit=True): - super().save(commit) - - def clean(self): - """ - If the user has no right to transfer funds, then it will be the source of the transfer by default. - Transactions between a note and the same note are not authorized. - """ - - cleaned_data = super().clean() - if "source" not in cleaned_data: # TODO Replace it with "if %user has no right to transfer funds" - cleaned_data["source"] = self.user.note - - if cleaned_data["source"].pk == cleaned_data["destination"].pk: - self.add_error("destination", _("Source and destination must be different.")) - - return cleaned_data - - class Meta: - model = Transaction - fields = ( - 'source', - 'destination', - 'reason', - 'amount', - ) - - # Voir ci-dessus - widgets = { - 'source': - autocomplete.ModelSelect2( - url='note:note_autocomplete', - attrs={ - 'data-placeholder': 'Note ...', - 'data-minimum-input-length': 1, - }, - ), - 'destination': - autocomplete.ModelSelect2( - url='note:note_autocomplete', - attrs={ - 'data-placeholder': 'Note ...', - 'data-minimum-input-length': 1, - }, - ), - } diff --git a/apps/note/models/__init__.py b/apps/note/models/__init__.py index 081b31a7..8f1921f9 100644 --- a/apps/note/models/__init__.py +++ b/apps/note/models/__init__.py @@ -3,12 +3,12 @@ from .notes import Alias, Note, NoteClub, NoteSpecial, NoteUser from .transactions import MembershipTransaction, Transaction, \ - TemplateCategory, TransactionTemplate, TemplateTransaction + TemplateCategory, TransactionTemplate, RecurrentTransaction __all__ = [ # Notes 'Alias', 'Note', 'NoteClub', 'NoteSpecial', 'NoteUser', # Transactions 'MembershipTransaction', 'Transaction', 'TemplateCategory', 'TransactionTemplate', - 'TemplateTransaction', + 'RecurrentTransaction', ] diff --git a/apps/note/models/transactions.py b/apps/note/models/transactions.py index 809e7c44..0e40edf6 100644 --- a/apps/note/models/transactions.py +++ b/apps/note/models/transactions.py @@ -7,7 +7,7 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from polymorphic.models import PolymorphicModel -from .notes import Note, NoteClub +from .notes import Note, NoteClub, NoteSpecial """ Defines transactions @@ -68,6 +68,7 @@ class TransactionTemplate(models.Model): description = models.CharField( verbose_name=_('description'), max_length=255, + blank=True, ) class Meta: @@ -106,7 +107,10 @@ class Transaction(PolymorphicModel): verbose_name=_('quantity'), default=1, ) - amount = models.PositiveIntegerField(verbose_name=_('amount'), ) + amount = models.PositiveIntegerField( + verbose_name=_('amount'), + ) + reason = models.CharField( verbose_name=_('reason'), max_length=255, @@ -132,6 +136,7 @@ class Transaction(PolymorphicModel): if self.source.pk == self.destination.pk: # When source == destination, no money is transfered + super().save(*args, **kwargs) return created = self.pk is None @@ -147,20 +152,25 @@ class Transaction(PolymorphicModel): self.source.balance -= to_transfer self.destination.balance += to_transfer + # We save first the transaction, in case of the user has no right to transfer money + super().save(*args, **kwargs) + # Save notes self.source.save() self.destination.save() - super().save(*args, **kwargs) @property def total(self): return self.amount * self.quantity + @property + def type(self): + return _('Transfer') -class TemplateTransaction(Transaction): + +class RecurrentTransaction(Transaction): """ Special type of :model:`note.Transaction` associated to a :model:`note.TransactionTemplate`. - """ template = models.ForeignKey( @@ -173,6 +183,36 @@ class TemplateTransaction(Transaction): on_delete=models.PROTECT, ) + @property + def type(self): + return _('Template') + + +class SpecialTransaction(Transaction): + """ + Special type of :model:`note.Transaction` associated to transactions with special notes + """ + + last_name = models.CharField( + max_length=255, + verbose_name=_("name"), + ) + + first_name = models.CharField( + max_length=255, + verbose_name=_("first_name"), + ) + + bank = models.CharField( + max_length=255, + verbose_name=_("bank"), + blank=True, + ) + + @property + def type(self): + return _('Credit') if isinstance(self.source, NoteSpecial) else _("Debit") + class MembershipTransaction(Transaction): """ @@ -189,3 +229,7 @@ class MembershipTransaction(Transaction): class Meta: verbose_name = _("membership transaction") verbose_name_plural = _("membership transactions") + + @property + def type(self): + return _('membership transaction') diff --git a/apps/note/tables.py b/apps/note/tables.py index d26ffedc..b9dac051 100644 --- a/apps/note/tables.py +++ b/apps/note/tables.py @@ -1,9 +1,12 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +import html + import django_tables2 as tables from django.db.models import F from django_tables2.utils import A +from django.utils.translation import gettext_lazy as _ from .models.notes import Alias from .models.transactions import Transaction @@ -17,17 +20,25 @@ class HistoryTable(tables.Table): 'table table-condensed table-striped table-hover' } model = Transaction - exclude = ("polymorphic_ctype", ) + exclude = ("id", "polymorphic_ctype", ) template_name = 'django_tables2/bootstrap4.html' - sequence = ('...', 'total', 'valid') + sequence = ('...', 'type', 'total', 'valid', ) + orderable = False + + type = tables.Column() total = tables.Column() # will use Transaction.total() !! + valid = tables.Column(attrs={"td": {"id": lambda record: "validate_" + str(record.id), + "class": lambda record: str(record.valid).lower() + ' validate', + "onclick": lambda record: 'de_validate(' + str(record.id) + ', ' + + str(record.valid).lower() + ')'}}) + def order_total(self, queryset, is_descending): # needed for rendering queryset = queryset.annotate(total=F('amount') * F('quantity')) \ .order_by(('-' if is_descending else '') + 'total') - return (queryset, True) + return queryset, True def render_amount(self, value): return pretty_money(value) @@ -35,6 +46,16 @@ class HistoryTable(tables.Table): def render_total(self, value): return pretty_money(value) + def render_type(self, value): + return _(value) + + # Django-tables escape strings. That's a wrong thing. + def render_reason(self, value): + return html.unescape(value) + + def render_valid(self, value): + return "✔" if value else "✖" + class AliasTable(tables.Table): class Meta: diff --git a/apps/note/templatetags/pretty_money.py b/apps/note/templatetags/pretty_money.py index f821e819..ba527f9b 100644 --- a/apps/note/templatetags/pretty_money.py +++ b/apps/note/templatetags/pretty_money.py @@ -11,7 +11,7 @@ def pretty_money(value): abs(value) // 100, ) else: - return "{:s}{:d} € {:02d}".format( + return "{:s}{:d}.{:02d} €".format( "- " if value < 0 else "", abs(value) // 100, abs(value) % 100, diff --git a/apps/note/views.py b/apps/note/views.py index 16e2e39b..84df2bd7 100644 --- a/apps/note/views.py +++ b/apps/note/views.py @@ -3,53 +3,46 @@ from dal import autocomplete from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.contenttypes.models import ContentType from django.db.models import Q -from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django.views.generic import CreateView, ListView, UpdateView from django_tables2 import SingleTableView +from permission.backends import PermissionBackend -from .forms import TransactionForm, TransactionTemplateForm -from .models import Transaction, TransactionTemplate, Alias +from .forms import TransactionTemplateForm +from .models import Transaction, TransactionTemplate, Alias, RecurrentTransaction, NoteSpecial +from .models.transactions import SpecialTransaction from .tables import HistoryTable -class TransactionCreate(LoginRequiredMixin, CreateView): +class TransactionCreate(LoginRequiredMixin, SingleTableView): """ Show transfer page - - TODO: If user have sufficient rights, they can transfer from an other note """ - model = Transaction - form_class = TransactionForm + template_name = "note/transaction_form.html" + + # Transaction history table + table_class = HistoryTable + table_pagination = {"per_page": 50} + + def get_queryset(self): + return Transaction.objects.filter(PermissionBackend.filter_queryset( + self.request.user, Transaction, "view") + ).order_by("-id").all()[:50] def get_context_data(self, **kwargs): """ Add some context variables in template such as page title """ context = super().get_context_data(**kwargs) - context['title'] = _('Transfer money from your account ' - 'to one or others') - - context['no_cache'] = True + context['title'] = _('Transfer money') + context['polymorphic_ctype'] = ContentType.objects.get_for_model(Transaction).pk + context['special_polymorphic_ctype'] = ContentType.objects.get_for_model(SpecialTransaction).pk + context['special_types'] = NoteSpecial.objects.order_by("special_type").all() return context - def get_form(self, form_class=None): - """ - If the user has no right to transfer funds, then it won't have the choice of the source of the transfer. - """ - form = super().get_form(form_class) - - if False: # TODO: fix it with "if %user has no right to transfer funds" - del form.fields['source'] - form.user = self.request.user - - return form - - def get_success_url(self): - return reverse('note:transfer') - class NoteAutocomplete(autocomplete.Select2QuerySetView): """ @@ -127,21 +120,30 @@ class ConsoView(LoginRequiredMixin, SingleTableView): """ Consume """ - model = Transaction template_name = "note/conso_form.html" # Transaction history table table_class = HistoryTable - table_pagination = {"per_page": 10} + table_pagination = {"per_page": 50} + + def get_queryset(self): + return Transaction.objects.filter( + PermissionBackend.filter_queryset(self.request.user, Transaction, "view") + ).order_by("-id").all()[:50] def get_context_data(self, **kwargs): """ Add some context variables in template such as page title """ context = super().get_context_data(**kwargs) - context['transaction_templates'] = TransactionTemplate.objects.filter(display=True) \ - .order_by('category') + from django.db.models import Count + buttons = TransactionTemplate.objects.filter( + PermissionBackend().filter_queryset(self.request.user, TransactionTemplate, "view") + ).filter(display=True).annotate(clicks=Count('recurrenttransaction')).order_by('category__name', 'name') + context['transaction_templates'] = buttons + context['most_used'] = buttons.order_by('-clicks', 'name')[:10] context['title'] = _("Consumptions") + context['polymorphic_ctype'] = ContentType.objects.get_for_model(RecurrentTransaction).pk # select2 compatibility context['no_cache'] = True diff --git a/apps/permission/__init__.py b/apps/permission/__init__.py new file mode 100644 index 00000000..4e3eb6bc --- /dev/null +++ b/apps/permission/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +default_app_config = 'permission.apps.PermissionConfig' diff --git a/apps/permission/admin.py b/apps/permission/admin.py new file mode 100644 index 00000000..aaa6f661 --- /dev/null +++ b/apps/permission/admin.py @@ -0,0 +1,31 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-lateré + +from django.contrib import admin + +from .models import Permission, PermissionMask, RolePermissions + + +@admin.register(PermissionMask) +class PermissionMaskAdmin(admin.ModelAdmin): + """ + Admin customisation for PermissionMask + """ + list_display = ('description', 'rank', ) + + +@admin.register(Permission) +class PermissionAdmin(admin.ModelAdmin): + """ + Admin customisation for Permission + """ + list_display = ('type', 'model', 'field', 'mask', 'description', ) + + +@admin.register(RolePermissions) +class RolePermissionsAdmin(admin.ModelAdmin): + """ + Admin customisation for RolePermissions + """ + list_display = ('role', ) + diff --git a/apps/permission/api/__init__.py b/apps/permission/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/permission/api/serializers.py b/apps/permission/api/serializers.py new file mode 100644 index 00000000..0a52f4fe --- /dev/null +++ b/apps/permission/api/serializers.py @@ -0,0 +1,17 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from rest_framework import serializers + +from ..models import Permission + + +class PermissionSerializer(serializers.ModelSerializer): + """ + REST API Serializer for Permission types. + The djangorestframework plugin will analyse the model `Permission` and parse all fields in the API. + """ + + class Meta: + model = Permission + fields = '__all__' diff --git a/apps/permission/api/urls.py b/apps/permission/api/urls.py new file mode 100644 index 00000000..d50344ea --- /dev/null +++ b/apps/permission/api/urls.py @@ -0,0 +1,11 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from .views import PermissionViewSet + + +def register_permission_urls(router, path): + """ + Configure router for permission REST API. + """ + router.register(path, PermissionViewSet) diff --git a/apps/permission/api/views.py b/apps/permission/api/views.py new file mode 100644 index 00000000..6087c83e --- /dev/null +++ b/apps/permission/api/views.py @@ -0,0 +1,20 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django_filters.rest_framework import DjangoFilterBackend + +from api.viewsets import ReadOnlyProtectedModelViewSet +from .serializers import PermissionSerializer +from ..models import Permission + + +class PermissionViewSet(ReadOnlyProtectedModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer, + then render it on /api/logs/ + """ + queryset = Permission.objects.all() + serializer_class = PermissionSerializer + filter_backends = [DjangoFilterBackend] + filterset_fields = ['model', 'type', ] diff --git a/apps/permission/apps.py b/apps/permission/apps.py new file mode 100644 index 00000000..2287fec4 --- /dev/null +++ b/apps/permission/apps.py @@ -0,0 +1,14 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.apps import AppConfig +from django.db.models.signals import pre_save, pre_delete + + +class PermissionConfig(AppConfig): + name = 'permission' + + def ready(self): + from . import signals + pre_save.connect(signals.pre_save_object) + pre_delete.connect(signals.pre_delete_object) diff --git a/apps/permission/backends.py b/apps/permission/backends.py new file mode 100644 index 00000000..e61b0719 --- /dev/null +++ b/apps/permission/backends.py @@ -0,0 +1,116 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.contrib.auth.backends import ModelBackend +from django.contrib.auth.models import User, AnonymousUser +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q, F +from note.models import Note, NoteUser, NoteClub, NoteSpecial +from note_kfet.middlewares import get_current_session +from member.models import Membership, Club + +from .models import Permission + + +class PermissionBackend(ModelBackend): + """ + Manage permissions of users + """ + supports_object_permissions = True + supports_anonymous_user = False + supports_inactive_user = False + + @staticmethod + def permissions(user, model, type): + """ + List all permissions of the given user that applies to a given model and a give type + :param user: The owner of the permissions + :param model: The model that the permissions shoud apply + :param type: The type of the permissions: view, change, add or delete + :return: A generator of the requested permissions + """ + for permission in Permission.objects.annotate(club=F("rolepermissions__role__membership__club")) \ + .filter( + rolepermissions__role__membership__user=user, + model__app_label=model.app_label, # For polymorphic models, we don't filter on model type + type=type, + ).all(): + if not isinstance(model, permission.model.__class__): + continue + + club = Club.objects.get(pk=permission.club) + permission = permission.about( + user=user, + club=club, + User=User, + Club=Club, + Membership=Membership, + Note=Note, + NoteUser=NoteUser, + NoteClub=NoteClub, + NoteSpecial=NoteSpecial, + F=F, + Q=Q + ) + if permission.mask.rank <= get_current_session().get("permission_mask", 0): + yield permission + + @staticmethod + def filter_queryset(user, model, t, field=None): + """ + Filter a queryset by considering the permissions of a given user. + :param user: The owner of the permissions that are fetched + :param model: The concerned model of the queryset + :param t: The type of modification (view, add, change, delete) + :param field: The field of the model to test, if concerned + :return: A query that corresponds to the filter to give to a queryset + """ + + if user is None or isinstance(user, AnonymousUser): + # Anonymous users can't do anything + return Q(pk=-1) + + if user.is_superuser and get_current_session().get("permission_mask", 0) >= 42: + # Superusers have all rights + return Q() + + if not isinstance(model, ContentType): + model = ContentType.objects.get_for_model(model) + + # Never satisfied + query = Q(pk=-1) + perms = PermissionBackend.permissions(user, model, t) + for perm in perms: + if perm.field and field != perm.field: + continue + if perm.type != t or perm.model != model: + continue + perm.update_query() + query = query | perm.query + return query + + def has_perm(self, user_obj, perm, obj=None): + if user_obj is None or isinstance(user_obj, AnonymousUser): + return False + + if user_obj.is_superuser and get_current_session().get("permission_mask", 0) >= 42: + return True + + if obj is None: + return True + + perm = perm.split('.')[-1].split('_', 2) + perm_type = perm[0] + perm_field = perm[2] if len(perm) == 3 else None + ct = ContentType.objects.get_for_model(obj) + if any(permission.applies(obj, perm_type, perm_field) + for permission in self.permissions(user_obj, ct, perm_type)): + return True + return False + + def has_module_perms(self, user_obj, app_label): + return False + + def get_all_permissions(self, user_obj, obj=None): + ct = ContentType.objects.get_for_model(obj) + return list(self.permissions(user_obj, ct, "view")) diff --git a/apps/permission/fixtures/initial.json b/apps/permission/fixtures/initial.json new file mode 100644 index 00000000..69900372 --- /dev/null +++ b/apps/permission/fixtures/initial.json @@ -0,0 +1,554 @@ +[ + { + "model": "member.role", + "pk": 1, + "fields": { + "name": "Adh\u00e9rent BDE" + } + }, + { + "model": "member.role", + "pk": 2, + "fields": { + "name": "Adh\u00e9rent Kfet" + } + }, + { + "model": "member.role", + "pk": 3, + "fields": { + "name": "Pr\u00e9sident\u00b7e BDE" + } + }, + { + "model": "member.role", + "pk": 4, + "fields": { + "name": "Tr\u00e9sorier\u00b7\u00e8re BDE" + } + }, + { + "model": "member.role", + "pk": 5, + "fields": { + "name": "Respo info" + } + }, + { + "model": "member.role", + "pk": 6, + "fields": { + "name": "GC Kfet" + } + }, + { + "model": "member.role", + "pk": 7, + "fields": { + "name": "Pr\u00e9sident\u00b7e de club" + } + }, + { + "model": "member.role", + "pk": 8, + "fields": { + "name": "Tr\u00e9sorier\u00b7\u00e8re de club" + } + }, + { + "model": "permission.permissionmask", + "pk": 1, + "fields": { + "rank": 0, + "description": "Droits basiques" + } + }, + { + "model": "permission.permissionmask", + "pk": 2, + "fields": { + "rank": 1, + "description": "Droits note seulement" + } + }, + { + "model": "permission.permissionmask", + "pk": 3, + "fields": { + "rank": 42, + "description": "Tous mes droits" + } + }, + { + "model": "permission.permission", + "pk": 1, + "fields": { + "model": 21, + "query": "{\"pk\": [\"user\", \"pk\"]}", + "type": "view", + "mask": 1, + "field": "", + "description": "View our User object" + } + }, + { + "model": "permission.permission", + "pk": 2, + "fields": { + "model": 31, + "query": "{\"user\": [\"user\"]}", + "type": "view", + "mask": 1, + "field": "", + "description": "View our profile" + } + }, + { + "model": "permission.permission", + "pk": 3, + "fields": { + "model": 34, + "query": "{\"pk\": [\"user\", \"note\", \"pk\"]}", + "type": "view", + "mask": 1, + "field": "", + "description": "View our own note" + } + }, + { + "model": "permission.permission", + "pk": 4, + "fields": { + "model": 25, + "query": "{\"user\": [\"user\"]}", + "type": "view", + "mask": 1, + "field": "", + "description": "View our API token" + } + }, + { + "model": "permission.permission", + "pk": 5, + "fields": { + "model": 36, + "query": "[\"OR\", {\"source\": [\"user\", \"note\"]}, {\"destination\": [\"user\", \"note\"]}]", + "type": "view", + "mask": 1, + "field": "", + "description": "View our own transactions" + } + }, + { + "model": "permission.permission", + "pk": 6, + "fields": { + "model": 33, + "query": "[\"OR\", {\"note__in\": [\"NoteUser\", \"objects\", [\"filter\", {\"user__membership__club__name\": \"Kfet\"}], [\"all\"]]}, {\"note__in\": [\"NoteClub\", \"objects\", [\"all\"]]}]", + "type": "view", + "mask": 1, + "field": "", + "description": "View aliases of clubs and members of Kfet club" + } + }, + { + "model": "permission.permission", + "pk": 7, + "fields": { + "model": 21, + "query": "{\"pk\": [\"user\", \"pk\"]}", + "type": "change", + "mask": 1, + "field": "last_login", + "description": "Change myself's last login" + } + }, + { + "model": "permission.permission", + "pk": 8, + "fields": { + "model": 21, + "query": "{\"pk\": [\"user\", \"pk\"]}", + "type": "change", + "mask": 1, + "field": "username", + "description": "Change myself's username" + } + }, + { + "model": "permission.permission", + "pk": 9, + "fields": { + "model": 21, + "query": "{\"pk\": [\"user\", \"pk\"]}", + "type": "change", + "mask": 1, + "field": "first_name", + "description": "Change myself's first name" + } + }, + { + "model": "permission.permission", + "pk": 10, + "fields": { + "model": 21, + "query": "{\"pk\": [\"user\", \"pk\"]}", + "type": "change", + "mask": 1, + "field": "last_name", + "description": "Change myself's last name" + } + }, + { + "model": "permission.permission", + "pk": 11, + "fields": { + "model": 21, + "query": "{\"pk\": [\"user\", \"pk\"]}", + "type": "change", + "mask": 1, + "field": "email", + "description": "Change myself's email" + } + }, + { + "model": "permission.permission", + "pk": 12, + "fields": { + "model": 25, + "query": "{\"user\": [\"user\"]}", + "type": "delete", + "mask": 1, + "field": "", + "description": "Delete API Token" + } + }, + { + "model": "permission.permission", + "pk": 13, + "fields": { + "model": 25, + "query": "{\"user\": [\"user\"]}", + "type": "add", + "mask": 1, + "field": "", + "description": "Create API Token" + } + }, + { + "model": "permission.permission", + "pk": 14, + "fields": { + "model": 33, + "query": "{\"note\": [\"user\", \"note\"]}", + "type": "delete", + "mask": 1, + "field": "", + "description": "Remove alias" + } + }, + { + "model": "permission.permission", + "pk": 15, + "fields": { + "model": 33, + "query": "{\"note\": [\"user\", \"note\"]}", + "type": "add", + "mask": 1, + "field": "", + "description": "Add alias" + } + }, + { + "model": "permission.permission", + "pk": 16, + "fields": { + "model": 34, + "query": "{\"pk\": [\"user\", \"note\", \"pk\"]}", + "type": "change", + "mask": 1, + "field": "display_image", + "description": "Change myself's display image" + } + }, + { + "model": "permission.permission", + "pk": 17, + "fields": { + "model": 36, + "query": "[\"AND\", {\"source\": [\"user\", \"note\"]}, {\"amount__lte\": [\"user\", \"note\", \"balance\"]}]", + "type": "add", + "mask": 1, + "field": "", + "description": "Transfer from myself's note" + } + }, + { + "model": "permission.permission", + "pk": 18, + "fields": { + "model": 34, + "query": "{}", + "type": "change", + "mask": 1, + "field": "balance", + "description": "Update a note balance with a transaction" + } + }, + { + "model": "permission.permission", + "pk": 19, + "fields": { + "model": 34, + "query": "[\"OR\", {\"pk\": [\"club\", \"note\", \"pk\"]}, {\"pk__in\": [\"NoteUser\", \"objects\", [\"filter\", {\"user__membership__club\": [\"club\"]}], [\"all\"]]}]", + "type": "view", + "mask": 2, + "field": "", + "description": "View notes of club members" + } + }, + { + "model": "permission.permission", + "pk": 20, + "fields": { + "model": 36, + "query": "[\"AND\", [\"OR\", {\"source\": [\"club\", \"note\"]}, {\"destination\": [\"club\", \"note\"]}], {\"amount__lte\": {\"F\": [\"ADD\", [\"F\", \"source__balance\"], 5000]}}]", + "type": "add", + "mask": 2, + "field": "", + "description": "Create transactions with a club" + } + }, + { + "model": "permission.permission", + "pk": 21, + "fields": { + "model": 42, + "query": "[\"AND\", {\"destination\": [\"club\", \"note\"]}, {\"amount__lte\": {\"F\": [\"ADD\", [\"F\", \"source__balance\"], 5000]}}]", + "type": "add", + "mask": 2, + "field": "", + "description": "Create transactions from buttons with a club" + } + }, + { + "model": "permission.permission", + "pk": 22, + "fields": { + "model": 29, + "query": "{\"pk\": [\"club\", \"pk\"]}", + "type": "view", + "mask": 1, + "field": "", + "description": "View club infos" + } + }, + { + "model": "permission.permission", + "pk": 23, + "fields": { + "model": 36, + "query": "{}", + "type": "change", + "mask": 1, + "field": "valid", + "description": "Update validation status of a transaction" + } + }, + { + "model": "permission.permission", + "pk": 24, + "fields": { + "model": 36, + "query": "{}", + "type": "view", + "mask": 2, + "field": "", + "description": "View all transactions" + } + }, + { + "model": "permission.permission", + "pk": 25, + "fields": { + "model": 40, + "query": "{}", + "type": "view", + "mask": 2, + "field": "", + "description": "Display credit/debit interface" + } + }, + { + "model": "permission.permission", + "pk": 26, + "fields": { + "model": 43, + "query": "{}", + "type": "add", + "mask": 2, + "field": "", + "description": "Create credit/debit transaction" + } + }, + { + "model": "permission.permission", + "pk": 27, + "fields": { + "model": 35, + "query": "{}", + "type": "view", + "mask": 2, + "field": "", + "description": "View button categories" + } + }, + { + "model": "permission.permission", + "pk": 28, + "fields": { + "model": 35, + "query": "{}", + "type": "change", + "mask": 3, + "field": "", + "description": "Change button category" + } + }, + { + "model": "permission.permission", + "pk": 29, + "fields": { + "model": 35, + "query": "{}", + "type": "add", + "mask": 3, + "field": "", + "description": "Add button category" + } + }, + { + "model": "permission.permission", + "pk": 30, + "fields": { + "model": 37, + "query": "{}", + "type": "view", + "mask": 2, + "field": "", + "description": "View buttons" + } + }, + { + "model": "permission.permission", + "pk": 31, + "fields": { + "model": 37, + "query": "{}", + "type": "add", + "mask": 3, + "field": "", + "description": "Add buttons" + } + }, + { + "model": "permission.permission", + "pk": 32, + "fields": { + "model": 37, + "query": "{}", + "type": "change", + "mask": 3, + "field": "", + "description": "Update buttons" + } + }, + { + "model": "permission.permission", + "pk": 33, + "fields": { + "model": 36, + "query": "{}", + "type": "add", + "mask": 2, + "field": "", + "description": "Create any transaction" + } + }, + { + "model": "permission.rolepermissions", + "pk": 1, + "fields": { + "role": 1, + "permissions": [ + 1, + 2, + 7, + 8, + 9, + 10, + 11 + ] + } + }, + { + "model": "permission.rolepermissions", + "pk": 2, + "fields": { + "role": 2, + "permissions": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18 + ] + } + }, + { + "model": "permission.rolepermissions", + "pk": 3, + "fields": { + "role": 8, + "permissions": [ + 19, + 20, + 21, + 22 + ] + } + }, + { + "model": "permission.rolepermissions", + "pk": 4, + "fields": { + "role": 4, + "permissions": [ + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33 + ] + } + } +] \ No newline at end of file diff --git a/apps/permission/migrations/__init__.py b/apps/permission/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/permission/models.py b/apps/permission/models.py new file mode 100644 index 00000000..109c1875 --- /dev/null +++ b/apps/permission/models.py @@ -0,0 +1,284 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +import functools +import json +import operator + +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import F, Q, Model +from django.utils.translation import gettext_lazy as _ + +from member.models import Role + + +class InstancedPermission: + + def __init__(self, model, query, type, field, mask, **kwargs): + self.model = model + self.raw_query = query + self.query = None + self.type = type + self.field = field + self.mask = mask + self.kwargs = kwargs + + def applies(self, obj, permission_type, field_name=None): + """ + Returns True if the permission applies to + the field `field_name` object `obj` + """ + + if not isinstance(obj, self.model.model_class()): + # The permission does not apply to the model + return False + + if self.type == 'add': + if permission_type == self.type: + self.update_query() + + # Don't increase indexes + obj.pk = 0 + # Force insertion, no data verification, no trigger + Model.save(obj, force_insert=True) + ret = obj in self.model.model_class().objects.filter(self.query).all() + # Delete testing object + Model.delete(obj) + return ret + + if permission_type == self.type: + if self.field and field_name != self.field: + return False + self.update_query() + return obj in self.model.model_class().objects.filter(self.query).all() + else: + return False + + def update_query(self): + """ + The query is not analysed in a first time. It is analysed at most once if needed. + :return: + """ + if not self.query: + # noinspection PyProtectedMember + self.query = Permission._about(self.raw_query, **self.kwargs) + + def __repr__(self): + if self.field: + return _("Can {type} {model}.{field} in {query}").format(type=self.type, model=self.model, field=self.field, query=self.query) + else: + return _("Can {type} {model} in {query}").format(type=self.type, model=self.model, query=self.query) + + def __str__(self): + return self.__repr__() + + +class PermissionMask(models.Model): + """ + Permissions that are hidden behind a mask + """ + + rank = models.PositiveSmallIntegerField( + unique=True, + verbose_name=_('rank'), + ) + + description = models.CharField( + max_length=255, + unique=True, + verbose_name=_('description'), + ) + + def __str__(self): + return self.description + + +class Permission(models.Model): + + PERMISSION_TYPES = [ + ('add', 'add'), + ('view', 'view'), + ('change', 'change'), + ('delete', 'delete') + ] + + model = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name='+') + + # A json encoded Q object with the following grammar + # query -> [] | {} (the empty query representing all objects) + # query -> ["AND", query, …] AND multiple queries + # | ["OR", query, …] OR multiple queries + # | ["NOT", query] Opposite of query + # query -> {key: value, …} A list of fields and values of a Q object + # key -> string A field name + # value -> int | string | bool | null Literal values + # | [parameter, …] A parameter. See compute_param for more details. + # | {"F": oper} An F object + # oper -> [string, …] A parameter. See compute_param for more details. + # | ["ADD", oper, …] Sum multiple F objects or literal + # | ["SUB", oper, oper] Substract two F objects or literal + # | ["MUL", oper, …] Multiply F objects or literals + # | int | string | bool | null Literal values + # | ["F", string] A field + # + # Examples: + # Q(is_superuser=True) := {"is_superuser": true} + # ~Q(is_superuser=True) := ["NOT", {"is_superuser": true}] + query = models.TextField() + + type = models.CharField(max_length=15, choices=PERMISSION_TYPES) + + mask = models.ForeignKey( + PermissionMask, + on_delete=models.PROTECT, + ) + + field = models.CharField(max_length=255, blank=True) + + description = models.CharField(max_length=255, blank=True) + + class Meta: + unique_together = ('model', 'query', 'type', 'field') + + def clean(self): + self.query = json.dumps(json.loads(self.query)) + if self.field and self.type not in {'view', 'change'}: + raise ValidationError(_("Specifying field applies only to view and change permission types.")) + + def save(self, **kwargs): + self.full_clean() + super().save() + + @staticmethod + def compute_f(oper, **kwargs): + if isinstance(oper, list): + if oper[0] == 'ADD': + return functools.reduce(operator.add, [Permission.compute_f(oper, **kwargs) for oper in oper[1:]]) + elif oper[0] == 'SUB': + return Permission.compute_f(oper[1], **kwargs) - Permission.compute_f(oper[2], **kwargs) + elif oper[0] == 'MUL': + return functools.reduce(operator.mul, [Permission.compute_f(oper, **kwargs) for oper in oper[1:]]) + elif oper[0] == 'F': + return F(oper[1]) + else: + field = kwargs[oper[0]] + for i in range(1, len(oper)): + field = getattr(field, oper[i]) + return field + else: + return oper + + @staticmethod + def compute_param(value, **kwargs): + """ + A parameter is given by a list. The first argument is the name of the parameter. + The parameters are the user, the club, and some classes (Note, ...) + If there are more arguments in the list, then attributes are queried. + For example, ["user", "note", "balance"] will return the balance of the note of the user. + If an argument is a list, then this is interpreted with a function call: + First argument is the name of the function, next arguments are parameters, and if there is a dict, + then the dict is given as kwargs. + For example: NoteUser.objects.filter(user__memberships__club__name="Kfet").all() is translated by: + ["NoteUser", "objects", ["filter", {"user__memberships__club__name": "Kfet"}], ["all"]] + """ + + if not isinstance(value, list): + return value + + field = kwargs[value[0]] + for i in range(1, len(value)): + if isinstance(value[i], list): + if value[i][0] in kwargs: + field = Permission.compute_param(value[i], **kwargs) + continue + + field = getattr(field, value[i][0]) + params = [] + call_kwargs = {} + for j in range(1, len(value[i])): + param = Permission.compute_param(value[i][j], **kwargs) + if isinstance(param, dict): + for key in param: + val = Permission.compute_param(param[key], **kwargs) + call_kwargs[key] = val + else: + params.append(param) + field = field(*params, **call_kwargs) + else: + field = getattr(field, value[i]) + return field + + @staticmethod + def _about(query, **kwargs): + """ + Translate JSON query into a Q query. + :param query: The JSON query + :param kwargs: Additional params + :return: A Q object + """ + if len(query) == 0: + # The query is either [] or {} and + # applies to all objects of the model + # to represent this we return a trivial request + return Q(pk=F("pk")) + if isinstance(query, list): + if query[0] == 'AND': + return functools.reduce(operator.and_, [Permission._about(query, **kwargs) for query in query[1:]]) + elif query[0] == 'OR': + return functools.reduce(operator.or_, [Permission._about(query, **kwargs) for query in query[1:]]) + elif query[0] == 'NOT': + return ~Permission._about(query[1], **kwargs) + else: + return Q(pk=F("pk")) + elif isinstance(query, dict): + q_kwargs = {} + for key in query: + value = query[key] + if isinstance(value, list): + # It is a parameter we query its return value + q_kwargs[key] = Permission.compute_param(value, **kwargs) + elif isinstance(value, dict): + # It is an F object + q_kwargs[key] = Permission.compute_f(value['F'], **kwargs) + else: + q_kwargs[key] = value + return Q(**q_kwargs) + else: + # TODO: find a better way to crash here + raise Exception("query {} is wrong".format(query)) + + def about(self, **kwargs): + """ + Return an InstancedPermission with the parameters + replaced by their values and the query interpreted + """ + query = json.loads(self.query) + # query = self._about(query, **kwargs) + return InstancedPermission(self.model, query, self.type, self.field, self.mask, **kwargs) + + def __str__(self): + if self.field: + return _("Can {type} {model}.{field} in {query}").format(type=self.type, model=self.model, field=self.field, query=self.query) + else: + return _("Can {type} {model} in {query}").format(type=self.type, model=self.model, query=self.query) + + +class RolePermissions(models.Model): + """ + Permissions associated with a Role + """ + role = models.ForeignKey( + Role, + on_delete=models.PROTECT, + related_name='+', + verbose_name=_('role'), + ) + permissions = models.ManyToManyField( + Permission, + ) + + def __str__(self): + return str(self.role) + diff --git a/apps/permission/permissions.py b/apps/permission/permissions.py new file mode 100644 index 00000000..d9816a63 --- /dev/null +++ b/apps/permission/permissions.py @@ -0,0 +1,63 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from rest_framework.permissions import DjangoObjectPermissions + +SAFE_METHODS = ('HEAD', 'OPTIONS', ) + + +class StrongDjangoObjectPermissions(DjangoObjectPermissions): + """ + Default DjangoObjectPermissions grant view permission to all. + This is a simple patch of this class that controls view access. + """ + + perms_map = { + 'GET': ['%(app_label)s.view_%(model_name)s'], + 'OPTIONS': [], + 'HEAD': [], + 'POST': ['%(app_label)s.add_%(model_name)s'], + 'PUT': ['%(app_label)s.change_%(model_name)s'], + 'PATCH': ['%(app_label)s.change_%(model_name)s'], + 'DELETE': ['%(app_label)s.delete_%(model_name)s'], + } + + def get_required_object_permissions(self, method, model_cls): + kwargs = { + 'app_label': model_cls._meta.app_label, + 'model_name': model_cls._meta.model_name + } + + if method not in self.perms_map: + from rest_framework import exceptions + raise exceptions.MethodNotAllowed(method) + + return [perm % kwargs for perm in self.perms_map[method]] + + def has_object_permission(self, request, view, obj): + # authentication checks have already executed via has_permission + queryset = self._queryset(view) + model_cls = queryset.model + user = request.user + + perms = self.get_required_object_permissions(request.method, model_cls) + + if not user.has_perms(perms, obj): + # If the user does not have permissions we need to determine if + # they have read permissions to see 403, or not, and simply see + # a 404 response. + from django.http import Http404 + + if request.method in SAFE_METHODS: + # Read permissions already checked and failed, no need + # to make another lookup. + raise Http404 + + read_perms = self.get_required_object_permissions('GET', model_cls) + if not user.has_perms(read_perms, obj): + raise Http404 + + # Has read permissions. + return False + + return True diff --git a/apps/permission/signals.py b/apps/permission/signals.py new file mode 100644 index 00000000..aebca39d --- /dev/null +++ b/apps/permission/signals.py @@ -0,0 +1,106 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.core.exceptions import PermissionDenied +from django.db.models.signals import pre_save, pre_delete, post_save, post_delete + +from logs import signals as logs_signals +from permission.backends import PermissionBackend +from note_kfet.middlewares import get_current_authenticated_user + + +EXCLUDED = [ + 'cas_server.proxygrantingticket', + 'cas_server.proxyticket', + 'cas_server.serviceticket', + 'cas_server.user', + 'cas_server.userattributes', + 'contenttypes.contenttype', + 'logs.changelog', + 'migrations.migration', + 'sessions.session', +] + + +def pre_save_object(sender, instance, **kwargs): + """ + Before a model get saved, we check the permissions + """ + # noinspection PyProtectedMember + if instance._meta.label_lower in EXCLUDED: + return + + user = get_current_authenticated_user() + if user is None: + # Action performed on shell is always granted + return + + qs = sender.objects.filter(pk=instance.pk).all() + model_name_full = instance._meta.label_lower.split(".") + app_label = model_name_full[0] + model_name = model_name_full[1] + + if qs.exists(): + # We check if the user can change the model + + # If the user has all right on a model, then OK + if PermissionBackend().has_perm(user, app_label + ".change_" + model_name, instance): + return + + # In the other case, we check if he/she has the right to change one field + previous = qs.get() + for field in instance._meta.fields: + field_name = field.name + old_value = getattr(previous, field.name) + new_value = getattr(instance, field.name) + # If the field wasn't modified, no need to check the permissions + if old_value == new_value: + continue + 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): + """ + Before a model get deleted, we check the permissions + """ + # noinspection PyProtectedMember + if instance._meta.label_lower in EXCLUDED: + return + + user = get_current_authenticated_user() + if user is None: + # Action performed on shell is always granted + return + + model_name_full = instance._meta.label_lower.split(".") + app_label = model_name_full[0] + model_name = model_name_full[1] + + # We check if the user has rights to delete the object + if not PermissionBackend().has_perm(user, app_label + ".delete_" + model_name, instance): + raise PermissionDenied diff --git a/apps/permission/templatetags/__init__.py b/apps/permission/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/permission/templatetags/perms.py b/apps/permission/templatetags/perms.py new file mode 100644 index 00000000..8f2a0006 --- /dev/null +++ b/apps/permission/templatetags/perms.py @@ -0,0 +1,55 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.contrib.contenttypes.models import ContentType +from django.template.defaultfilters import stringfilter + +from note_kfet.middlewares import get_current_authenticated_user, get_current_session +from django import template + +from permission.backends import PermissionBackend + + +@stringfilter +def not_empty_model_list(model_name): + """ + Return True if and only if the current user has right to see any object of the given model. + """ + user = get_current_authenticated_user() + session = get_current_session() + if user is None: + return False + elif user.is_superuser and session.get("permission_mask", 0) >= 42: + return True + if session.get("not_empty_model_list_" + model_name, None): + return session.get("not_empty_model_list_" + model_name, None) == 1 + spl = model_name.split(".") + ct = ContentType.objects.get(app_label=spl[0], model=spl[1]) + qs = ct.model_class().objects.filter(PermissionBackend.filter_queryset(user, ct, "view")).all() + session["not_empty_model_list_" + model_name] = 1 if qs.exists() else 2 + return session.get("not_empty_model_list_" + model_name) == 1 + + +@stringfilter +def not_empty_model_change_list(model_name): + """ + Return True if and only if the current user has right to change any object of the given model. + """ + user = get_current_authenticated_user() + session = get_current_session() + if user is None: + return False + elif user.is_superuser and session.get("permission_mask", 0) >= 42: + return True + if session.get("not_empty_model_change_list_" + model_name, None): + return session.get("not_empty_model_change_list_" + model_name, None) == 1 + spl = model_name.split(".") + ct = ContentType.objects.get(app_label=spl[0], model=spl[1]) + qs = ct.model_class().objects.filter(PermissionBackend.filter_queryset(user, ct, "change")) + session["not_empty_model_change_list_" + model_name] = 1 if qs.exists() else 2 + return session.get("not_empty_model_change_list_" + model_name) == 1 + + +register = template.Library() +register.filter('not_empty_model_list', not_empty_model_list) +register.filter('not_empty_model_change_list', not_empty_model_change_list) diff --git a/apps/scripts b/apps/scripts deleted file mode 160000 index 123466cf..00000000 --- a/apps/scripts +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 123466cfa914422422cd372197e64adf65ef05f7 diff --git a/entrypoint.sh b/entrypoint.sh index e5a22a5a..4d0177e8 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -7,7 +7,7 @@ if [ -z ${NOTE_URL+x} ]; then else sed -i -e "s/example.com/$DOMAIN/g" /code/apps/member/fixtures/initial.json sed -i -e "s/localhost/$NOTE_URL/g" /code/note_kfet/fixtures/initial.json - sed -i -e "s/\.\*/https?:\/\/$NOTE_URL\/.*/g" /code/note_kfet/fixtures/cas.json + sed -i -e "s/\"\.\*\"/\"https?:\/\/$NOTE_URL\/.*\"/g" /code/note_kfet/fixtures/cas.json sed -i -e "s/REPLACEME/La Note Kfet \\\\ud83c\\\\udf7b/g" /code/note_kfet/fixtures/cas.json fi diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index 4d791b42..69168864 100644 --- a/locale/de/LC_MESSAGES/django.po +++ b/locale/de/LC_MESSAGES/django.po @@ -25,7 +25,8 @@ msgstr "" #: apps/activity/models.py:19 apps/activity/models.py:44 #: apps/member/models.py:61 apps/member/models.py:112 #: apps/note/models/notes.py:188 apps/note/models/transactions.py:24 -#: apps/note/models/transactions.py:44 templates/member/profile_detail.html:15 +#: apps/note/models/transactions.py:44 apps/note/models/transactions.py:202 +#: templates/member/profile_detail.html:15 msgid "name" msgstr "" @@ -51,7 +52,7 @@ msgid "description" msgstr "" #: apps/activity/models.py:54 apps/note/models/notes.py:164 -#: apps/note/models/transactions.py:62 +#: apps/note/models/transactions.py:62 apps/note/models/transactions.py:115 msgid "type" msgstr "" @@ -254,12 +255,12 @@ msgstr "" msgid "Alias successfully deleted" msgstr "" -#: apps/note/admin.py:120 apps/note/models/transactions.py:93 +#: apps/note/admin.py:120 apps/note/models/transactions.py:94 msgid "source" msgstr "" #: apps/note/admin.py:128 apps/note/admin.py:156 -#: apps/note/models/transactions.py:53 apps/note/models/transactions.py:99 +#: apps/note/models/transactions.py:53 apps/note/models/transactions.py:100 msgid "destination" msgstr "" @@ -279,10 +280,6 @@ msgstr "" msgid "Maximal size: 2MB" msgstr "" -#: apps/note/forms.py:70 -msgid "Source and destination must be different." -msgstr "" - #: apps/note/models/notes.py:27 msgid "account balance" msgstr "" @@ -313,7 +310,7 @@ msgstr "" msgid "display image" msgstr "" -#: apps/note/models/notes.py:53 apps/note/models/transactions.py:102 +#: apps/note/models/notes.py:53 apps/note/models/transactions.py:103 msgid "created at" msgstr "" @@ -399,7 +396,7 @@ msgstr "" msgid "A template with this name already exist" msgstr "" -#: apps/note/models/transactions.py:56 apps/note/models/transactions.py:109 +#: apps/note/models/transactions.py:56 apps/note/models/transactions.py:111 msgid "amount" msgstr "" @@ -407,31 +404,57 @@ msgstr "" msgid "in centimes" msgstr "" -#: apps/note/models/transactions.py:74 +#: apps/note/models/transactions.py:75 msgid "transaction template" msgstr "" -#: apps/note/models/transactions.py:75 +#: apps/note/models/transactions.py:76 msgid "transaction templates" msgstr "" -#: apps/note/models/transactions.py:106 +#: apps/note/models/transactions.py:107 msgid "quantity" msgstr "" -#: apps/note/models/transactions.py:111 +#: apps/note/models/transactions.py:117 templates/note/transaction_form.html:15 +msgid "Gift" +msgstr "" + +#: apps/note/models/transactions.py:118 templates/base.html:90 +#: templates/note/transaction_form.html:19 +#: templates/note/transaction_form.html:126 +msgid "Transfer" +msgstr "" + +#: apps/note/models/transactions.py:119 +msgid "Template" +msgstr "" + +#: apps/note/models/transactions.py:120 templates/note/transaction_form.html:23 +msgid "Credit" +msgstr "" + +#: apps/note/models/transactions.py:121 templates/note/transaction_form.html:27 +msgid "Debit" +msgstr "" + +#: apps/note/models/transactions.py:122 apps/note/models/transactions.py:230 +msgid "membership transaction" +msgstr "" + +#: apps/note/models/transactions.py:129 msgid "reason" msgstr "" -#: apps/note/models/transactions.py:115 +#: apps/note/models/transactions.py:133 msgid "valid" msgstr "" -#: apps/note/models/transactions.py:120 +#: apps/note/models/transactions.py:138 msgid "transaction" msgstr "" -#: apps/note/models/transactions.py:121 +#: apps/note/models/transactions.py:139 msgid "transactions" msgstr "" @@ -439,12 +462,21 @@ msgstr "" msgid "membership transaction" msgstr "" +#: apps/note/models/transactions.py:207 +msgid "first_name" +msgstr "" + #: apps/note/models/transactions.py:191 +#: apps/note/models/transactions.py:212 +msgid "bank" +msgstr "" + +#: apps/note/models/transactions.py:231 msgid "membership transactions" msgstr "" #: apps/note/views.py:31 -msgid "Transfer money from your account to one or others" +msgid "Transfer money" msgstr "" #: apps/note/views.py:144 templates/base.html:70 @@ -549,8 +581,8 @@ msgstr "" msgid "Unit price" msgstr "" -#: apps/treasury/tables.py:14 -msgid "Billing #" +#: apps/note/views.py:132 templates/base.html:78 +msgid "Consumptions" msgstr "" #: apps/treasury/tables.py:17 @@ -580,11 +612,11 @@ msgstr "" msgid "The ENS Paris-Saclay BDE note." msgstr "" -#: templates/base.html:73 +#: templates/base.html:81 msgid "Clubs" msgstr "" -#: templates/base.html:76 +#: templates/base.html:84 msgid "Activities" msgstr "" @@ -653,6 +685,7 @@ msgstr "" #: templates/django_filters/rest_framework/form.html:5 #: templates/member/club_form.html:10 templates/treasury/billing_form.html:38 +#: templates/member/club_form.html:10 msgid "Submit" msgstr "" @@ -737,6 +770,83 @@ msgstr "" msgid "Sign up" msgstr "" +#: templates/note/conso_form.html:28 templates/note/transaction_form.html:38 +msgid "Select emitters" +msgstr "" + +#: templates/note/conso_form.html:45 +msgid "Select consumptions" +msgstr "" + +#: templates/note/conso_form.html:51 +msgid "Consume!" +msgstr "" + +#: templates/note/conso_form.html:64 +msgid "Most used buttons" +msgstr "" + +#: templates/note/conso_form.html:121 +msgid "Edit" +msgstr "" + +#: templates/note/conso_form.html:126 +msgid "Single consumptions" +msgstr "" + +#: templates/note/conso_form.html:130 +msgid "Double consumptions" +msgstr "" + +#: templates/note/conso_form.html:141 +msgid "Recent transactions history" +msgstr "" + +#: templates/note/transaction_form.html:55 +msgid "External payment" +msgstr "" + +#: templates/note/transaction_form.html:63 +msgid "Transfer type" +msgstr "" + +#: templates/note/transaction_form.html:73 +msgid "Name" +msgstr "" + +#: templates/note/transaction_form.html:79 +msgid "First name" +msgstr "" + +#: templates/note/transaction_form.html:85 +msgid "Bank" +msgstr "" + +#: templates/note/transaction_form.html:97 +#: templates/note/transaction_form.html:179 +#: templates/note/transaction_form.html:186 +msgid "Select receivers" +msgstr "" + +#: templates/note/transaction_form.html:114 +msgid "Amount" +msgstr "" + +#: templates/note/transaction_form.html:119 +msgid "Reason" +msgstr "" + +#: templates/note/transaction_form.html:193 +msgid "Credit note" +msgstr "" + +#: templates/note/transaction_form.html:200 +msgid "Debit note" +msgstr "" + +msgid "Sign up" +msgstr "" + #: templates/note/transactiontemplate_form.html:6 msgid "Buttons list" msgstr "" diff --git a/note_kfet/middlewares.py b/note_kfet/middlewares.py index b034e2be..fff824c5 100644 --- a/note_kfet/middlewares.py +++ b/note_kfet/middlewares.py @@ -1,6 +1,66 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +from django.conf import settings +from django.contrib.auth.models import AnonymousUser, User + +from threading import local + +from django.contrib.sessions.backends.db import SessionStore + +USER_ATTR_NAME = getattr(settings, 'LOCAL_USER_ATTR_NAME', '_current_user') +SESSION_ATTR_NAME = getattr(settings, 'LOCAL_SESSION_ATTR_NAME', '_current_session') +IP_ATTR_NAME = getattr(settings, 'LOCAL_IP_ATTR_NAME', '_current_ip') + +_thread_locals = local() + + +def _set_current_user_and_ip(user=None, session=None, ip=None): + setattr(_thread_locals, USER_ATTR_NAME, user) + setattr(_thread_locals, SESSION_ATTR_NAME, session) + setattr(_thread_locals, IP_ATTR_NAME, ip) + + +def get_current_user() -> User: + return getattr(_thread_locals, USER_ATTR_NAME, None) + + +def get_current_session() -> SessionStore: + return getattr(_thread_locals, SESSION_ATTR_NAME, None) + + +def get_current_ip() -> str: + return getattr(_thread_locals, IP_ATTR_NAME, None) + + +def get_current_authenticated_user(): + current_user = get_current_user() + if isinstance(current_user, AnonymousUser): + return None + return current_user + + +class SessionMiddleware(object): + """ + This middleware get the current user with his or her IP address on each request. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + user = request.user + if 'HTTP_X_FORWARDED_FOR' in request.META: + ip = request.META.get('HTTP_X_FORWARDED_FOR') + else: + ip = request.META.get('REMOTE_ADDR') + + _set_current_user_and_ip(user, request.session, ip) + response = self.get_response(request) + _set_current_user_and_ip(None, None, None) + + return response + class TurbolinksMiddleware(object): """ diff --git a/note_kfet/settings/__init__.py b/note_kfet/settings/__init__.py index 28935deb..1ab06b9c 100644 --- a/note_kfet/settings/__init__.py +++ b/note_kfet/settings/__init__.py @@ -41,6 +41,8 @@ else: try: #in secrets.py defines everything you want from .secrets import * + INSTALLED_APPS += OPTIONAL_APPS + except ImportError: pass @@ -74,7 +76,7 @@ if "cas" in INSTALLED_APPS: if "logs" in INSTALLED_APPS: - MIDDLEWARE += ('logs.middlewares.LogsMiddleware',) + MIDDLEWARE += ('note_kfet.middlewares.SessionMiddleware',) if "debug_toolbar" in INSTALLED_APPS: MIDDLEWARE.insert(1, "debug_toolbar.middleware.DebugToolbarMiddleware") diff --git a/note_kfet/settings/base.py b/note_kfet/settings/base.py index d85071c5..84bd3249 100644 --- a/note_kfet/settings/base.py +++ b/note_kfet/settings/base.py @@ -39,8 +39,6 @@ INSTALLED_APPS = [ 'polymorphic', 'crispy_forms', 'django_tables2', - 'cas_server', - 'cas', # Django contrib 'django.contrib.admin', 'django.contrib.admindocs', @@ -62,6 +60,7 @@ INSTALLED_APPS = [ 'member', 'note', 'treasury', + 'permission', 'api', 'logs', ] @@ -127,18 +126,15 @@ PASSWORD_HASHERS = [ 'member.hashers.CustomNK15Hasher', ] -# Django Guardian object permissions - AUTHENTICATION_BACKENDS = ( - 'django.contrib.auth.backends.ModelBackend', # this is default + 'permission.backends.PermissionBackend', # Custom role-based permission system + 'cas.backends.CASBackend', # For CAS connections ) REST_FRAMEWORK = { - # Use Django's standard `django.contrib.auth` permissions, - # or allow read-only access for unauthenticated users. 'DEFAULT_PERMISSION_CLASSES': [ - # TODO Maybe replace it with our custom permissions system - 'rest_framework.permissions.DjangoModelPermissions', + # Control API access with our role-based permission system + 'permission.permissions.StrongDjangoObjectPermissions', ], 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework.authentication.SessionAuthentication', diff --git a/note_kfet/urls.py b/note_kfet/urls.py index 80808bbe..40a9a614 100644 --- a/note_kfet/urls.py +++ b/note_kfet/urls.py @@ -7,6 +7,8 @@ from django.contrib import admin from django.urls import path, include from django.views.generic import RedirectView +from member.views import CustomLoginView + urlpatterns = [ # Dev so redirect to something random path('', RedirectView.as_view(pattern_name='note:transfer'), name='index'), @@ -17,10 +19,11 @@ urlpatterns = [ # Include Django Contrib and Core routers path('i18n/', include('django.conf.urls.i18n')), - path('accounts/', include('member.urls')), - path('accounts/', include('django.contrib.auth.urls')), path('admin/doc/', include('django.contrib.admindocs.urls')), path('admin/', admin.site.urls), + path('accounts/', include('member.urls')), + path('accounts/login/', CustomLoginView.as_view()), + path('accounts/', include('django.contrib.auth.urls')), path('api/', include('api.urls')), ] diff --git a/requirements/api.txt b/requirements/api.txt deleted file mode 100644 index 8dd9f5f2..00000000 --- a/requirements/api.txt +++ /dev/null @@ -1,3 +0,0 @@ -djangorestframework==3.9.0 -django-rest-polymorphic==0.1.8 - diff --git a/requirements/base.txt b/requirements/base.txt index e9dc7635..6c5fbc4c 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -19,4 +19,6 @@ requests==2.22.0 requests-oauthlib==1.2.0 six==1.12.0 sqlparse==0.3.0 +djangorestframework==3.9.0 +django-rest-polymorphic==0.1.8 urllib3==1.25.3 diff --git a/static/js/base.js b/static/js/base.js new file mode 100644 index 00000000..f7085850 --- /dev/null +++ b/static/js/base.js @@ -0,0 +1,297 @@ +// Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +// SPDX-License-Identifier: GPL-3.0-or-later + + +/** + * Convert balance in cents to a human readable amount + * @param value the balance, in cents + * @returns {string} + */ +function pretty_money(value) { + if (value % 100 === 0) + return (value < 0 ? "- " : "") + Math.floor(Math.abs(value) / 100) + " €"; + else + return (value < 0 ? "- " : "") + Math.floor(Math.abs(value) / 100) + "." + + (Math.abs(value) % 100 < 10 ? "0" : "") + (Math.abs(value) % 100) + " €"; +} + +/** + * Add a message on the top of the page. + * @param msg The message to display + * @param alert_type The type of the alert. Choices: info, success, warning, danger + */ +function addMsg(msg, alert_type) { + let msgDiv = $("#messages"); + let html = msgDiv.html(); + html += "
- Sélection des émitteurs + {% trans "Select emitters" %}
+ {% trans "Select consumptions" %} +
+