mirror of
				https://gitlab.crans.org/bde/nk20
				synced 2025-10-30 23:39:54 +01:00 
			
		
		
		
	Merge remote-tracking branch 'origin/master' into tresorerie
# Conflicts: # locale/de/LC_MESSAGES/django.po # locale/fr/LC_MESSAGES/django.po # note_kfet/settings/base.py # templates/base.html
This commit is contained in:
		
							
								
								
									
										13
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								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 | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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' | ||||
|   | ||||
							
								
								
									
										31
									
								
								apps/api/viewsets.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								apps/api/viewsets.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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")) | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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 | ||||
| @@ -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 = [ | ||||
|   | ||||
| @@ -15,6 +15,7 @@ class ProfileSerializer(serializers.ModelSerializer): | ||||
|     class Meta: | ||||
|         model = Profile | ||||
|         fields = '__all__' | ||||
|         read_only_fields = ('user', ) | ||||
|  | ||||
|  | ||||
| class ClubSerializer(serializers.ModelSerializer): | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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() | ||||
|   | ||||
| @@ -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() | ||||
|   | ||||
| @@ -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',) | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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, | ||||
|                     }, | ||||
|                 ), | ||||
|         } | ||||
|   | ||||
| @@ -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', | ||||
| ] | ||||
|   | ||||
| @@ -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') | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
							
								
								
									
										4
									
								
								apps/permission/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								apps/permission/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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' | ||||
							
								
								
									
										31
									
								
								apps/permission/admin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								apps/permission/admin.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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', ) | ||||
|  | ||||
							
								
								
									
										0
									
								
								apps/permission/api/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								apps/permission/api/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										17
									
								
								apps/permission/api/serializers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								apps/permission/api/serializers.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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__' | ||||
							
								
								
									
										11
									
								
								apps/permission/api/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								apps/permission/api/urls.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
							
								
								
									
										20
									
								
								apps/permission/api/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								apps/permission/api/views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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', ] | ||||
							
								
								
									
										14
									
								
								apps/permission/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								apps/permission/apps.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
							
								
								
									
										116
									
								
								apps/permission/backends.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								apps/permission/backends.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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")) | ||||
							
								
								
									
										554
									
								
								apps/permission/fixtures/initial.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										554
									
								
								apps/permission/fixtures/initial.json
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
|       ] | ||||
|     } | ||||
|   } | ||||
| ] | ||||
							
								
								
									
										0
									
								
								apps/permission/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								apps/permission/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										284
									
								
								apps/permission/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										284
									
								
								apps/permission/models.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
|  | ||||
							
								
								
									
										63
									
								
								apps/permission/permissions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								apps/permission/permissions.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
							
								
								
									
										106
									
								
								apps/permission/signals.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								apps/permission/signals.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
							
								
								
									
										0
									
								
								apps/permission/templatetags/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								apps/permission/templatetags/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										55
									
								
								apps/permission/templatetags/perms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								apps/permission/templatetags/perms.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
 Submodule apps/scripts deleted from 123466cfa9
									
								
							| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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 "" | ||||
