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 e4d76f07..00000000 --- a/apps/logs/middlewares.py +++ /dev/null @@ -1,77 +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): - """ - Store current user and IP address in the local thread. - """ - setattr(_thread_locals, USER_ATTR_NAME, user) - setattr(_thread_locals, IP_ATTR_NAME, ip) - - -def get_current_user(): - """ - :return: The user that performed a request (may be anonymous) - """ - return getattr(_thread_locals, USER_ATTR_NAME, None) - - -def get_current_ip(): - """ - :return: The IP address of the user that has performed a request - """ - return getattr(_thread_locals, IP_ATTR_NAME, None) - - -def get_current_authenticated_user(): - """ - :return: The user that performed a request (must be authenticated, return None if anonymous) - """ - current_user = get_current_user() - if isinstance(current_user, AnonymousUser): - return None - return current_user - - -class LogsMiddleware(object): - """ - This middleware gets 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): - """ - This function is called on each request. - :param request: The HTTP Request - :return: The HTTP Response - """ - user = request.user - # Get request IP from the headers - # The `REMOTE_ADDR` field may not contain the true IP, if there is a proxy - if 'HTTP_X_FORWARDED_FOR' in request.META: - ip = request.META.get('HTTP_X_FORWARDED_FOR') - else: - ip = request.META.get('REMOTE_ADDR') - - # The user and the IP address are stored in the current thread - _set_current_user_and_ip(user, ip) - # The request is then analysed, and the response is generated - response = self.get_response(request) - # We flush the connected user and the IP address for the next requests - _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 dacfde33..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,6 +131,9 @@ 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'] @@ -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 85f500ed..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, SpecialTransaction + RecurrentTransaction, SpecialTransaction class NoteSerializer(serializers.ModelSerializer): @@ -18,6 +18,7 @@ class NoteSerializer(serializers.ModelSerializer): class Meta: model = Note fields = '__all__' + read_only_fields = [f.name for f in model._meta.get_fields()] # Notes are read-only protected class NoteClubSerializer(serializers.ModelSerializer): @@ -30,6 +31,7 @@ class NoteClubSerializer(serializers.ModelSerializer): class Meta: model = NoteClub fields = '__all__' + read_only_fields = ('note', 'club', ) def get_name(self, obj): return str(obj) @@ -45,6 +47,7 @@ class NoteSpecialSerializer(serializers.ModelSerializer): class Meta: model = NoteSpecial fields = '__all__' + read_only_fields = ('note', ) def get_name(self, obj): return str(obj) @@ -60,6 +63,7 @@ class NoteUserSerializer(serializers.ModelSerializer): class Meta: model = NoteUser fields = '__all__' + read_only_fields = ('note', 'user', ) def get_name(self, obj): return str(obj) @@ -70,14 +74,11 @@ class AliasSerializer(serializers.ModelSerializer): REST API Serializer for Aliases. The djangorestframework plugin will analyse the model `Alias` and parse all fields in the API. """ - note = serializers.SerializerMethodField() class Meta: model = Alias fields = '__all__' - - def get_note(self, alias): - return NotePolymorphicSerializer().to_representation(alias.note) + read_only_fields = ('note', ) class NotePolymorphicSerializer(PolymorphicSerializer): @@ -88,6 +89,9 @@ class NotePolymorphicSerializer(PolymorphicSerializer): NoteSpecial: NoteSpecialSerializer } + class Meta: + model = Note + class TemplateCategorySerializer(serializers.ModelSerializer): """ @@ -122,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__' @@ -158,7 +162,10 @@ class SpecialTransactionSerializer(serializers.ModelSerializer): 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 29c79bd8..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 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, @@ -70,29 +29,18 @@ class NotePolymorphicViewSet(viewsets.ModelViewSet): 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.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, @@ -110,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, @@ -150,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, @@ -162,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/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 86c00737..0e40edf6 100644 --- a/apps/note/models/transactions.py +++ b/apps/note/models/transactions.py @@ -152,10 +152,12 @@ 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): @@ -166,7 +168,7 @@ class Transaction(PolymorphicModel): return _('Transfer') -class TemplateTransaction(Transaction): +class RecurrentTransaction(Transaction): """ Special type of :model:`note.Transaction` associated to a :model:`note.TransactionTemplate`. """ diff --git a/apps/note/views.py b/apps/note/views.py index 31a79be7..84df2bd7 100644 --- a/apps/note/views.py +++ b/apps/note/views.py @@ -8,9 +8,10 @@ from django.db.models import Q 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 TransactionTemplateForm -from .models import Transaction, TransactionTemplate, Alias, TemplateTransaction, NoteSpecial +from .models import Transaction, TransactionTemplate, Alias, RecurrentTransaction, NoteSpecial from .models.transactions import SpecialTransaction from .tables import HistoryTable @@ -18,16 +19,18 @@ from .tables import HistoryTable class TransactionCreate(LoginRequiredMixin, SingleTableView): """ Show transfer page - - TODO: If user have sufficient rights, they can transfer from an other note """ - queryset = Transaction.objects.order_by("-id").all()[:50] 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 @@ -117,25 +120,30 @@ class ConsoView(LoginRequiredMixin, SingleTableView): """ Consume """ - queryset = Transaction.objects.order_by("-id").all()[:50] template_name = "note/conso_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) from django.db.models import Count - buttons = TransactionTemplate.objects.filter(display=True) \ - .annotate(clicks=Count('templatetransaction')).order_by('category__name', 'name') + 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(TemplateTransaction).pk + 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/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/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 0a8ce64d..1ab06b9c 100644 --- a/note_kfet/settings/__init__.py +++ b/note_kfet/settings/__init__.py @@ -76,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 566ca295..64016b27 100644 --- a/note_kfet/settings/base.py +++ b/note_kfet/settings/base.py @@ -59,6 +59,7 @@ INSTALLED_APPS = [ 'activity', 'member', 'note', + 'permission', 'api', 'logs', ] @@ -124,18 +125,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 da2f9d6c..9170c62e 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'), @@ -16,10 +18,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/static/js/base.js b/static/js/base.js index 2362375b..f7085850 100644 --- a/static/js/base.js +++ b/static/js/base.js @@ -61,13 +61,23 @@ function li(id, text) { * @param profile_pic_field */ function displayNote(note, alias, user_note_field=null, profile_pic_field=null) { - let img = note == null ? null : note.display_image; - if (img == null) - img = '/media/pic/default.png'; - if (note !== null && alias !== note.name) + if (!note.display_image) { + note.display_image = 'https://nk20.ynerant.fr/media/pic/default.png'; + $.getJSON("/api/note/note/" + note.id + "/?format=json", function(new_note) { + note.display_image = new_note.display_image.replace("http:", "https:"); + note.name = new_note.name; + note.balance = new_note.balance; + + displayNote(note, alias, user_note_field, profile_pic_field); + }); + return; + } + + let img = note.display_image; + if (alias !== note.name) alias += " (aka. " + note.name + ")"; - if (note !== null && user_note_field !== null) - $("#" + user_note_field).text(alias + " : " + pretty_money(note.balance)); + if (user_note_field !== null) + $("#" + user_note_field).text(alias + (note.balance == null ? "" : (" : " + pretty_money(note.balance)))); if (profile_pic_field != null) $("#" + profile_pic_field).attr('src', img); } @@ -173,8 +183,13 @@ function autoCompleteNote(field_id, alias_matched_id, note_list_id, notes, notes aliases.results.forEach(function (alias) { let note = alias.note; + note = { + id: note, + name: alias.name, + alias: alias, + balance: null + }; aliases_matched_html += li(alias_prefix + "_" + alias.id, alias.name); - note.alias = alias; notes.push(note); }); @@ -192,6 +207,7 @@ function autoCompleteNote(field_id, alias_matched_id, note_list_id, notes, notes // When the user click on an alias, the associated note is added to the emitters alias_obj.click(function () { field.val(""); + old_pattern = ""; // If the note is already an emitter, we increase the quantity var disp = null; notes_display.forEach(function (d) { @@ -258,7 +274,7 @@ function de_validate(id, validated) { "X-CSRFTOKEN": CSRF_TOKEN }, data: { - "resourcetype": "TemplateTransaction", + "resourcetype": "RecurrentTransaction", valid: !validated }, success: function () { diff --git a/static/js/consos.js b/static/js/consos.js index 5f7a314a..896f996c 100644 --- a/static/js/consos.js +++ b/static/js/consos.js @@ -97,7 +97,7 @@ autoCompleteNote("note", "alias_matched", "note_list", notes, notes_display, * Add a transaction from a button. * @param dest Where the money goes * @param amount The price of the item - * @param type The type of the transaction (content type id for TemplateTransaction) + * @param type The type of the transaction (content type id for RecurrentTransaction) * @param category_id The category identifier * @param category_name The category name * @param template_id The identifier of the button @@ -154,7 +154,8 @@ function reset() { $("#note_list").html(""); $("#alias_matched").html(""); $("#consos_list").html(""); - displayNote(null, ""); + $("#user_note").text(""); + $("#profile_pic").attr("src", "/media/pic/default.png"); refreshHistory(); refreshBalance(); } @@ -179,7 +180,7 @@ function consumeAll() { * @param quantity The quantity sold (type: int) * @param amount The price of one item, in cents (type: int) * @param reason The transaction details (type: str) - * @param type The type of the transaction (content type id for TemplateTransaction) + * @param type The type of the transaction (content type id for RecurrentTransaction) * @param category The category id of the button (type: int) * @param template The button id (type: int) */ @@ -192,7 +193,7 @@ function consume(source, dest, quantity, amount, reason, type, category, templat "reason": reason, "valid": true, "polymorphic_ctype": type, - "resourcetype": "TemplateTransaction", + "resourcetype": "RecurrentTransaction", "source": source, "destination": dest, "category": category, diff --git a/static/js/transfer.js b/static/js/transfer.js index a0c2d88a..c615f932 100644 --- a/static/js/transfer.js +++ b/static/js/transfer.js @@ -21,6 +21,8 @@ function reset() { $("#last_name").val(""); $("#first_name").val(""); $("#bank").val(""); + $("#user_note").val(""); + $("#profile_pic").attr("src", "/media/pic/default.png"); refreshBalance(); refreshHistory(); } @@ -30,16 +32,18 @@ $(document).ready(function() { "source_alias", "source_note", "user_note", "profile_pic"); autoCompleteNote("dest_note", "dest_alias_matched", "dest_note_list", dests, dests_notes_display, "dest_alias", "dest_note", "user_note", "profile_pic", function() { - let last = dests_notes_display[dests_notes_display.length - 1]; - dests_notes_display.length = 0; - dests_notes_display.push(last); + if ($("#type_credit").is(":checked") || $("#type_debit").is(":checked")) { + let last = dests_notes_display[dests_notes_display.length - 1]; + dests_notes_display.length = 0; + dests_notes_display.push(last); - last.quantity = 1; + last.quantity = 1; - $.getJSON("/api/user/" + last.note.user + "/", function(user) { - $("#last_name").val(user.last_name); - $("#first_name").val(user.first_name); - }); + $.getJSON("/api/user/" + last.note.user + "/", function(user) { + $("#last_name").val(user.last_name); + $("#first_name").val(user.first_name); + }); + } return true; }); diff --git a/templates/base.html b/templates/base.html index e6193702..fae86443 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,4 +1,4 @@ -{% load static i18n pretty_money static getenv %} +{% load static i18n pretty_money static getenv perms %} {% comment %} SPDX-License-Identifier: GPL-3.0-or-later {% endcomment %} @@ -74,21 +74,29 @@ SPDX-License-Identifier: GPL-3.0-or-later @@ -58,47 +60,49 @@ SPDX-License-Identifier: GPL-2.0-or-later -