|   | ||||
| @@ -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): | ||||
|     """ | ||||
|   | ||||
| @@ -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") | ||||
|   | ||||
| @@ -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', | ||||
|   | ||||
| @@ -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')), | ||||
| ] | ||||
|  | ||||
|   | ||||
| @@ -1,3 +0,0 @@ | ||||
| djangorestframework==3.9.0 | ||||
| django-rest-polymorphic==0.1.8 | ||||
|  | ||||
| @@ -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 | ||||
|   | ||||
							
								
								
									
										297
									
								
								static/js/base.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										297
									
								
								static/js/base.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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 += "<div class=\"alert alert-" + alert_type + " alert-dismissible\">" + | ||||
|         "<button class=\"close\" data-dismiss=\"alert\" href=\"#\"><span aria-hidden=\"true\">×</span></button>" | ||||
|         + msg + "</div>\n"; | ||||
|     msgDiv.html(html); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Reload the balance of the user on the right top corner | ||||
|  */ | ||||
| function refreshBalance() { | ||||
|     $("#user_balance").load("/ #user_balance"); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Query the 20 first matched notes with a given pattern | ||||
|  * @param pattern The pattern that is queried | ||||
|  * @param fun For each found note with the matched alias `alias`, fun(note, alias) is called. | ||||
|  */ | ||||
| function getMatchedNotes(pattern, fun) { | ||||
|     $.getJSON("/api/note/alias/?format=json&alias=" + pattern + "&search=user|club&ordering=normalized_name", fun); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Generate a <li> entry with a given id and text | ||||
|  */ | ||||
| function li(id, text) { | ||||
|     return "<li class=\"list-group-item py-1 d-flex justify-content-between align-items-center\"" + | ||||
|                 " id=\"" + id + "\">" + text + "</li>\n"; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Render note name and picture | ||||
|  * @param note The note to render | ||||
|  * @param alias The alias to be displayed | ||||
|  * @param user_note_field | ||||
|  * @param profile_pic_field | ||||
|  */ | ||||
| function displayNote(note, alias, user_note_field=null, profile_pic_field=null) { | ||||
|     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 (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); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Remove a note from the emitters. | ||||
|  * @param d The note to remove | ||||
|  * @param note_prefix The prefix of the identifiers of the <li> blocks of the emitters | ||||
|  * @param notes_display An array containing the infos of the buyers: [alias, note id, note object, quantity] | ||||
|  * @param note_list_id The div block identifier where the notes of the buyers are displayed | ||||
|  * @param user_note_field The identifier of the field that display the note of the hovered note (useful in | ||||
|  *                        consumptions, put null if not used) | ||||
|  * @param profile_pic_field The identifier of the field that display the profile picture of the hovered note | ||||
|  *                          (useful in consumptions, put null if not used) | ||||
|  * @returns an anonymous function to be compatible with jQuery events | ||||
|  */ | ||||
| function removeNote(d, note_prefix="note", notes_display, note_list_id, user_note_field=null, profile_pic_field=null) { | ||||
|     return (function() { | ||||
|         let new_notes_display = []; | ||||
|         let html = ""; | ||||
|         notes_display.forEach(function (disp) { | ||||
|             if (disp.quantity > 1 || disp.id !== d.id) { | ||||
|                 disp.quantity -= disp.id === d.id ? 1 : 0; | ||||
|                 new_notes_display.push(disp); | ||||
|                 html += li(note_prefix + "_" + disp.id, disp.name | ||||
|                     + "<span class=\"badge badge-dark badge-pill\">" + disp.quantity + "</span>"); | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         notes_display.length = 0; | ||||
|         new_notes_display.forEach(function(disp) { | ||||
|             notes_display.push(disp); | ||||
|         }); | ||||
|  | ||||
|         $("#" + note_list_id).html(html); | ||||
|         notes_display.forEach(function (disp) { | ||||
|             let obj = $("#" + note_prefix + "_" + disp.id); | ||||
|             obj.click(removeNote(disp, note_prefix, notes_display, note_list_id, user_note_field, profile_pic_field)); | ||||
|             obj.hover(function() { | ||||
|                 if (disp.note) | ||||
|                     displayNote(disp.note, disp.name, user_note_field, profile_pic_field); | ||||
|             }); | ||||
|         }); | ||||
|     }); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Generate an auto-complete field to query a note with its alias | ||||
|  * @param field_id The identifier of the text field where the alias is typed | ||||
|  * @param alias_matched_id The div block identifier where the matched aliases are displayed | ||||
|  * @param note_list_id The div block identifier where the notes of the buyers are displayed | ||||
|  * @param notes An array containing the note objects of the buyers | ||||
|  * @param notes_display An array containing the infos of the buyers: [alias, note id, note object, quantity] | ||||
|  * @param alias_prefix The prefix of the <li> blocks for the matched aliases | ||||
|  * @param note_prefix The prefix of the <li> blocks for the notes of the buyers | ||||
|  * @param user_note_field The identifier of the field that display the note of the hovered note (useful in | ||||
|  *                        consumptions, put null if not used) | ||||
|  * @param profile_pic_field The identifier of the field that display the profile picture of the hovered note | ||||
|  *                          (useful in consumptions, put null if not used) | ||||
|  * @param alias_click Function that is called when an alias is clicked. If this method exists and doesn't return true, | ||||
|  *                    the associated note is not displayed. | ||||
|  *                    Useful for a consumption if the item is selected before. | ||||
|  */ | ||||
| function autoCompleteNote(field_id, alias_matched_id, note_list_id, notes, notes_display, alias_prefix="alias", | ||||
|                           note_prefix="note", user_note_field=null, profile_pic_field=null, alias_click=null) { | ||||
|     let field = $("#" + field_id); | ||||
|     // When the user clicks on the search field, it is immediately cleared | ||||
|     field.click(function() { | ||||
|         field.val(""); | ||||
|     }); | ||||
|  | ||||
|     let old_pattern = null; | ||||
|  | ||||
|     // When the user type "Enter", the first alias is clicked | ||||
|     field.keypress(function(event) { | ||||
|         if (event.originalEvent.charCode === 13) | ||||
|             $("#" + alias_matched_id + " li").first().trigger("click"); | ||||
|     }); | ||||
|  | ||||
|     // When the user type something, the matched aliases are refreshed | ||||
|     field.keyup(function(e) { | ||||
|         if (e.originalEvent.charCode === 13) | ||||
|             return; | ||||
|  | ||||
|         let pattern = field.val(); | ||||
|         // If the pattern is not modified, we don't query the API | ||||
|         if (pattern === old_pattern || pattern === "") | ||||
|             return; | ||||
|  | ||||
|         old_pattern = pattern; | ||||
|  | ||||
|         // Clear old matched notes | ||||
|         notes.length = 0; | ||||
|  | ||||
|         let aliases_matched_obj = $("#" + alias_matched_id); | ||||
|         let aliases_matched_html = ""; | ||||
|  | ||||
|         // Get matched notes with the given pattern | ||||
|         getMatchedNotes(pattern, function(aliases) { | ||||
|             // The response arrived too late, we stop the request | ||||
|             if (pattern !== $("#" + field_id).val()) | ||||
|                 return; | ||||
|  | ||||
|             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); | ||||
|                 notes.push(note); | ||||
|             }); | ||||
|  | ||||
|             // Display the list of matched aliases | ||||
|             aliases_matched_obj.html(aliases_matched_html); | ||||
|  | ||||
|             notes.forEach(function (note) { | ||||
|                 let alias = note.alias; | ||||
|                 let alias_obj = $("#" + alias_prefix + "_" + alias.id); | ||||
|                 // When an alias is hovered, the profile picture and the balance are displayed at the right place | ||||
|                 alias_obj.hover(function () { | ||||
|                     displayNote(note, alias.name, user_note_field, profile_pic_field); | ||||
|                 }); | ||||
|  | ||||
|                 // 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) { | ||||
|                         // We compare the note ids | ||||
|                         if (d.id === note.id) { | ||||
|                             d.quantity += 1; | ||||
|                             disp = d; | ||||
|                         } | ||||
|                     }); | ||||
|                     // In the other case, we add a new emitter | ||||
|                     if (disp == null) { | ||||
|                         disp = { | ||||
|                             name: alias.name, | ||||
|                             id: note.id, | ||||
|                             note: note, | ||||
|                             quantity: 1 | ||||
|                         }; | ||||
|                         notes_display.push(disp); | ||||
|                     } | ||||
|  | ||||
|                     // If the function alias_click exists, it is called. If it doesn't return true, then the notes are | ||||
|                     // note displayed. Useful for a consumption when a button is already clicked | ||||
|                     if (alias_click && !alias_click()) | ||||
|                         return; | ||||
|  | ||||
|                     let note_list = $("#" + note_list_id); | ||||
|                     let html = ""; | ||||
|                     notes_display.forEach(function (disp) { | ||||
|                         html += li(note_prefix + "_" + disp.id, disp.name | ||||
|                             + "<span class=\"badge badge-dark badge-pill\">" + disp.quantity + "</span>"); | ||||
|                     }); | ||||
|  | ||||
|                     // Emitters are displayed | ||||
|                     note_list.html(html); | ||||
|  | ||||
|                     notes_display.forEach(function (disp) { | ||||
|                         let line_obj = $("#" + note_prefix + "_" + disp.id); | ||||
|                         // Hover an emitter display also the profile picture | ||||
|                         line_obj.hover(function () { | ||||
|                             displayNote(disp.note, disp.name, user_note_field, profile_pic_field); | ||||
|                         }); | ||||
|  | ||||
|                         // When an emitter is clicked, it is removed | ||||
|                         line_obj.click(removeNote(disp, note_prefix, notes_display, note_list_id, user_note_field, | ||||
|                             profile_pic_field)); | ||||
|                     }); | ||||
|                 }); | ||||
|             }); | ||||
|         }); | ||||
|     }); | ||||
| } | ||||
|  | ||||
| // When a validate button is clicked, we switch the validation status | ||||
| function de_validate(id, validated) { | ||||
|     $("#validate_" + id).html("<strong style=\"font-size: 16pt;\">⟳ ...</strong>"); | ||||
|  | ||||
|     // Perform a PATCH request to the API in order to update the transaction | ||||
|     // If the user has insuffisent rights, an error message will appear | ||||
|     $.ajax({ | ||||
|         "url": "/api/note/transaction/transaction/" + id + "/", | ||||
|         type: "PATCH", | ||||
|         dataType: "json", | ||||
|         headers: { | ||||
|             "X-CSRFTOKEN": CSRF_TOKEN | ||||
|         }, | ||||
|         data: { | ||||
|             "resourcetype": "RecurrentTransaction", | ||||
|             valid: !validated | ||||
|         }, | ||||
|         success: function () { | ||||
|             // Refresh jQuery objects | ||||
|             $(".validate").click(de_validate); | ||||
|  | ||||
|             refreshBalance(); | ||||
|             // error if this method doesn't exist. Please define it. | ||||
|             refreshHistory(); | ||||
|         }, | ||||
|         error: function(err) { | ||||
|             addMsg("Une erreur est survenue lors de la validation/dévalidation " + | ||||
|                 "de cette transaction : " + err.responseText, "danger"); | ||||
|  | ||||
|             refreshBalance(); | ||||
|             // error if this method doesn't exist. Please define it. | ||||
|             refreshHistory(); | ||||
|         } | ||||
|     }); | ||||
| } | ||||
							
								
								
									
										206
									
								
								static/js/consos.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										206
									
								
								static/js/consos.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,206 @@ | ||||
| // Copyright (C) 2018-2020 by BDE ENS Paris-Saclay | ||||
| // SPDX-License-Identifier: GPL-3.0-or-later | ||||
|  | ||||
| /** | ||||
|  * Refresh the history table on the consumptions page. | ||||
|  */ | ||||
| function refreshHistory() { | ||||
|     $("#history").load("/note/consos/ #history"); | ||||
|     $("#most_used").load("/note/consos/ #most_used"); | ||||
| } | ||||
|  | ||||
| $(document).ready(function() { | ||||
|     // If hash of a category in the URL, then select this category | ||||
|     // else select the first one | ||||
|     if (location.hash) { | ||||
|         $("a[href='" + location.hash + "']").tab("show"); | ||||
|     } else { | ||||
|         $("a[data-toggle='tab']").first().tab("show"); | ||||
|     } | ||||
|  | ||||
|     // When selecting a category, change URL | ||||
|     $(document.body).on("click", "a[data-toggle='tab']", function() { | ||||
|         location.hash = this.getAttribute("href"); | ||||
|     }); | ||||
|  | ||||
|     // Switching in double consumptions mode should update the layout | ||||
|     let double_conso_obj = $("#double_conso"); | ||||
|     double_conso_obj.click(function() { | ||||
|         $("#consos_list_div").show(); | ||||
|         $("#infos_div").attr('class', 'col-sm-5 col-xl-6'); | ||||
|         $("#note_infos_div").attr('class', 'col-xl-3'); | ||||
|         $("#user_select_div").attr('class', 'col-xl-4'); | ||||
|         $("#buttons_div").attr('class', 'col-sm-7 col-xl-6'); | ||||
|  | ||||
|         let note_list_obj = $("#note_list"); | ||||
|         if (buttons.length > 0 && note_list_obj.text().length > 0) { | ||||
|             $("#consos_list").html(note_list_obj.html()); | ||||
|             note_list_obj.html(""); | ||||
|  | ||||
|             buttons.forEach(function(button) { | ||||
|                 $("#conso_button_" + button.id).click(removeNote(button, "conso_button", buttons, | ||||
|                     "consos_list")); | ||||
|             }); | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     let single_conso_obj = $("#single_conso"); | ||||
|     single_conso_obj.click(function() { | ||||
|         $("#consos_list_div").hide(); | ||||
|         $("#infos_div").attr('class', 'col-sm-5 col-md-4'); | ||||
|         $("#note_infos_div").attr('class', 'col-xl-5'); | ||||
|         $("#user_select_div").attr('class', 'col-xl-7'); | ||||
|         $("#buttons_div").attr('class', 'col-sm-7 col-md-8'); | ||||
|  | ||||
|         let consos_list_obj = $("#consos_list"); | ||||
|         if (buttons.length > 0) { | ||||
|             if (notes_display.length === 0 && consos_list_obj.text().length > 0) { | ||||
|                 $("#note_list").html(consos_list_obj.html()); | ||||
|                 consos_list_obj.html(""); | ||||
|                 buttons.forEach(function(button) { | ||||
|                     $("#conso_button_" + button.id).click(removeNote(button, "conso_button", buttons, | ||||
|                         "note_list")); | ||||
|                 }); | ||||
|             } | ||||
|             else { | ||||
|                 buttons.length = 0; | ||||
|                 consos_list_obj.html(""); | ||||
|             } | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     // Ensure we begin in single consumption. Removing these lines may cause problems when reloading. | ||||
|     single_conso_obj.prop('checked', 'true'); | ||||
|     double_conso_obj.removeAttr('checked'); | ||||
|     $("label[for='double_conso']").attr('class', 'btn btn-sm btn-outline-primary'); | ||||
|  | ||||
|     $("#consos_list_div").hide(); | ||||
|  | ||||
|     $("#consume_all").click(consumeAll); | ||||
| }); | ||||
|  | ||||
| notes = []; | ||||
| notes_display = []; | ||||
| buttons = []; | ||||
|  | ||||
| // When the user searches an alias, we update the auto-completion | ||||
| autoCompleteNote("note", "alias_matched", "note_list", notes, notes_display, | ||||
|     "alias", "note", "user_note", "profile_pic", function() { | ||||
|         if (buttons.length > 0 && $("#single_conso").is(":checked")) { | ||||
|             consumeAll(); | ||||
|             return false; | ||||
|         } | ||||
|         return true; | ||||
|     }); | ||||
|  | ||||
| /** | ||||
|  * 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 RecurrentTransaction) | ||||
|  * @param category_id The category identifier | ||||
|  * @param category_name The category name | ||||
|  * @param template_id The identifier of the button | ||||
|  * @param template_name The name of  the button | ||||
|  */ | ||||
| function addConso(dest, amount, type, category_id, category_name, template_id, template_name) { | ||||
|     var button = null; | ||||
|     buttons.forEach(function(b) { | ||||
|         if (b.id === template_id) { | ||||
|             b.quantity += 1; | ||||
|             button = b; | ||||
|         } | ||||
|     }); | ||||
|     if (button == null) { | ||||
|         button = { | ||||
|             id: template_id, | ||||
|             name: template_name, | ||||
|             dest: dest, | ||||
|             quantity: 1, | ||||
|             amount: amount, | ||||
|             type: type, | ||||
|             category_id: category_id, | ||||
|             category_name: category_name | ||||
|         }; | ||||
|         buttons.push(button); | ||||
|     } | ||||
|  | ||||
|     let dc_obj = $("#double_conso"); | ||||
|     if (dc_obj.is(":checked") || notes_display.length === 0) { | ||||
|         let list = dc_obj.is(":checked") ? "consos_list" : "note_list"; | ||||
|         let html = ""; | ||||
|         buttons.forEach(function(button) { | ||||
|             html += li("conso_button_" + button.id, button.name | ||||
|                 + "<span class=\"badge badge-dark badge-pill\">" + button.quantity + "</span>"); | ||||
|         }); | ||||
|  | ||||
|         $("#" + list).html(html); | ||||
|  | ||||
|         buttons.forEach(function(button) { | ||||
|             $("#conso_button_" + button.id).click(removeNote(button, "conso_button", buttons, list)); | ||||
|         }); | ||||
|     } | ||||
|     else | ||||
|         consumeAll(); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Reset the page as its initial state. | ||||
|  */ | ||||
| function reset() { | ||||
|     notes_display.length = 0; | ||||
|     notes.length = 0; | ||||
|     buttons.length = 0; | ||||
|     $("#note_list").html(""); | ||||
|     $("#alias_matched").html(""); | ||||
|     $("#consos_list").html(""); | ||||
|     $("#user_note").text(""); | ||||
|     $("#profile_pic").attr("src", "/media/pic/default.png"); | ||||
|     refreshHistory(); | ||||
|     refreshBalance(); | ||||
| } | ||||
|  | ||||
|  | ||||
| /** | ||||
|  * Apply all transactions: all notes in `notes` buy each item in `buttons` | ||||
|  */ | ||||
| function consumeAll() { | ||||
|     notes_display.forEach(function(note_display) { | ||||
|         buttons.forEach(function(button) { | ||||
|             consume(note_display.id, button.dest, button.quantity * note_display.quantity, button.amount, | ||||
|                 button.name + " (" + button.category_name + ")", button.type, button.category_id, button.id); | ||||
|        }); | ||||
|     }); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Create a new transaction from a button through the API. | ||||
|  * @param source The note that paid the item (type: int) | ||||
|  * @param dest The note that sold the item (type: int) | ||||
|  * @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 RecurrentTransaction) | ||||
|  * @param category The category id of the button (type: int) | ||||
|  * @param template The button id (type: int) | ||||
|  */ | ||||
| function consume(source, dest, quantity, amount, reason, type, category, template) { | ||||
|     $.post("/api/note/transaction/transaction/", | ||||
|         { | ||||
|             "csrfmiddlewaretoken": CSRF_TOKEN, | ||||
|             "quantity": quantity, | ||||
|             "amount": amount, | ||||
|             "reason": reason, | ||||
|             "valid": true, | ||||
|             "polymorphic_ctype": type, | ||||
|             "resourcetype": "RecurrentTransaction", | ||||
|             "source": source, | ||||
|             "destination": dest, | ||||
|             "category": category, | ||||
|             "template": template | ||||
|         }, reset).fail(function (e) { | ||||
|             reset(); | ||||
|  | ||||
|             addMsg("Une erreur est survenue lors de la transaction : " + e.responseText, "danger"); | ||||
|     }); | ||||
| } | ||||
							
								
								
									
										161
									
								
								static/js/transfer.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										161
									
								
								static/js/transfer.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,161 @@ | ||||
| sources = []; | ||||
| sources_notes_display = []; | ||||
| dests = []; | ||||
| dests_notes_display = []; | ||||
|  | ||||
| function refreshHistory() { | ||||
|     $("#history").load("/note/transfer/ #history"); | ||||
| } | ||||
|  | ||||
| function reset() { | ||||
|     sources_notes_display.length = 0; | ||||
|     sources.length = 0; | ||||
|     dests_notes_display.length = 0; | ||||
|     dests.length = 0; | ||||
|     $("#source_note_list").html(""); | ||||
|     $("#dest_note_list").html(""); | ||||
|     $("#source_alias_matched").html(""); | ||||
|     $("#dest_alias_matched").html(""); | ||||
|     $("#amount").val(""); | ||||
|     $("#reason").val(""); | ||||
|     $("#last_name").val(""); | ||||
|     $("#first_name").val(""); | ||||
|     $("#bank").val(""); | ||||
|     $("#user_note").val(""); | ||||
|     $("#profile_pic").attr("src", "/media/pic/default.png"); | ||||
|     refreshBalance(); | ||||
|     refreshHistory(); | ||||
| } | ||||
|  | ||||
| $(document).ready(function() { | ||||
|     autoCompleteNote("source_note", "source_alias_matched", "source_note_list", sources, sources_notes_display, | ||||
|         "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() { | ||||
|             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; | ||||
|  | ||||
|                 $.getJSON("/api/user/" + last.note.user + "/", function(user) { | ||||
|                     $("#last_name").val(user.last_name); | ||||
|                     $("#first_name").val(user.first_name); | ||||
|                 }); | ||||
|             } | ||||
|  | ||||
|             return true; | ||||
|        }); | ||||
|  | ||||
|  | ||||
|     // Ensure we begin in gift mode. Removing these lines may cause problems when reloading. | ||||
|     $("#type_gift").prop('checked', 'true'); | ||||
|     $("#type_transfer").removeAttr('checked'); | ||||
|     $("#type_credit").removeAttr('checked'); | ||||
|     $("#type_debit").removeAttr('checked'); | ||||
|     $("label[for='type_transfer']").attr('class', 'btn btn-sm btn-outline-primary'); | ||||
|     $("label[for='type_credit']").attr('class', 'btn btn-sm btn-outline-primary'); | ||||
|     $("label[for='type_debit']").attr('class', 'btn btn-sm btn-outline-primary'); | ||||
| }); | ||||
|  | ||||
| $("#transfer").click(function() { | ||||
|     if ($("#type_gift").is(':checked')) { | ||||
|         dests_notes_display.forEach(function (dest) { | ||||
|             $.post("/api/note/transaction/transaction/", | ||||
|                 { | ||||
|                     "csrfmiddlewaretoken": CSRF_TOKEN, | ||||
|                     "quantity": dest.quantity, | ||||
|                     "amount": 100 * $("#amount").val(), | ||||
|                     "reason": $("#reason").val(), | ||||
|                     "valid": true, | ||||
|                     "polymorphic_ctype": TRANSFER_POLYMORPHIC_CTYPE, | ||||
|                     "resourcetype": "Transaction", | ||||
|                     "source": user_id, | ||||
|                     "destination": dest.id | ||||
|                 }, function () { | ||||
|                     addMsg("Le transfert de " | ||||
|                         + pretty_money(dest.quantity * 100 * $("#amount").val()) + " de votre note " | ||||
|                         + " vers la note " + dest.name + " a été fait avec succès !", "success"); | ||||
|  | ||||
|                     reset(); | ||||
|                 }).fail(function (err) { | ||||
|                     addMsg("Le transfert de " | ||||
|                         + pretty_money(dest.quantity * 100 * $("#amount").val()) + " de votre note " | ||||
|                         + " vers la note " + dest.name + " a échoué : " + err.responseText, "danger"); | ||||
|  | ||||
|                 reset(); | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
|     else if ($("#type_transfer").is(':checked')) { | ||||
|         sources_notes_display.forEach(function (source) { | ||||
|             dests_notes_display.forEach(function (dest) { | ||||
|                 $.post("/api/note/transaction/transaction/", | ||||
|                     { | ||||
|                         "csrfmiddlewaretoken": CSRF_TOKEN, | ||||
|                         "quantity": source.quantity * dest.quantity, | ||||
|                         "amount": 100 * $("#amount").val(), | ||||
|                         "reason": $("#reason").val(), | ||||
|                         "valid": true, | ||||
|                         "polymorphic_ctype": TRANSFER_POLYMORPHIC_CTYPE, | ||||
|                         "resourcetype": "Transaction", | ||||
|                         "source": source.id, | ||||
|                         "destination": dest.id | ||||
|                     }, function () { | ||||
|                         addMsg("Le transfert de " | ||||
|                             + pretty_money(source.quantity * dest.quantity * 100 * $("#amount").val()) + " de la note " + source.name | ||||
|                             + " vers la note " + dest.name + " a été fait avec succès !", "success"); | ||||
|  | ||||
|                         reset(); | ||||
|                     }).fail(function (err) { | ||||
|                         addMsg("Le transfert de " | ||||
|                             + pretty_money(source.quantity * dest.quantity * 100 * $("#amount").val()) + " de la note " + source.name | ||||
|                             + " vers la note " + dest.name + " a échoué : " + err.responseText, "danger"); | ||||
|  | ||||
|                         reset(); | ||||
|                 }); | ||||
|             }); | ||||
|         }); | ||||
|     } else if ($("#type_credit").is(':checked') || $("#type_debit").is(':checked')) { | ||||
|         let special_note = $("#credit_type").val(); | ||||
|         let user_note = dests_notes_display[0].id; | ||||
|         let given_reason = $("#reason").val(); | ||||
|         let source, dest, reason; | ||||
|         if ($("#type_credit").is(':checked')) { | ||||
|             source = special_note; | ||||
|             dest = user_note; | ||||
|             reason = "Crédit " + $("#credit_type option:selected").text().toLowerCase(); | ||||
|             if (given_reason.length > 0) | ||||
|                 reason += " (" + given_reason + ")"; | ||||
|         } | ||||
|         else { | ||||
|             source = user_note; | ||||
|             dest = special_note; | ||||
|             reason = "Retrait " + $("#credit_type option:selected").text().toLowerCase(); | ||||
|             if (given_reason.length > 0) | ||||
|                 reason += " (" + given_reason + ")"; | ||||
|         } | ||||
|         $.post("/api/note/transaction/transaction/", | ||||
|             { | ||||
|                 "csrfmiddlewaretoken": CSRF_TOKEN, | ||||
|                 "quantity": 1, | ||||
|                 "amount": 100 * $("#amount").val(), | ||||
|                 "reason": reason, | ||||
|                 "valid": true, | ||||
|                 "polymorphic_ctype": SPECIAL_TRANSFER_POLYMORPHIC_CTYPE, | ||||
|                 "resourcetype": "SpecialTransaction", | ||||
|                 "source": source, | ||||
|                 "destination": dest, | ||||
|                 "last_name": $("#last_name").val(), | ||||
|                 "first_name": $("#first_name").val(), | ||||
|                 "bank": $("#bank").val() | ||||
|             }, function () { | ||||
|                 addMsg("Le crédit/retrait a bien été effectué !", "success"); | ||||
|                 reset(); | ||||
|             }).fail(function (err) { | ||||
|                 addMsg("Le crédit/transfert a échoué : " + err.responseText, "danger"); | ||||
|                 reset(); | ||||
|         }); | ||||
|     } | ||||
| }); | ||||
| @@ -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 %} | ||||
| @@ -46,12 +46,20 @@ SPDX-License-Identifier: GPL-3.0-or-later | ||||
|             crossorigin="anonymous"></script> | ||||
|     <script src="https://cdnjs.cloudflare.com/ajax/libs/turbolinks/5.2.0/turbolinks.js" | ||||
|             crossorigin="anonymous"></script> | ||||
|     <script src="/static/js/base.js"></script> | ||||
|  | ||||
|     {# Si un formulaire requiert des données supplémentaires (notamment JS), les données sont chargées #} | ||||
|     {% if form.media %} | ||||
|         {{ form.media }} | ||||
|     {% endif %} | ||||
|  | ||||
|     <style> | ||||
|         .validate:hover { | ||||
|             cursor: pointer; | ||||
|             text-decoration: underline; | ||||
|         } | ||||
|     </style> | ||||
|  | ||||
|     {% block extracss %}{% endblock %} | ||||
| </head> | ||||
| <body class="d-flex w-100 h-100 flex-column"> | ||||
| @@ -66,30 +74,41 @@ SPDX-License-Identifier: GPL-3.0-or-later | ||||
|         </button> | ||||
|         <div class="collapse navbar-collapse" id="navbarNavDropdown"> | ||||
|             <ul class="navbar-nav"> | ||||
|                 <li class="nav-item active"> | ||||
|                     <a class="nav-link" href="{% url 'note:consos' %}"><i class="fa fa-coffee"></i> {% trans 'Consumptions' %}</a> | ||||
|                 </li> | ||||
|                 <li class="nav-item active"> | ||||
|                     <a class="nav-link" href="{% url 'member:club_list' %}"><i class="fa fa-users"></i> {% trans 'Clubs' %}</a> | ||||
|                 </li> | ||||
|                 <li class="nav-item active"> | ||||
|                     <a class="nav-link" href="#"><i class="fa fa-calendar"></i> {% trans 'Activities' %}</a> | ||||
|                 </li> | ||||
|                 <li class="nav-item active"> | ||||
|                     <a class="nav-link" href="{% url 'note:template_list' %}"><i class="fa fa-coffee"></i> {% trans 'Buttons' %}</a> | ||||
|                 </li> | ||||
|                 {% if "note.transactiontemplate"|not_empty_model_list %} | ||||
|                     <li class="nav-item active"> | ||||
|                         <a class="nav-link" href="{% url 'note:consos' %}"><i class="fa fa-coffee"></i> {% trans 'Consumptions' %}</a> | ||||
|                     </li> | ||||
|                 {% endif %} | ||||
|                 {% if "member.club"|not_empty_model_list %} | ||||
|                     <li class="nav-item active"> | ||||
|                         <a class="nav-link" href="{% url 'member:club_list' %}"><i class="fa fa-users"></i> {% trans 'Clubs' %}</a> | ||||
|                     </li> | ||||
|                 {% endif %} | ||||
|                 {% if "activity.activity"|not_empty_model_list %} | ||||
|                     <li class="nav-item active"> | ||||
|                         <a class="nav-link" href="#"><i class="fa fa-calendar"></i> {% trans 'Activities' %}</a> | ||||
|                     </li> | ||||
|                 {% endif %} | ||||
|                 {% if "note.transactiontemplate"|not_empty_model_change_list %} | ||||
|                     <li class="nav-item active"> | ||||
|                         <a class="nav-link" href="{% url 'note:template_list' %}"><i class="fa fa-coffee"></i> {% trans 'Buttons' %}</a> | ||||
|                     </li> | ||||
|                 {% endif %} | ||||
|                 <li class="nav-item active"> | ||||
|                     <a class="nav-link" href="{% url 'note:transfer' %}"><i class="fa fa-exchange"></i>{% trans 'Transfer' %} </a> | ||||
|                 </li> | ||||
|                 <li class="nav-item active"> | ||||
|                     <a class="nav-link" href="{% url 'treasury:billing' %}"><i class="fa fa-money"></i>{% trans 'Treasury' %} </a> | ||||
|                 </li> | ||||
|                 {% if "treasury.billing"|not_empty_model_change_list %} | ||||
|                     <li class="nav-item active"> | ||||
|                         <a class="nav-link" href="{% url 'treasury:billing' %}"><i class="fa fa-money"></i>{% trans 'Treasury' %} </a> | ||||
|                     </li> | ||||
|                 {% endif %} | ||||
|             </ul> | ||||
|             <ul class="navbar-nav ml-auto"> | ||||
|                 {% if user.is_authenticated %} | ||||
|                     <li class="dropdown"> | ||||
|                         <a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> | ||||
|                             <i class="fa fa-user"></i> {{ user.username }} ({{ user.note.balance | pretty_money }}) | ||||
|                             <i class="fa fa-user"></i> | ||||
|                             <span id="user_balance">{{ user.username }} ({{ user.note.balance | pretty_money }})</span> | ||||
|                         </a> | ||||
|                         <div class="dropdown-menu dropdown-menu-right" | ||||
|                              aria-labelledby="navbarDropdownMenuLink"> | ||||
| @@ -118,6 +137,7 @@ SPDX-License-Identifier: GPL-3.0-or-later | ||||
|     </nav> | ||||
|     <div class="container-fluid my-3" style="max-width: 1600px;"> | ||||
|         {% block contenttitle %}<h1>{{ title }}</h1>{% endblock %} | ||||
|         <div id="messages"></div> | ||||
|         {% block content %} | ||||
|             <p>Default content...</p> | ||||
|         {% endblock content %} | ||||
| @@ -161,6 +181,10 @@ SPDX-License-Identifier: GPL-3.0-or-later | ||||
|     </div> | ||||
| </footer> | ||||
|  | ||||
| <script> | ||||
|     CSRF_TOKEN = "{{ csrf_token }}"; | ||||
| </script> | ||||
|  | ||||
| {% block extrajavascript %} | ||||
| {% endblock extrajavascript %} | ||||
| </body> | ||||
|   | ||||
| @@ -10,7 +10,7 @@ | ||||
|                     <img src="{{ object.note.display_image.url }}" class="img-thumbnail mt-2" > | ||||
|                 </a> | ||||
|             </div> | ||||
|             <div class="card-body"> | ||||
|             <div class="card-body" id="profile_infos"> | ||||
|                 <dl class="row"> | ||||
|                     <dt class="col-xl-6">{% trans 'name'|capfirst %}, {% trans 'first name' %}</dt> | ||||
|                     <dd class="col-xl-6">{{ object.last_name }} {{ object.first_name }}</dd> | ||||
| @@ -76,7 +76,9 @@ | ||||
|                     </a> | ||||
|                 </div> | ||||
|                 <div id="historyListCollapse" class="collapse" style="overflow:auto hidden" aria-labelledby="historyListHeading" data-parent="#accordionProfile"> | ||||
|                     {% render_table history_list %} | ||||
|                     <div id="history_list"> | ||||
|                         {% render_table history_list %} | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
| @@ -84,3 +86,12 @@ | ||||
|     </div> | ||||
| </div> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block extrajavascript %} | ||||
|     <script> | ||||
|     function refreshHistory() { | ||||
|         $("#history_list").load("{% url 'member:user_detail' pk=object.pk %} #history_list"); | ||||
|         $("#profile_infos").load("{% url 'member:user_detail' pk=object.pk %} #profile_infos"); | ||||
|     } | ||||
|     </script> | ||||
| {% endblock %} | ||||
|   | ||||
| @@ -7,67 +7,88 @@ | ||||
|  | ||||
| {% block content %} | ||||
|     <div class="row mt-4"> | ||||
|         <div class="col-sm-5 col-md-4"> | ||||
|         <div class="col-sm-5 col-md-4" id="infos_div"> | ||||
|             <div class="row"> | ||||
|                 {# User details column #} | ||||
|                 <div class="col-xl-5"> | ||||
|                 <div class="col-xl-5" id="note_infos_div"> | ||||
|                     <div class="card border-success shadow mb-4"> | ||||
|                         <img src="https://perso.crans.org/erdnaxe/site-crans/img/logo.svg" | ||||
|                             alt="" class="img-fluid rounded mx-auto d-block"> | ||||
|                         <img src="/media/pic/default.png" | ||||
|                             id="profile_pic" alt="" class="img-fluid rounded mx-auto d-block"> | ||||
|                         <div class="card-body text-center"> | ||||
|                             Paquito (aka. PAC) : -230 € | ||||
|                             <span id="user_note"></span> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|  | ||||
|                 {# User selection column #} | ||||
|                 <div class="col-xl-7"> | ||||
|                 <div class="col-xl-7" id="user_select_div"> | ||||
|                     <div class="card border-success shadow mb-4"> | ||||
|                         <div class="card-header"> | ||||
|                             <p class="card-text font-weight-bold"> | ||||
|                                 Sélection des émitteurs | ||||
|                                 {% trans "Select emitters" %} | ||||
|                             </p> | ||||
|                         </div> | ||||
|                         <ul class="list-group list-group-flush"> | ||||
|                             <li class="list-group-item py-1 d-flex justify-content-between align-items-center"> | ||||
|                                 Cras justo odio | ||||
|                                 <span class="badge badge-dark badge-pill">14</span> | ||||
|                             </li> | ||||
|                             <li class="list-group-item py-1 d-flex justify-content-between align-items-center"> | ||||
|                                 Dapibus ac facilisis in | ||||
|                                 <span class="badge badge-dark badge-pill">1</span> | ||||
|                             </li> | ||||
|                         <ul class="list-group list-group-flush" id="note_list"> | ||||
|                         </ul> | ||||
|                         <div class="card-body"> | ||||
|                             TODO: reimplement select2 here in JS | ||||
|                             <input class="form-control mx-auto d-block" type="text" id="note" /> | ||||
|                             <ul class="list-group list-group-flush" id="alias_matched"> | ||||
|                             </ul> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|  | ||||
|                 <div class="col-xl-5" id="consos_list_div"> | ||||
|                     <div class="card border-info shadow mb-4"> | ||||
|                         <div class="card-header"> | ||||
|                             <p class="card-text font-weight-bold"> | ||||
|                                 {% trans "Select consumptions" %} | ||||
|                             </p> | ||||
|                         </div> | ||||
|                         <ul class="list-group list-group-flush" id="consos_list"> | ||||
|                         </ul> | ||||
|                         <button id="consume_all" class="form-control btn btn-primary"> | ||||
|                             {% trans "Consume!" %} | ||||
|                         </button> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         {# Buttons column #} | ||||
|         <div class="col-sm-7 col-md-8"> | ||||
|         <div class="col-sm-7 col-md-8" id="buttons_div"> | ||||
|             {# Show last used buttons #} | ||||
|             <div class="card shadow mb-4"> | ||||
|                 <div class="card-body text-nowrap" style="overflow:auto hidden"> | ||||
|                     <p class="card-text text-muted font-weight-light font-italic"> | ||||
|                         Les boutons les plus utilisés s'afficheront ici. | ||||
|                 <div class="card-header"> | ||||
|                     <p class="card-text font-weight-bold"> | ||||
|                         {% trans "Most used buttons" %} | ||||
|                     </p> | ||||
|                 </div> | ||||
|                 <div class="card-body text-nowrap" style="overflow:auto hidden"> | ||||
|                     <div class="d-inline-flex flex-wrap justify-content-center" id="most_used"> | ||||
|                         {% for button in most_used %} | ||||
|                             {% if button.display %} | ||||
|                                 <button class="btn btn-outline-dark rounded-0 flex-fill" | ||||
|                                         id="most_used_button{{ button.id }}" name="button" value="{{ button.name }}"> | ||||
|                                     {{ button.name }} ({{ button.amount | pretty_money }}) | ||||
|                                 </button> | ||||
|                             {% endif %} | ||||
|                         {% endfor %} | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|  | ||||
|             {# Regroup buttons under categories #} | ||||
|             {% regroup transaction_templates by template_type as template_types %} | ||||
|             {% regroup transaction_templates by category as categories %} | ||||
|  | ||||
|             <div class="card border-primary text-center shadow mb-4"> | ||||
|                 {# Tabs for button categories #} | ||||
|                 <div class="card-header"> | ||||
|                     <ul class="nav nav-tabs nav-fill card-header-tabs"> | ||||
|                         {% for template_type in template_types %} | ||||
|                         {% for category in categories %} | ||||
|                             <li class="nav-item"> | ||||
|                                 <a class="nav-link font-weight-bold" data-toggle="tab" href="#{{ template_type.grouper|slugify }}"> | ||||
|                                     {{ template_type.grouper }} | ||||
|                                 <a class="nav-link font-weight-bold" data-toggle="tab" href="#{{ category.grouper|slugify }}"> | ||||
|                                     {{ category.grouper }} | ||||
|                                 </a> | ||||
|                             </li> | ||||
|                         {% endfor %} | ||||
| @@ -77,14 +98,16 @@ | ||||
|                 {# Tabs content #} | ||||
|                 <div class="card-body"> | ||||
|                     <div class="tab-content"> | ||||
|                         {% for template_type in template_types %} | ||||
|                             <div class="tab-pane" id="{{ template_type.grouper|slugify }}"> | ||||
|                         {% for category in categories %} | ||||
|                             <div class="tab-pane" id="{{ category.grouper|slugify }}"> | ||||
|                                 <div class="d-inline-flex flex-wrap justify-content-center"> | ||||
|                                     {% for button in template_type.list %} | ||||
|                                         <button class="btn btn-outline-dark rounded-0 flex-fill" | ||||
|                                                 name="button" value="{{ button.name }}"> | ||||
|                                             {{ button.name }} ({{ button.amount | pretty_money }}) | ||||
|                                         </button> | ||||
|                                     {% for button in category.list %} | ||||
|                                         {% if button.display %} | ||||
|                                             <button class="btn btn-outline-dark rounded-0 flex-fill" | ||||
|                                                     id="button{{ button.id }}" name="button" value="{{ button.name }}"> | ||||
|                                                 {{ button.name }} ({{ button.amount | pretty_money }}) | ||||
|                                             </button> | ||||
|                                         {% endif %} | ||||
|                                     {% endfor %} | ||||
|                                 </div> | ||||
|                             </div> | ||||
| @@ -95,16 +118,16 @@ | ||||
|                 {# Mode switch #} | ||||
|                 <div class="card-footer border-primary"> | ||||
|                     <a class="btn btn-sm btn-secondary float-left" href="{% url 'note:template_list' %}"> | ||||
|                         <i class="fa fa-edit"></i> Éditer | ||||
|                         <i class="fa fa-edit"></i> {% trans "Edit" %} | ||||
|                     </a> | ||||
|                     <div class="btn-group btn-group-toggle float-right" data-toggle="buttons"> | ||||
|                         <label class="btn btn-sm btn-outline-primary active"> | ||||
|                             <input type="radio" name="options" id="option1" checked> | ||||
|                             Consomations simples | ||||
|                         <label for="single_conso" class="btn btn-sm btn-outline-primary active"> | ||||
|                             <input type="radio" name="conso_type" id="single_conso" checked> | ||||
|                             {% trans "Single consumptions" %} | ||||
|                         </label> | ||||
|                         <label class="btn btn-sm btn-outline-primary"> | ||||
|                             <input type="radio" name="options" id="option2"> | ||||
|                             Consomations doubles | ||||
|                         <label for="double_conso" class="btn btn-sm btn-outline-primary"> | ||||
|                             <input type="radio" name="conso_type" id="double_conso"> | ||||
|                             {% trans "Double consumptions" %} | ||||
|                         </label> | ||||
|                     </div> | ||||
|                 </div> | ||||
| @@ -112,40 +135,37 @@ | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class="card shadow mb-4"> | ||||
|     <div class="card shadow mb-4" id="history"> | ||||
|         <div class="card-header"> | ||||
|             <p class="card-text font-weight-bold"> | ||||
|                 Historique des transactions récentes | ||||
|                 {% trans "Recent transactions history" %} | ||||
|             </p> | ||||
|         </div> | ||||
|         {% render_table table %} | ||||
|     </div> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block extracss %} | ||||
|     <style> | ||||
|         .select2-container{ | ||||
|             max-width: 100%; | ||||
|             min-width: 100%; | ||||
|         } | ||||
|     </style> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block extrajavascript %} | ||||
|     <script type="text/javascript" src="/static/js/consos.js"></script> | ||||
|     <script type="text/javascript"> | ||||
|         $(document).ready(function() { | ||||
|             // If hash of a category in the URL, then select this category | ||||
|             // else select the first one | ||||
|             if (location.hash) { | ||||
|                 $("a[href='" + location.hash + "']").tab("show"); | ||||
|             } else { | ||||
|                 $("a[data-toggle='tab']").first().tab("show"); | ||||
|             } | ||||
|         {% for button in most_used %} | ||||
|             {% if button.display %} | ||||
|                 $("#most_used_button{{ button.id }}").click(function() { | ||||
|                     addConso({{ button.destination.id }}, {{ button.amount }}, | ||||
|                         {{ polymorphic_ctype }}, {{ button.category.id }}, "{{ button.category.name }}", | ||||
|                         {{ button.id }}, "{{ button.name }}"); | ||||
|                 }); | ||||
|             {% endif %} | ||||
|         {% endfor %} | ||||
|  | ||||
|             // When selecting a category, change URL | ||||
|             $(document.body).on("click", "a[data-toggle='tab']", function(event) { | ||||
|                 location.hash = this.getAttribute("href"); | ||||
|             }); | ||||
|         }); | ||||
|         {% for button in transaction_templates %} | ||||
|             {% if button.display %} | ||||
|                 $("#button{{ button.id }}").click(function() { | ||||
|                     addConso({{ button.destination.id }}, {{ button.amount }}, | ||||
|                         {{ polymorphic_ctype }}, {{ button.category.id }}, "{{ button.category.name }}", | ||||
|                         {{ button.id }}, "{{ button.name }}"); | ||||
|                 }); | ||||
|             {% endif %} | ||||
|         {% endfor %} | ||||
|     </script> | ||||
| {% endblock %} | ||||
|   | ||||
| @@ -3,35 +3,192 @@ | ||||
| SPDX-License-Identifier: GPL-2.0-or-later | ||||
| {% endcomment %} | ||||
|  | ||||
| {% load i18n static %} | ||||
| {% load i18n static django_tables2 perms %} | ||||
|  | ||||
| {% block content %} | ||||
|     <form method="post" onsubmit="window.onbeforeunload=null">{% csrf_token %} | ||||
|         {% if form.non_field_errors %} | ||||
|             <p class="errornote"> | ||||
|                 {% for error in form.non_field_errors %} | ||||
|                     {{ error }} | ||||
|                 {% endfor %} | ||||
|             </p> | ||||
|         {% endif %} | ||||
|         <fieldset class="module aligned"> | ||||
|             {% for field in form %} | ||||
|                 <div class="form-row{% if field.errors %} errors{% endif %}"> | ||||
|                     {{ field.errors }} | ||||
|                     <div> | ||||
|                         {{ field.label_tag }} | ||||
|                         {% if field.is_readonly %} | ||||
|                             <div class="readonly">{{ field.contents }}</div> | ||||
|                         {% else %} | ||||
|                             {{ field }} | ||||
|                         {% endif %} | ||||
|                         {% if field.field.help_text %} | ||||
|                             <div class="help">{{ field.field.help_text|safe }}</div> | ||||
|                         {% endif %} | ||||
|  | ||||
|     <div class="row"> | ||||
|         <div class="col-xl-12"> | ||||
|             <div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0" data-toggle="buttons"> | ||||
|                 <label for="type_gift" class="btn btn-sm btn-outline-primary active"> | ||||
|                     <input type="radio" name="transaction_type" id="type_gift" checked> | ||||
|                     {% trans "Gift" %} | ||||
|                 </label> | ||||
|                 <label for="type_transfer" class="btn btn-sm btn-outline-primary"> | ||||
|                     <input type="radio" name="transaction_type" id="type_transfer"> | ||||
|                     {% trans "Transfer" %} | ||||
|                 </label> | ||||
|                 {% if "note.notespecial"|not_empty_model_list %} | ||||
|                     <label for="type_credit" class="btn btn-sm btn-outline-primary"> | ||||
|                         <input type="radio" name="transaction_type" id="type_credit"> | ||||
|                         {% trans "Credit" %} | ||||
|                     </label> | ||||
|                     <label type="type_debit" class="btn btn-sm btn-outline-primary"> | ||||
|                         <input type="radio" name="transaction_type" id="type_debit"> | ||||
|                         {% trans "Debit" %} | ||||
|                     </label> | ||||
|                 {% endif %} | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class="row"> | ||||
|         <div class="col-md-4" id="emitters_div" style="display: none;"> | ||||
|             <div class="card border-success shadow mb-4"> | ||||
|                 <div class="card-header"> | ||||
|                     <p class="card-text font-weight-bold"> | ||||
|                         {% trans "Select emitters" %} | ||||
|                     </p> | ||||
|                 </div> | ||||
|                 <ul class="list-group list-group-flush" id="source_note_list"> | ||||
|                 </ul> | ||||
|                 <div class="card-body"> | ||||
|                     <input class="form-control mx-auto d-block" type="text" id="source_note" /> | ||||
|                     <ul class="list-group list-group-flush" id="source_alias_matched"> | ||||
|                     </ul> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         <div class="col-xl-4" id="note_infos_div"> | ||||
|             <div class="card border-success shadow mb-4"> | ||||
|                 <img src="/media/pic/default.png" | ||||
|                     id="profile_pic" alt="" class="img-fluid rounded mx-auto d-block"> | ||||
|                 <div class="card-body text-center"> | ||||
|                     <span id="user_note"></span> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         {% if "note.notespecial"|not_empty_model_list %} | ||||
|             <div class="col-md-4" id="external_div" style="display: none;"> | ||||
|                 <div class="card border-success shadow mb-4"> | ||||
|                     <div class="card-header"> | ||||
|                         <p class="card-text font-weight-bold"> | ||||
|                             {% trans "External payment" %} | ||||
|                         </p> | ||||
|                     </div> | ||||
|                     <ul class="list-group list-group-flush" id="source_note_list"> | ||||
|                     </ul> | ||||
|                     <div class="card-body"> | ||||
|                         <div class="form-row"> | ||||
|                             <div class="col-md-12"> | ||||
|                                 <label for="credit_type">{% trans "Transfer type" %} :</label> | ||||
|                                 <select id="credit_type" class="custom-select"> | ||||
|                                     {% for special_type in special_types %} | ||||
|                                         <option value="{{ special_type.id }}">{{ special_type.special_type }}</option> | ||||
|                                     {% endfor %} | ||||
|                                 </select> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                         <div class="form-row"> | ||||
|                             <div class="col-md-12"> | ||||
|                                 <label for="last_name">{% trans "Name" %} :</label> | ||||
|                                 <input type="text" id="last_name" class="form-control" /> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                         <div class="form-row"> | ||||
|                             <div class="col-md-12"> | ||||
|                                 <label for="first_name">{% trans "First name" %} :</label> | ||||
|                                 <input type="text" id="first_name" class="form-control" /> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                         <div class="form-row"> | ||||
|                             <div class="col-md-12"> | ||||
|                                 <label for="bank">{% trans "Bank" %} :</label> | ||||
|                                 <input type="text" id="bank" class="form-control" /> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             {% endfor %} | ||||
|         </fieldset> | ||||
|         <input type="submit" value="{% trans 'Transfer' %}"> | ||||
|     </form> | ||||
|             </div> | ||||
|         {% endif %} | ||||
|  | ||||
|         <div class="col-md-8" id="dests_div"> | ||||
|             <div class="card border-info shadow mb-4"> | ||||
|                 <div class="card-header"> | ||||
|                     <p class="card-text font-weight-bold" id="dest_title"> | ||||
|                         {% trans "Select receivers" %} | ||||
|                     </p> | ||||
|                 </div> | ||||
|                 <ul class="list-group list-group-flush" id="dest_note_list"> | ||||
|                 </ul> | ||||
|                 <div class="card-body"> | ||||
|                     <input class="form-control mx-auto d-block" type="text" id="dest_note" /> | ||||
|                     <ul class="list-group list-group-flush" id="dest_alias_matched"> | ||||
|                     </ul> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|  | ||||
|     <div class="form-row"> | ||||
|         <div class="form-group col-md-6"> | ||||
|             <label for="amount">{% trans "Amount" %} :</label> | ||||
|             <div class="input-group"> | ||||
|                 <input class="form-control mx-auto d-block" type="number" min="0" step="0.01" id="amount" /> | ||||
|                 <div class="input-group-append"> | ||||
|                     <span class="input-group-text">€</span> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         <div class="form-group col-md-6"> | ||||
|             <label for="reason">{% trans "Reason" %} :</label> | ||||
|             <input class="form-control mx-auto d-block" type="text" id="reason" required /> | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class="form-row"> | ||||
|         <div class="col-md-12"> | ||||
|             <button id="transfer" class="form-control btn btn-primary">{% trans 'Transfer' %}</button> | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class="card shadow mb-4" id="history"> | ||||
|         <div class="card-header"> | ||||
|             <p class="card-text font-weight-bold"> | ||||
|                 {% trans "Recent transactions history" %} | ||||
|             </p> | ||||
|         </div> | ||||
|         {% render_table table %} | ||||
|     </div> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block extrajavascript %} | ||||
|     <script> | ||||
|         TRANSFER_POLYMORPHIC_CTYPE = {{ polymorphic_ctype }}; | ||||
|         SPECIAL_TRANSFER_POLYMORPHIC_CTYPE = {{ special_polymorphic_ctype }}; | ||||
|         user_id = {{ user.note.pk }}; | ||||
|  | ||||
|         $("#type_gift").click(function() { | ||||
|             $("#emitters_div").hide(); | ||||
|             $("#external_div").hide(); | ||||
|             $("#dests_div").attr('class', 'col-md-8'); | ||||
|             $("#dest_title").text("{% trans "Select receivers" %}"); | ||||
|         }); | ||||
|  | ||||
|         $("#type_transfer").click(function() { | ||||
|             $("#emitters_div").show(); | ||||
|             $("#external_div").hide(); | ||||
|             $("#dests_div").attr('class', 'col-md-4'); | ||||
|             $("#dest_title").text("{% trans "Select receivers" %}"); | ||||
|         }); | ||||
|  | ||||
|         $("#type_credit").click(function() { | ||||
|             $("#emitters_div").hide(); | ||||
|             $("#external_div").show(); | ||||
|             $("#dests_div").attr('class', 'col-md-4'); | ||||
|             $("#dest_title").text("{% trans "Credit note" %}"); | ||||
|         }); | ||||
|  | ||||
|         $("#type_debit").click(function() { | ||||
|             $("#emitters_div").hide(); | ||||
|             $("#external_div").show(); | ||||
|             $("#dests_div").attr('class', 'col-md-4'); | ||||
|             $("#dest_title").text("{% trans "Debit note" %}"); | ||||
|         }); | ||||
|     </script> | ||||
|     <script src="/static/js/transfer.js"></script> | ||||
| {% endblock %} | ||||
|   | ||||
| @@ -15,7 +15,7 @@ | ||||
|     <td><a href="{{object.get_absolute_url}}">{{ object.name }}</a></td> | ||||
|     <td>{{ object.destination }}</td> | ||||
|     <td>{{ object.amount | pretty_money }}</td> | ||||
|     <td>{{ object.template_type }}</td> | ||||
|     <td>{{ object.category }}</td> | ||||
| </tr> | ||||
| {% endfor %} | ||||
| </table> | ||||
|   | ||||
							
								
								
									
										2
									
								
								tox.ini
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								tox.ini
									
									
									
									
									
								
							| @@ -10,7 +10,6 @@ setenv = | ||||
| 	PYTHONWARNINGS = all | ||||
| deps = | ||||
|     -r{toxinidir}/requirements/base.txt | ||||
|     -r{toxinidir}/requirements/api.txt | ||||
|     -r{toxinidir}/requirements/cas.txt | ||||
|     -r{toxinidir}/requirements/production.txt | ||||
|     coverage | ||||
| @@ -22,7 +21,6 @@ commands = | ||||
| [testenv:linters] | ||||
| deps = | ||||
|     -r{toxinidir}/requirements/base.txt | ||||
|     -r{toxinidir}/requirements/api.txt | ||||
|     -r{toxinidir}/requirements/cas.txt | ||||
|     -r{toxinidir}/requirements/production.txt | ||||
|     flake8 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user