diff --git a/.env_example b/.env_example new file mode 100644 index 00000000..5aba0d14 --- /dev/null +++ b/.env_example @@ -0,0 +1,13 @@ +DJANGO_APP_STAGE="dev" +# Only used in dev mode, change to "postgresql" if you want to use PostgreSQL in dev +DJANGO_DEV_STORE_METHOD="sqllite" +DJANGO_DB_HOST="localhost" +DJANGO_DB_NAME="note_db" +DJANGO_DB_USER="note" +DJANGO_DB_PASSWORD="CHANGE_ME" +DJANGO_DB_PORT="" +DJANGO_SECRET_KEY="CHANGE_ME" +DJANGO_SETTINGS_MODULE="note_kfet.settings" +DOMAIN="localhost" +CONTACT_EMAIL="tresorerie.bde@localhost" +NOTE_URL="localhost" diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..94cf1be6 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "apps/scripts"] + path = apps/scripts + url = git@gitlab.crans.org:bde/nk20-scripts.git diff --git a/Dockerfile b/Dockerfile index a2f45b00..d42bdd1f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,13 +9,13 @@ RUN apt update && \ apt install -y gettext nginx uwsgi uwsgi-plugin-python3 && \ rm -rf /var/lib/apt/lists/* -COPY requirements.txt /code/ +COPY . /code/ + +# Comment what is not needed RUN pip install -r requirements/base.txt RUN pip install -r requirements/api.txt RUN pip install -r requirements/cas.txt RUN pip install -r requirements/production.txt -COPY . /code/ - ENTRYPOINT ["/code/entrypoint.sh"] EXPOSE 8000 diff --git a/README.md b/README.md index 14ec5f42..1ffe8793 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ On supposera pour la suite que vous utiliser 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 @@ -40,14 +41,13 @@ On supposera pour la suite que vous utiliser debian/ubuntu sur un serveur tout n $ cp nginx_note.conf_example nginx_note.conf -***Modifier le fichier pour être en accord avec le reste de votre config*** + ***Modifier le fichier pour être en accord avec le reste de votre config*** On utilise uwsgi et Nginx pour gérer le coté serveur : - $ sudo ln -sf /var/www/note_kfet/nginx_note.conf /etc/nginx/sites-enabled/ + $ sudo ln -sf /var/www/note_kfet/nginx_note.conf /etc/nginx/sites-enabled/ - - Si l'on a un emperor (plusieurs instance uwsgi): + Si l'on a un emperor (plusieurs instance uwsgi): $ sudo ln -sf /var/www/note_kfet/uwsgi_note.ini /etc/uwsgi/sites/ @@ -85,7 +85,7 @@ On supposera pour la suite que vous utiliser debian/ubuntu sur un serveur tout n postgres=# CREATE DATABASE note_db OWNER note; CREATE DATABASE - Si tout va bien: + Si tout va bien : postgres=#\list List of databases @@ -96,22 +96,29 @@ On supposera pour la suite que vous utiliser debian/ubuntu sur un serveur tout n template0 | postgres | UTF8 | fr_FR.UTF-8 | fr_FR.UTF-8 | =c/postgres+postgres=CTc/postgres template1 | postgres | UTF8 | fr_FR.UTF-8 | fr_FR.UTF-8 | =c/postgres +postgres=CTc/postgres (4 rows) - - Dans un fichier `.env` à la racine du projet on renseigne des secrets: - DJANGO_APP_STAGE='prod' - DJANGO_DB_PASSWORD='le_mot_de_passe_de_la_bdd' - DJANGO_SECRET_KEY='une_secret_key_longue_et_compliquee' - ALLOWED_HOSTS='le_ndd_de_votre_instance' - - 6. Variable d'environnement et Migrations + 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" # 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_PORT="" + DJANGO_SECRET_KEY="CHANGE_ME" + DJANGO_SETTINGS_MODULE="note_kfet.settings" + DOMAIN="localhost" # note.example.com + CONTACT_EMAIL="tresorerie.bde@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 + Ensuite on (re)bascule dans l'environement virtuel et on lance les migrations $ source /env/bin/activate - (env)$ ./manage.py check # pas de bétise qui traine + (env)$ ./manage.py check # pas de bêtise qui traine (env)$ ./manage.py makemigrations (env)$ ./manage.py migrate @@ -126,17 +133,21 @@ Il est possible de travailler sur une instance Docker. $ git clone git@gitlab.crans.org:bde/nk20.git -2. Dans le fichier `docker_compose.yml`, qu'on suppose déjà configuré, +2. Copiez le fichier `.env_example` à la racine du projet vers le fichier `.env`, +et mettez à jour vos variables d'environnement + +3. Dans le fichier `docker_compose.yml`, qu'on suppose déjà configuré, ajouter les lignes suivantes, en les adaptant à la configuration voulue : nk20: build: /chemin/vers/nk20 volumes: - /chemin/vers/nk20:/code/ + env_file: /chemin/vers/nk20/.env restart: always labels: - - traefik.domain=ndd.exemple.com - - traefik.frontend.rule=Host:ndd.exemple.com + - traefik.domain=ndd.example.com + - traefik.frontend.rule=Host:ndd.example.com - traefik.port=8000 3. Enjoy : @@ -157,19 +168,22 @@ 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. Migrations et chargement des données initiales : +3. Copier le fichier `.env_example` vers `.env` à la racine du projet et mettre à jour +ce qu'il faut + +4. Migrations et chargement des données initiales : (env)$ ./manage.py makemigrations (env)$ ./manage.py migrate (env)$ ./manage.py loaddata initial -4. Créer un super-utilisateur : +5. Créer un super-utilisateur : (env)$ ./manage.py createsuperuser -5. Enjoy : +6. Enjoy : (env)$ ./manage.py runserver 0.0.0.0:8000 @@ -184,4 +198,4 @@ Il est disponible [ici](https://wiki.crans.org/NoteKfet/NoteKfet2018/CdC). ## Documentation La documentation est générée par django et son module admindocs. -**Commenter votre code !** +**Commentez votre code !** diff --git a/apps/activity/api/views.py b/apps/activity/api/views.py index 6a6c024e..76b2b333 100644 --- a/apps/activity/api/views.py +++ b/apps/activity/api/views.py @@ -1,13 +1,15 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -from rest_framework import viewsets +from django_filters.rest_framework import DjangoFilterBackend +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, @@ -15,9 +17,11 @@ class ActivityTypeViewSet(viewsets.ModelViewSet): """ queryset = ActivityType.objects.all() serializer_class = ActivityTypeSerializer + filter_backends = [DjangoFilterBackend] + 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, @@ -25,9 +29,11 @@ class ActivityViewSet(viewsets.ModelViewSet): """ queryset = Activity.objects.all() serializer_class = ActivitySerializer + filter_backends = [DjangoFilterBackend] + 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, @@ -35,3 +41,5 @@ class GuestViewSet(viewsets.ModelViewSet): """ queryset = Guest.objects.all() serializer_class = GuestSerializer + filter_backends = [SearchFilter] + search_fields = ['$name', ] diff --git a/apps/api/urls.py b/apps/api/urls.py index c1b6bf48..b275a0b8 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -3,10 +3,17 @@ from django.conf.urls import url, include from django.contrib.auth.models import User -from rest_framework import routers, serializers, viewsets +from django.contrib.contenttypes.models import ContentType +from django_filters.rest_framework import DjangoFilterBackend +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): @@ -24,7 +31,18 @@ class UserSerializer(serializers.ModelSerializer): ) -class UserViewSet(viewsets.ModelViewSet): +class ContentTypeSerializer(serializers.ModelSerializer): + """ + REST API Serializer for Users. + The djangorestframework plugin will analyse the model `User` and parse all fields in the API. + """ + + class Meta: + model = ContentType + fields = '__all__' + + +class UserViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer, @@ -32,15 +50,32 @@ class UserViewSet(viewsets.ModelViewSet): """ queryset = User.objects.all() serializer_class = UserSerializer + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_superuser', 'is_staff', 'is_active', ] + search_fields = ['$username', '$first_name', '$last_name', ] + + +# 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, + then render it on /api/users/ + """ + queryset = ContentType.objects.all() + serializer_class = ContentTypeSerializer # Routers provide an easy way of automatically determining the URL conf. # Register each app API router and user viewset router = routers.DefaultRouter() +router.register('models', ContentTypeViewSet) router.register('user', UserViewSet) register_members_urls(router, 'members') register_activity_urls(router, 'activity') register_note_urls(router, 'note') +register_permission_urls(router, 'permission') +register_logs_urls(router, 'logs') app_name = 'api' diff --git a/apps/api/viewsets.py b/apps/api/viewsets.py new file mode 100644 index 00000000..f7532beb --- /dev/null +++ b/apps/api/viewsets.py @@ -0,0 +1,31 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.contrib.contenttypes.models import ContentType +from permission.backends import PermissionBackend +from rest_framework import viewsets +from note_kfet.middlewares import get_current_authenticated_user + + +class ReadProtectedModelViewSet(viewsets.ModelViewSet): + """ + Protect a ModelViewSet by filtering the objects that the user cannot see. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class() + user = get_current_authenticated_user() + self.queryset = model.objects.filter(PermissionBackend.filter_queryset(user, model, "view")) + + +class ReadOnlyProtectedModelViewSet(viewsets.ReadOnlyModelViewSet): + """ + Protect a ReadOnlyModelViewSet by filtering the objects that the user cannot see. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class() + user = get_current_authenticated_user() + self.queryset = model.objects.filter(PermissionBackend.filter_queryset(user, model, "view")) diff --git a/apps/logs/api/__init__.py b/apps/logs/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/logs/api/serializers.py b/apps/logs/api/serializers.py new file mode 100644 index 00000000..c76e3a5d --- /dev/null +++ b/apps/logs/api/serializers.py @@ -0,0 +1,19 @@ +# 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 Changelog + + +class ChangelogSerializer(serializers.ModelSerializer): + """ + REST API Serializer for Changelog types. + The djangorestframework plugin will analyse the model `Changelog` and parse all fields in the API. + """ + + class Meta: + model = Changelog + fields = '__all__' + # noinspection PyProtectedMember + read_only_fields = [f.name for f in model._meta.get_fields()] # Changelogs are read-only protected diff --git a/apps/logs/api/urls.py b/apps/logs/api/urls.py new file mode 100644 index 00000000..9a0ceaa8 --- /dev/null +++ b/apps/logs/api/urls.py @@ -0,0 +1,11 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from .views import ChangelogViewSet + + +def register_logs_urls(router, path): + """ + Configure router for Activity REST API. + """ + router.register(path, ChangelogViewSet) diff --git a/apps/logs/api/views.py b/apps/logs/api/views.py new file mode 100644 index 00000000..b3b9b166 --- /dev/null +++ b/apps/logs/api/views.py @@ -0,0 +1,23 @@ +# 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.filters import OrderingFilter +from api.viewsets import ReadOnlyProtectedModelViewSet + +from .serializers import ChangelogSerializer +from ..models import Changelog + + +class ChangelogViewSet(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 = Changelog.objects.all() + serializer_class = ChangelogSerializer + filter_backends = [DjangoFilterBackend, OrderingFilter] + filterset_fields = ['model', 'action', "instance_pk", 'user', 'ip', ] + ordering_fields = ['timestamp', ] + ordering = ['-timestamp', ] diff --git a/apps/logs/apps.py b/apps/logs/apps.py index f48820c7..239f86cf 100644 --- a/apps/logs/apps.py +++ b/apps/logs/apps.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from django.apps import AppConfig +from django.db.models.signals import pre_save, post_save, post_delete from django.utils.translation import gettext_lazy as _ @@ -11,4 +12,7 @@ class LogsConfig(AppConfig): def ready(self): # noinspection PyUnresolvedReferences - import logs.signals + from . import signals + pre_save.connect(signals.pre_save_object) + post_save.connect(signals.save_object) + post_delete.connect(signals.delete_object) diff --git a/apps/logs/models.py b/apps/logs/models.py index 9ab3cf6a..10e2651f 100644 --- a/apps/logs/models.py +++ b/apps/logs/models.py @@ -56,6 +56,12 @@ class Changelog(models.Model): max_length=16, null=False, blank=False, + choices=[ + ('create', _('create')), + ('edit', _('edit')), + ('delete', _('delete')), + ], + default='edit', verbose_name=_('action'), ) diff --git a/apps/logs/signals.py b/apps/logs/signals.py index 415e7c1c..43fc1e13 100644 --- a/apps/logs/signals.py +++ b/apps/logs/signals.py @@ -1,67 +1,39 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -import inspect - from django.contrib.contenttypes.models import ContentType -from django.core import serializers -from django.db.models.signals import pre_save, post_save, post_delete -from django.dispatch import receiver +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 - -def get_request_in_signal(sender): - req = None - for entry in reversed(inspect.stack()): - try: - req = entry[0].f_locals['request'] - # Check if there is a user - # noinspection PyStatementEffect - req.user - break - except: - pass - - if not req: - print("WARNING: Attempt to save " + str(sender) + " with no user") - - return req - - -def get_user_and_ip(sender): - req = get_request_in_signal(sender) - try: - user = req.user - if 'HTTP_X_FORWARDED_FOR' in req.META: - ip = req.META.get('HTTP_X_FORWARDED_FOR') - else: - ip = req.META.get('REMOTE_ADDR') - except: - user = None - ip = None - return user, ip +import getpass +# Ces modèles ne nécessitent pas de logs EXCLUDED = [ 'admin.logentry', 'authtoken.token', + 'cas_server.proxygrantingticket', + 'cas_server.proxyticket', + 'cas_server.serviceticket', 'cas_server.user', 'cas_server.userattributes', 'contenttypes.contenttype', - 'logs.changelog', + 'logs.changelog', # Never remove this line 'migrations.migration', - 'note.noteuser', - 'note.noteclub', - 'note.notespecial', + 'note.note' # We only store the subclasses + 'note.transaction', 'sessions.session', - 'reversion.revision', - 'reversion.version', ] -@receiver(pre_save) def pre_save_object(sender, instance, **kwargs): + """ + Before a model get saved, we get the previous instance that is currently in the database + """ qs = sender.objects.filter(pk=instance.pk).all() if qs.exists(): instance._previous = qs.get() @@ -69,30 +41,51 @@ def pre_save_object(sender, instance, **kwargs): instance._previous = None -@receiver(post_save) def save_object(sender, instance, **kwargs): + """ + Each time a model is saved, an entry in the table `Changelog` is added in the database + in order to store each modification made + """ # noinspection PyProtectedMember if instance._meta.label_lower in EXCLUDED: return + # noinspection PyProtectedMember previous = instance._previous - user, ip = get_user_and_ip(sender) + # Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP + user, ip = get_current_authenticated_user(), get_current_ip() - from django.contrib.auth.models import AnonymousUser - if isinstance(user, AnonymousUser): - user = None + if user is None: + # Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py` + # On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée + # IMPORTANT : l'utilisateur dans la VM doit être un des alias note du respo info + ip = "127.0.0.1" + username = Alias.normalize(getpass.getuser()) + note = NoteUser.objects.filter(alias__normalized_name=username) + # if not note.exists(): + # print("WARNING: A model attempted to be saved in the DB, but the actor is unknown: " + username) + # else: + if note.exists(): + user = note.get().user + # noinspection PyProtectedMember if user is not None and instance._meta.label_lower == "auth.user" and previous: - # Don't save last login modifications + # On n'enregistre pas les connexions if instance.last_login != previous.last_login: return - previous_json = serializers.serialize('json', [previous, ])[1:-1] if previous else None - instance_json = serializers.serialize('json', [instance, ])[1:-1] + # On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles + class CustomSerializer(ModelSerializer): + class Meta: + model = instance.__class__ + fields = '__all__' + + previous_json = JSONRenderer().render(CustomSerializer(previous).data).decode("UTF-8") if previous else None + instance_json = JSONRenderer().render(CustomSerializer(instance).data).decode("UTF-8") if previous_json == instance_json: - # No modification + # Pas de log s'il n'y a pas de modification return Changelog.objects.create(user=user, @@ -105,15 +98,38 @@ def save_object(sender, instance, **kwargs): ).save() -@receiver(post_delete) def delete_object(sender, instance, **kwargs): + """ + Each time a model is deleted, an entry in the table `Changelog` is added in the database + """ # noinspection PyProtectedMember if instance._meta.label_lower in EXCLUDED: return - user, ip = get_user_and_ip(sender) + # Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP + user, ip = get_current_authenticated_user(), get_current_ip() + + if user is None: + # Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py` + # On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée + # IMPORTANT : l'utilisateur dans la VM doit être un des alias note du respo info + ip = "127.0.0.1" + username = Alias.normalize(getpass.getuser()) + note = NoteUser.objects.filter(alias__normalized_name=username) + # if not note.exists(): + # print("WARNING: A model attempted to be saved in the DB, but the actor is unknown: " + username) + # else: + if note.exists(): + user = note.get().user + + # On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles + class CustomSerializer(ModelSerializer): + class Meta: + model = instance.__class__ + fields = '__all__' + + instance_json = JSONRenderer().render(CustomSerializer(instance).data).decode("UTF-8") - instance_json = serializers.serialize('json', [instance, ])[1:-1] Changelog.objects.create(user=user, ip=ip, model=ContentType.objects.get_for_model(instance), diff --git a/apps/member/api/serializers.py b/apps/member/api/serializers.py index 962841ae..a956a46b 100644 --- a/apps/member/api/serializers.py +++ b/apps/member/api/serializers.py @@ -15,6 +15,7 @@ class ProfileSerializer(serializers.ModelSerializer): class Meta: model = Profile fields = '__all__' + read_only_fields = ('user', ) class ClubSerializer(serializers.ModelSerializer): diff --git a/apps/member/api/views.py b/apps/member/api/views.py index 7e7dcd1d..57c216a1 100644 --- a/apps/member/api/views.py +++ b/apps/member/api/views.py @@ -1,13 +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, @@ -17,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, @@ -25,9 +26,11 @@ class ClubViewSet(viewsets.ModelViewSet): """ queryset = Club.objects.all() serializer_class = ClubSerializer + filter_backends = [SearchFilter] + 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, @@ -35,9 +38,11 @@ class RoleViewSet(viewsets.ModelViewSet): """ queryset = Role.objects.all() serializer_class = RoleSerializer + filter_backends = [SearchFilter] + search_fields = ['$name', ] -class MembershipViewSet(viewsets.ModelViewSet): +class MembershipViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Membership` objects, serialize it to JSON with the given serializer, diff --git a/apps/member/forms.py b/apps/member/forms.py index d2134cdd..5f2d5838 100644 --- a/apps/member/forms.py +++ b/apps/member/forms.py @@ -6,12 +6,21 @@ from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout from dal import autocomplete from django import forms -from django.contrib.auth.forms import UserCreationForm +from django.contrib.auth.forms import UserCreationForm, AuthenticationForm from django.contrib.auth.models import User +from permission.models import PermissionMask from .models import Profile, Club, Membership +class CustomAuthenticationForm(AuthenticationForm): + permission_mask = forms.ModelChoiceField( + label="Masque de permissions", + queryset=PermissionMask.objects.order_by("rank"), + empty_label=None, + ) + + class SignUpForm(UserCreationForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/apps/member/models.py b/apps/member/models.py index 50b0bea1..cdbb9332 100644 --- a/apps/member/models.py +++ b/apps/member/models.py @@ -1,6 +1,8 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +import datetime + from django.conf import settings from django.db import models from django.urls import reverse, reverse_lazy @@ -46,6 +48,7 @@ class Profile(models.Model): class Meta: verbose_name = _('user profile') verbose_name_plural = _('user profile') + indexes = [models.Index(fields=['user'])] def get_absolute_url(self): return reverse('user_detail', args=(self.pk,)) @@ -149,15 +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') - -# @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() + indexes = [models.Index(fields=['user'])] diff --git a/apps/member/views.py b/apps/member/views.py index 21c8de5f..0ba76d6a 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -9,6 +9,7 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import User +from django.contrib.auth.views import LoginView from django.core.exceptions import ValidationError from django.db.models import Q from django.http import HttpResponseRedirect @@ -23,13 +24,23 @@ from note.forms import AliasForm, ImageForm from note.models import Alias, NoteUser from note.models.transactions import Transaction from note.tables import HistoryTable, AliasTable +from permission.backends import PermissionBackend from .filters import UserFilter, UserFilterFormHelper -from .forms import SignUpForm, ProfileForm, ClubForm, MembershipForm, MemberFormSet, FormSetHelper +from .forms import SignUpForm, ProfileForm, ClubForm, MembershipForm, MemberFormSet, FormSetHelper, \ + CustomAuthenticationForm from .models import Club, Membership from .tables import ClubTable, UserTable +class CustomLoginView(LoginView): + form_class = CustomAuthenticationForm + + def form_valid(self, form): + self.request.session['permission_mask'] = form.cleaned_data['permission_mask'].rank + return super().form_valid(form) + + class UserCreateView(CreateView): """ Une vue pour inscrire un utilisateur et lui créer un profile @@ -120,11 +131,14 @@ class UserDetailView(LoginRequiredMixin, DetailView): context_object_name = "user_object" template_name = "member/profile_detail.html" + def get_queryset(self, **kwargs): + return super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, User, "view")) + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) user = context['user_object'] history_list = \ - Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note)) + Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note)).order_by("-id") context['history_list'] = HistoryTable(history_list) club_list = \ Membership.objects.all().filter(user=user).only("club") @@ -147,7 +161,7 @@ class UserListView(LoginRequiredMixin, SingleTableView): formhelper_class = UserFilterFormHelper def get_queryset(self, **kwargs): - qs = super().get_queryset() + qs = super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, User, "view")) self.filter = self.filter_class(self.request.GET, queryset=qs) self.filter.form.helper = self.formhelper_class() return self.filter.qs @@ -203,7 +217,6 @@ class DeleteAliasView(LoginRequiredMixin, DeleteView): return HttpResponseRedirect(self.get_success_url()) def get_success_url(self): - print(self.request) return reverse_lazy('member:user_alias', kwargs={'pk': self.object.note.user.pk}) def get(self, request, *args, **kwargs): @@ -297,10 +310,10 @@ 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) + qs = qs.filter(username__regex="^" + self.q) return qs @@ -328,11 +341,17 @@ class ClubListView(LoginRequiredMixin, SingleTableView): model = Club table_class = ClubTable + def get_queryset(self, **kwargs): + return super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, Club, "view")) + class ClubDetailView(LoginRequiredMixin, DetailView): model = Club context_object_name = "club" + def get_queryset(self, **kwargs): + return super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, Club, "view")) + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) club = context["club"] @@ -351,6 +370,11 @@ class ClubAddMemberView(LoginRequiredMixin, CreateView): form_class = MembershipForm template_name = 'member/add_members.html' + def get_queryset(self, **kwargs): + return super().get_queryset().filter(PermissionBackend.filter_queryset(self.request.user, Membership, "view") + | PermissionBackend.filter_queryset(self.request.user, Membership, + "change")) + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['formset'] = MemberFormSet() diff --git a/apps/note/admin.py b/apps/note/admin.py index a0928641..702d3350 100644 --- a/apps/note/admin.py +++ b/apps/note/admin.py @@ -8,7 +8,7 @@ from polymorphic.admin import PolymorphicChildModelAdmin, \ from .models.notes import Alias, Note, NoteClub, NoteSpecial, NoteUser from .models.transactions import Transaction, TemplateCategory, TransactionTemplate, \ - TemplateTransaction, MembershipTransaction + RecurrentTransaction, MembershipTransaction class AliasInlines(admin.TabularInline): @@ -102,7 +102,7 @@ class TransactionAdmin(PolymorphicParentModelAdmin): """ Admin customisation for Transaction """ - child_models = (TemplateTransaction, MembershipTransaction) + child_models = (RecurrentTransaction, MembershipTransaction) list_display = ('created_at', 'poly_source', 'poly_destination', 'quantity', 'amount', 'valid') list_filter = ('valid',) diff --git a/apps/note/api/serializers.py b/apps/note/api/serializers.py index 1696bfee..a51b4263 100644 --- a/apps/note/api/serializers.py +++ b/apps/note/api/serializers.py @@ -5,7 +5,8 @@ from rest_framework import serializers from rest_polymorphic.serializers import PolymorphicSerializer from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias -from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction +from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory, \ + RecurrentTransaction, SpecialTransaction class NoteSerializer(serializers.ModelSerializer): @@ -17,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): @@ -30,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): @@ -41,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): @@ -52,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): @@ -67,6 +78,7 @@ class AliasSerializer(serializers.ModelSerializer): class Meta: model = Alias fields = '__all__' + read_only_fields = ('note', ) class NotePolymorphicSerializer(PolymorphicSerializer): @@ -77,6 +89,20 @@ class NotePolymorphicSerializer(PolymorphicSerializer): NoteSpecial: NoteSpecialSerializer } + class Meta: + model = Note + + +class TemplateCategorySerializer(serializers.ModelSerializer): + """ + REST API Serializer for Transaction templates. + The djangorestframework plugin will analyse the model `TemplateCategory` and parse all fields in the API. + """ + + class Meta: + model = TemplateCategory + fields = '__all__' + class TransactionTemplateSerializer(serializers.ModelSerializer): """ @@ -100,6 +126,17 @@ class TransactionSerializer(serializers.ModelSerializer): fields = '__all__' +class RecurrentTransactionSerializer(serializers.ModelSerializer): + """ + REST API Serializer for Transactions. + The djangorestframework plugin will analyse the model `RecurrentTransaction` and parse all fields in the API. + """ + + class Meta: + model = RecurrentTransaction + fields = '__all__' + + class MembershipTransactionSerializer(serializers.ModelSerializer): """ REST API Serializer for Membership transactions. @@ -109,3 +146,26 @@ class MembershipTransactionSerializer(serializers.ModelSerializer): class Meta: model = MembershipTransaction 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, + RecurrentTransaction: RecurrentTransactionSerializer, + MembershipTransaction: MembershipTransactionSerializer, + SpecialTransaction: SpecialTransactionSerializer, + } + + class Meta: + model = Transaction diff --git a/apps/note/api/urls.py b/apps/note/api/urls.py index 54218796..796a397f 100644 --- a/apps/note/api/urls.py +++ b/apps/note/api/urls.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from .views import NotePolymorphicViewSet, AliasViewSet, \ - TransactionViewSet, TransactionTemplateViewSet, MembershipTransactionViewSet + TemplateCategoryViewSet, TransactionViewSet, TransactionTemplateViewSet def register_note_urls(router, path): @@ -12,6 +12,6 @@ def register_note_urls(router, path): router.register(path + '/note', NotePolymorphicViewSet) router.register(path + '/alias', AliasViewSet) + router.register(path + '/transaction/category', TemplateCategoryViewSet) router.register(path + '/transaction/transaction', TransactionViewSet) router.register(path + '/transaction/template', TransactionTemplateViewSet) - router.register(path + '/transaction/membership', MembershipTransactionViewSet) diff --git a/apps/note/api/views.py b/apps/note/api/views.py index cf0136f2..f230a646 100644 --- a/apps/note/api/views.py +++ b/apps/note/api/views.py @@ -2,56 +2,17 @@ # SPDX-License-Identifier: GPL-3.0-or-later from django.db.models import Q -from rest_framework import viewsets +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.filters import OrderingFilter, SearchFilter +from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet -from .serializers import NoteSerializer, NotePolymorphicSerializer, NoteClubSerializer, NoteSpecialSerializer, \ - NoteUserSerializer, AliasSerializer, \ - TransactionTemplateSerializer, TransactionSerializer, MembershipTransactionSerializer -from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias -from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction +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, @@ -59,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.lower())) + 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, @@ -96,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): """ @@ -103,35 +58,30 @@ 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 TransactionTemplateViewSet(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, + then render it on /api/note/transaction/category/ + """ + queryset = TemplateCategory.objects.all() + serializer_class = TemplateCategorySerializer + filter_backends = [SearchFilter] + search_fields = ['$name', ] + + +class TransactionTemplateViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `TransactionTemplate` objects, serialize it to JSON with the given serializer, @@ -139,23 +89,17 @@ class TransactionTemplateViewSet(viewsets.ModelViewSet): """ queryset = TransactionTemplate.objects.all() serializer_class = TransactionTemplateSerializer + filter_backends = [DjangoFilterBackend] + 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, then render it on /api/note/transaction/transaction/ """ queryset = Transaction.objects.all() - serializer_class = TransactionSerializer - - -class MembershipTransactionViewSet(viewsets.ModelViewSet): - """ - REST API View set. - The djangorestframework plugin will get all `MembershipTransaction` objects, serialize it to JSON with the given serializer, - then render it on /api/note/transaction/membership/ - """ - queryset = MembershipTransaction.objects.all() - serializer_class = MembershipTransactionSerializer + serializer_class = TransactionPolymorphicSerializer + filter_backends = [SearchFilter] + search_fields = ['$reason', ] diff --git a/apps/note/fixtures/initial.json b/apps/note/fixtures/initial.json index f80332c0..63285e34 100644 --- a/apps/note/fixtures/initial.json +++ b/apps/note/fixtures/initial.json @@ -1,220 +1,259 @@ [ - { - "model": "note.note", - "pk": 1, - "fields": { - "polymorphic_ctype": 39, - "balance": 0, - "is_active": true, - "display_image": "", - "created_at": "2020-02-20T20:02:48.778Z" - } - }, - { - "model": "note.note", - "pk": 2, - "fields": { - "polymorphic_ctype": 39, - "balance": 0, - "is_active": true, - "display_image": "", - "created_at": "2020-02-20T20:06:39.546Z" - } - }, - { - "model": "note.note", - "pk": 3, - "fields": { - "polymorphic_ctype": 39, - "balance": 0, - "is_active": true, - "display_image": "", - "created_at": "2020-02-20T20:06:43.049Z" - } - }, - { - "model": "note.note", - "pk": 4, - "fields": { - "polymorphic_ctype": 39, - "balance": 0, - "is_active": true, - "display_image": "", - "created_at": "2020-02-20T20:06:50.996Z" - } - }, - { - "model": "note.note", - "pk": 5, - "fields": { - "polymorphic_ctype": 38, - "balance": 0, - "is_active": true, - "display_image": "", - "created_at": "2020-02-20T20:09:38.615Z" - } - }, - { - "model": "note.note", - "pk": 6, - "fields": { - "polymorphic_ctype": 38, - "balance": 0, - "is_active": true, - "display_image": "", - "created_at": "2020-02-20T20:16:14.753Z" - } - }, - { - "model": "note.notespecial", - "pk": 1, - "fields": { - "special_type": "Esp\u00e8ces" - } - }, - { - "model": "note.notespecial", - "pk": 2, - "fields": { - "special_type": "Carte bancaire" - } - }, - { - "model": "note.notespecial", - "pk": 3, - "fields": { - "special_type": "Ch\u00e8que" - } - }, - { - "model": "note.notespecial", - "pk": 4, - "fields": { - "special_type": "Virement bancaire" - } - }, - { - "model": "note.noteclub", - "pk": 5, - "fields": { - "club": 1 - } - }, - { - "model": "note.noteclub", - "pk": 6, - "fields": { - "club": 2 - } - }, - { - "model": "note.alias", - "pk": 1, - "fields": { - "name": "Esp\u00e8ces", - "normalized_name": "especes", - "note": 1 - } - }, - { - "model": "note.alias", - "pk": 2, - "fields": { - "name": "Carte bancaire", - "normalized_name": "cartebancaire", - "note": 2 - } - }, - { - "model": "note.alias", - "pk": 3, - "fields": { - "name": "Ch\u00e8que", - "normalized_name": "cheque", - "note": 3 - } - }, - { - "model": "note.alias", - "pk": 4, - "fields": { - "name": "Virement bancaire", - "normalized_name": "virementbancaire", - "note": 4 - } - }, - { - "model": "note.alias", - "pk": 5, - "fields": { - "name": "BDE", - "normalized_name": "bde", - "note": 5 - } - }, - { - "model": "note.alias", - "pk": 6, - "fields": { - "name": "Kfet", - "normalized_name": "kfet", - "note": 6 - } - }, - { - "model": "note.templatecategory", - "pk": 1, - "fields": { - "name": "Soft" - } - }, - { - "model": "note.templatecategory", - "pk": 2, - "fields": { - "name": "Pulls" - } - }, - { - "model": "note.templatecategory", - "pk": 3, - "fields": { - "name": "Gala" - } - }, - { - "model": "note.templatecategory", - "pk": 4, - "fields": { - "name": "Clubs" - } - }, - { - "model": "note.templatecategory", - "pk": 5, - "fields": { - "name": "Bouffe" - } - }, - { - "model": "note.templatecategory", - "pk": 6, - "fields": { - "name": "BDA" - } - }, - { - "model": "note.templatecategory", - "pk": 7, - "fields": { - "name": "Autre" - } - }, - { - "model": "note.templatecategory", - "pk": 8, - "fields": { - "name": "Alcool" - } + { + "model": "note.note", + "pk": 1, + "fields": { + "polymorphic_ctype": [ + "note", + "notespecial" + ], + "balance": 0, + "last_negative": null, + "is_active": true, + "display_image": "", + "created_at": "2020-02-20T20:02:48.778Z" } + }, + { + "model": "note.note", + "pk": 2, + "fields": { + "polymorphic_ctype": [ + "note", + "notespecial" + ], + "balance": 0, + "last_negative": null, + "is_active": true, + "display_image": "", + "created_at": "2020-02-20T20:06:39.546Z" + } + }, + { + "model": "note.note", + "pk": 3, + "fields": { + "polymorphic_ctype": [ + "note", + "notespecial" + ], + "balance": 0, + "last_negative": null, + "is_active": true, + "display_image": "", + "created_at": "2020-02-20T20:06:43.049Z" + } + }, + { + "model": "note.note", + "pk": 4, + "fields": { + "polymorphic_ctype": [ + "note", + "notespecial" + ], + "balance": 0, + "last_negative": null, + "is_active": true, + "display_image": "", + "created_at": "2020-02-20T20:06:50.996Z" + } + }, + { + "model": "note.note", + "pk": 5, + "fields": { + "polymorphic_ctype": [ + "note", + "noteclub" + ], + "balance": 0, + "last_negative": null, + "is_active": true, + "display_image": "", + "created_at": "2020-02-20T20:09:38.615Z" + } + }, + { + "model": "note.note", + "pk": 6, + "fields": { + "polymorphic_ctype": [ + "note", + "noteclub" + ], + "balance": 0, + "last_negative": null, + "is_active": true, + "display_image": "", + "created_at": "2020-02-20T20:16:14.753Z" + } + }, + { + "model": "note.note", + "pk": 7, + "fields": { + "polymorphic_ctype": [ + "note", + "noteuser" + ], + "balance": 0, + "last_negative": null, + "is_active": true, + "display_image": "pic/default.png", + "created_at": "2020-03-22T13:01:35.680Z" + } + }, + { + "model": "note.noteclub", + "pk": 5, + "fields": { + "club": 1 + } + }, + { + "model": "note.noteclub", + "pk": 6, + "fields": { + "club": 2 + } + }, + { + "model": "note.notespecial", + "pk": 1, + "fields": { + "special_type": "Esp\u00e8ces" + } + }, + { + "model": "note.notespecial", + "pk": 2, + "fields": { + "special_type": "Carte bancaire" + } + }, + { + "model": "note.notespecial", + "pk": 3, + "fields": { + "special_type": "Ch\u00e8que" + } + }, + { + "model": "note.notespecial", + "pk": 4, + "fields": { + "special_type": "Virement bancaire" + } + }, + { + "model": "note.alias", + "pk": 1, + "fields": { + "name": "Esp\u00e8ces", + "normalized_name": "especes", + "note": 1 + } + }, + { + "model": "note.alias", + "pk": 2, + "fields": { + "name": "Carte bancaire", + "normalized_name": "cartebancaire", + "note": 2 + } + }, + { + "model": "note.alias", + "pk": 3, + "fields": { + "name": "Ch\u00e8que", + "normalized_name": "cheque", + "note": 3 + } + }, + { + "model": "note.alias", + "pk": 4, + "fields": { + "name": "Virement bancaire", + "normalized_name": "virementbancaire", + "note": 4 + } + }, + { + "model": "note.alias", + "pk": 5, + "fields": { + "name": "BDE", + "normalized_name": "bde", + "note": 5 + } + }, + { + "model": "note.alias", + "pk": 6, + "fields": { + "name": "Kfet", + "normalized_name": "kfet", + "note": 6 + } + }, + { + "model": "note.templatecategory", + "pk": 1, + "fields": { + "name": "Soft" + } + }, + { + "model": "note.templatecategory", + "pk": 2, + "fields": { + "name": "Pulls" + } + }, + { + "model": "note.templatecategory", + "pk": 3, + "fields": { + "name": "Gala" + } + }, + { + "model": "note.templatecategory", + "pk": 4, + "fields": { + "name": "Clubs" + } + }, + { + "model": "note.templatecategory", + "pk": 5, + "fields": { + "name": "Bouffe" + } + }, + { + "model": "note.templatecategory", + "pk": 6, + "fields": { + "name": "BDA" + } + }, + { + "model": "note.templatecategory", + "pk": 7, + "fields": { + "name": "Autre" + } + }, + { + "model": "note.templatecategory", + "pk": 8, + "fields": { + "name": "Alcool" + } + } ] diff --git a/apps/note/forms.py b/apps/note/forms.py index 20804412..ac6adaaf 100644 --- a/apps/note/forms.py +++ b/apps/note/forms.py @@ -6,7 +6,7 @@ from django import forms from django.utils.translation import gettext_lazy as _ from .models import Alias -from .models import Transaction, TransactionTemplate, TemplateTransaction +from .models import TransactionTemplate class AliasForm(forms.ModelForm): @@ -50,82 +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, - }, - ), - } - - -class ConsoForm(forms.ModelForm): - def save(self, commit=True): - button: TransactionTemplate = TransactionTemplate.objects.filter( - name=self.data['button']).get() - self.instance.destination = button.destination - self.instance.amount = button.amount - self.instance.reason = '{} ({})'.format(button.name, button.category) - self.instance.template = button - self.instance.category = button.category - super().save(commit) - - class Meta: - model = TemplateTransaction - fields = ('source',) - - # Le champ d'utilisateur est remplacé par un champ d'auto-complétion. - # Quand des lettres sont tapées, une requête est envoyée sur l'API d'auto-complétion - # et récupère les aliases de note valides - widgets = { - 'source': - autocomplete.ModelSelect2( - url='note:note_autocomplete', - attrs={ - 'data-placeholder': 'Note ...', - 'data-minimum-input-length': 1, - }, - ), - } diff --git a/apps/note/models/__init__.py b/apps/note/models/__init__.py index 081b31a7..8f1921f9 100644 --- a/apps/note/models/__init__.py +++ b/apps/note/models/__init__.py @@ -3,12 +3,12 @@ from .notes import Alias, Note, NoteClub, NoteSpecial, NoteUser from .transactions import MembershipTransaction, Transaction, \ - TemplateCategory, TransactionTemplate, TemplateTransaction + TemplateCategory, TransactionTemplate, RecurrentTransaction __all__ = [ # Notes 'Alias', 'Note', 'NoteClub', 'NoteSpecial', 'NoteUser', # Transactions 'MembershipTransaction', 'Transaction', 'TemplateCategory', 'TransactionTemplate', - 'TemplateTransaction', + 'RecurrentTransaction', ] diff --git a/apps/note/models/notes.py b/apps/note/models/notes.py index 74cda3ea..b6b00aa8 100644 --- a/apps/note/models/notes.py +++ b/apps/note/models/notes.py @@ -209,6 +209,10 @@ class Alias(models.Model): class Meta: verbose_name = _("alias") verbose_name_plural = _("aliases") + indexes = [ + models.Index(fields=['name']), + models.Index(fields=['normalized_name']), + ] def __str__(self): return self.name @@ -231,7 +235,7 @@ class Alias(models.Model): try: sim_alias = Alias.objects.get(normalized_name=normalized_name) if self != sim_alias: - raise ValidationError(_('An alias with a similar name already exists: {} '.format(sim_alias)), + raise ValidationError(_('An alias with a similar name already exists: {} ').format(sim_alias), code="same_alias" ) except Alias.DoesNotExist: diff --git a/apps/note/models/transactions.py b/apps/note/models/transactions.py index 3bb7ca76..0e40edf6 100644 --- a/apps/note/models/transactions.py +++ b/apps/note/models/transactions.py @@ -7,7 +7,7 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from polymorphic.models import PolymorphicModel -from .notes import Note, NoteClub +from .notes import Note, NoteClub, NoteSpecial """ Defines transactions @@ -68,6 +68,7 @@ class TransactionTemplate(models.Model): description = models.CharField( verbose_name=_('description'), max_length=255, + blank=True, ) class Meta: @@ -106,7 +107,10 @@ class Transaction(PolymorphicModel): verbose_name=_('quantity'), default=1, ) - amount = models.PositiveIntegerField(verbose_name=_('amount'), ) + amount = models.PositiveIntegerField( + verbose_name=_('amount'), + ) + reason = models.CharField( verbose_name=_('reason'), max_length=255, @@ -119,6 +123,11 @@ class Transaction(PolymorphicModel): class Meta: verbose_name = _("transaction") verbose_name_plural = _("transactions") + indexes = [ + models.Index(fields=['created_at']), + models.Index(fields=['source']), + models.Index(fields=['destination']), + ] def save(self, *args, **kwargs): """ @@ -127,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 @@ -142,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( @@ -168,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): """ @@ -184,3 +229,7 @@ class MembershipTransaction(Transaction): class Meta: verbose_name = _("membership transaction") verbose_name_plural = _("membership transactions") + + @property + def type(self): + return _('membership transaction') diff --git a/apps/note/tables.py b/apps/note/tables.py index 580cb91d..050c8981 100644 --- a/apps/note/tables.py +++ b/apps/note/tables.py @@ -1,12 +1,15 @@ # 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, TransactionTemplate +from .models.transactions import Transaction from .templatetags.pretty_money import pretty_money @@ -17,17 +20,25 @@ class HistoryTable(tables.Table): 'table table-condensed table-striped table-hover' } model = Transaction - exclude = ("polymorphic_ctype", ) + exclude = ("id", "polymorphic_ctype", ) template_name = 'django_tables2/bootstrap4.html' - sequence = ('...', 'total', 'valid') + sequence = ('...', 'type', 'total', 'valid', ) + orderable = False + + type = tables.Column() total = tables.Column() # will use Transaction.total() !! + valid = tables.Column(attrs={"td": {"id": lambda record: "validate_" + str(record.id), + "class": lambda record: str(record.valid).lower() + ' validate', + "onclick": lambda record: 'de_validate(' + str(record.id) + ', ' + + str(record.valid).lower() + ')'}}) + def order_total(self, queryset, is_descending): # needed for rendering queryset = queryset.annotate(total=F('amount') * F('quantity')) \ .order_by(('-' if is_descending else '') + 'total') - return (queryset, True) + return queryset, True def render_amount(self, value): return pretty_money(value) @@ -35,6 +46,16 @@ class HistoryTable(tables.Table): def render_total(self, value): return pretty_money(value) + def render_type(self, value): + return _(value) + + # Django-tables escape strings. That's a wrong thing. + def render_reason(self, value): + return html.unescape(value) + + def render_valid(self, value): + return "✔" if value else "✖" + class AliasTable(tables.Table): class Meta: diff --git a/apps/note/templatetags/getenv.py b/apps/note/templatetags/getenv.py new file mode 100644 index 00000000..c133cb8f --- /dev/null +++ b/apps/note/templatetags/getenv.py @@ -0,0 +1,14 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django import template + +import os + + +def getenv(value): + return os.getenv(value) + + +register = template.Library() +register.filter('getenv', getenv) diff --git a/apps/note/templatetags/pretty_money.py b/apps/note/templatetags/pretty_money.py index 12530c6e..265870a8 100644 --- a/apps/note/templatetags/pretty_money.py +++ b/apps/note/templatetags/pretty_money.py @@ -11,7 +11,7 @@ def pretty_money(value): abs(value) // 100, ) else: - return "{:s}{:d} € {:02d}".format( + return "{:s}{:d}.{:02d} €".format( "- " if value < 0 else "", abs(value) // 100, abs(value) % 100, diff --git a/apps/note/views.py b/apps/note/views.py index 63d951cb..4f13f749 100644 --- a/apps/note/views.py +++ b/apps/note/views.py @@ -3,16 +3,18 @@ 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 TransactionTemplateForm +from .models import Transaction, TransactionTemplate, Alias, RecurrentTransaction, NoteSpecial +from .models.transactions import SpecialTransaction +from .tables import HistoryTable -from .forms import TransactionForm, TransactionTemplateForm, ConsoForm -from .models import Transaction, TransactionTemplate, Alias, TemplateTransaction -from .tables import ButtonTable class TransactionCreateView(LoginRequiredMixin, SingleTableView): """ @@ -23,34 +25,27 @@ class TransactionCreateView(LoginRequiredMixin, SingleTableView): model = Transaction form_class = TransactionForm + # 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): """ @@ -71,7 +66,7 @@ class NoteAutocomplete(autocomplete.Select2QuerySetView): # self.q est le paramètre de la recherche if self.q: - qs = qs.filter(Q(name__regex=self.q) | Q(normalized_name__regex=Alias.normalize(self.q))) \ + qs = qs.filter(Q(name__regex="^" + self.q) | Q(normalized_name__regex="^" + Alias.normalize(self.q))) \ .order_by('normalized_name').distinct() # Filtrage par type de note (user, club, special) @@ -131,31 +126,37 @@ class TransactionTemplateUpdateView(LoginRequiredMixin, UpdateView): form_class = TransactionTemplateForm -class ConsoView(LoginRequiredMixin, CreateView): +class ConsoView(LoginRequiredMixin, SingleTableView): """ The Magic View that make people pay their beer and burgers. (Most of the magic happens in the dark world of Javascript see consos.js) """ - model = TemplateTransaction template_name = "note/conso_form.html" - form_class = ConsoForm + + # 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['transaction_templates'] = TransactionTemplate.objects.filter(display=True) \ - .order_by('category') - context['title'] = _("Consommations") + 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 return context - - def get_success_url(self): - """ - When clicking a button, reload the same page - """ - return reverse('note:consos') diff --git a/apps/logs/urls.py b/apps/permission/__init__.py similarity index 61% rename from apps/logs/urls.py rename to apps/permission/__init__.py index 6d76674c..4e3eb6bc 100644 --- a/apps/logs/urls.py +++ b/apps/permission/__init__.py @@ -1,8 +1,4 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -app_name = 'logs' - -# TODO User interface -urlpatterns = [ -] +default_app_config = 'permission.apps.PermissionConfig' diff --git a/apps/permission/admin.py b/apps/permission/admin.py new file mode 100644 index 00000000..aaa6f661 --- /dev/null +++ b/apps/permission/admin.py @@ -0,0 +1,31 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-lateré + +from django.contrib import admin + +from .models import Permission, PermissionMask, RolePermissions + + +@admin.register(PermissionMask) +class PermissionMaskAdmin(admin.ModelAdmin): + """ + Admin customisation for PermissionMask + """ + list_display = ('description', 'rank', ) + + +@admin.register(Permission) +class PermissionAdmin(admin.ModelAdmin): + """ + Admin customisation for Permission + """ + list_display = ('type', 'model', 'field', 'mask', 'description', ) + + +@admin.register(RolePermissions) +class RolePermissionsAdmin(admin.ModelAdmin): + """ + Admin customisation for RolePermissions + """ + list_display = ('role', ) + diff --git a/apps/permission/api/__init__.py b/apps/permission/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/permission/api/serializers.py b/apps/permission/api/serializers.py new file mode 100644 index 00000000..0a52f4fe --- /dev/null +++ b/apps/permission/api/serializers.py @@ -0,0 +1,17 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from rest_framework import serializers + +from ..models import Permission + + +class PermissionSerializer(serializers.ModelSerializer): + """ + REST API Serializer for Permission types. + The djangorestframework plugin will analyse the model `Permission` and parse all fields in the API. + """ + + class Meta: + model = Permission + fields = '__all__' diff --git a/apps/permission/api/urls.py b/apps/permission/api/urls.py new file mode 100644 index 00000000..d50344ea --- /dev/null +++ b/apps/permission/api/urls.py @@ -0,0 +1,11 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from .views import PermissionViewSet + + +def register_permission_urls(router, path): + """ + Configure router for permission REST API. + """ + router.register(path, PermissionViewSet) diff --git a/apps/permission/api/views.py b/apps/permission/api/views.py new file mode 100644 index 00000000..6087c83e --- /dev/null +++ b/apps/permission/api/views.py @@ -0,0 +1,20 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django_filters.rest_framework import DjangoFilterBackend + +from api.viewsets import ReadOnlyProtectedModelViewSet +from .serializers import PermissionSerializer +from ..models import Permission + + +class PermissionViewSet(ReadOnlyProtectedModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer, + then render it on /api/logs/ + """ + queryset = Permission.objects.all() + serializer_class = PermissionSerializer + filter_backends = [DjangoFilterBackend] + filterset_fields = ['model', 'type', ] diff --git a/apps/permission/apps.py b/apps/permission/apps.py new file mode 100644 index 00000000..2287fec4 --- /dev/null +++ b/apps/permission/apps.py @@ -0,0 +1,14 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.apps import AppConfig +from django.db.models.signals import pre_save, pre_delete + + +class PermissionConfig(AppConfig): + name = 'permission' + + def ready(self): + from . import signals + pre_save.connect(signals.pre_save_object) + pre_delete.connect(signals.pre_delete_object) diff --git a/apps/permission/backends.py b/apps/permission/backends.py new file mode 100644 index 00000000..e61b0719 --- /dev/null +++ b/apps/permission/backends.py @@ -0,0 +1,116 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.contrib.auth.backends import ModelBackend +from django.contrib.auth.models import User, AnonymousUser +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q, F +from note.models import Note, NoteUser, NoteClub, NoteSpecial +from note_kfet.middlewares import get_current_session +from member.models import Membership, Club + +from .models import Permission + + +class PermissionBackend(ModelBackend): + """ + Manage permissions of users + """ + supports_object_permissions = True + supports_anonymous_user = False + supports_inactive_user = False + + @staticmethod + def permissions(user, model, type): + """ + List all permissions of the given user that applies to a given model and a give type + :param user: The owner of the permissions + :param model: The model that the permissions shoud apply + :param type: The type of the permissions: view, change, add or delete + :return: A generator of the requested permissions + """ + for permission in Permission.objects.annotate(club=F("rolepermissions__role__membership__club")) \ + .filter( + rolepermissions__role__membership__user=user, + model__app_label=model.app_label, # For polymorphic models, we don't filter on model type + type=type, + ).all(): + if not isinstance(model, permission.model.__class__): + continue + + club = Club.objects.get(pk=permission.club) + permission = permission.about( + user=user, + club=club, + User=User, + Club=Club, + Membership=Membership, + Note=Note, + NoteUser=NoteUser, + NoteClub=NoteClub, + NoteSpecial=NoteSpecial, + F=F, + Q=Q + ) + if permission.mask.rank <= get_current_session().get("permission_mask", 0): + yield permission + + @staticmethod + def filter_queryset(user, model, t, field=None): + """ + Filter a queryset by considering the permissions of a given user. + :param user: The owner of the permissions that are fetched + :param model: The concerned model of the queryset + :param t: The type of modification (view, add, change, delete) + :param field: The field of the model to test, if concerned + :return: A query that corresponds to the filter to give to a queryset + """ + + if user is None or isinstance(user, AnonymousUser): + # Anonymous users can't do anything + return Q(pk=-1) + + if user.is_superuser and get_current_session().get("permission_mask", 0) >= 42: + # Superusers have all rights + return Q() + + if not isinstance(model, ContentType): + model = ContentType.objects.get_for_model(model) + + # Never satisfied + query = Q(pk=-1) + perms = PermissionBackend.permissions(user, model, t) + for perm in perms: + if perm.field and field != perm.field: + continue + if perm.type != t or perm.model != model: + continue + perm.update_query() + query = query | perm.query + return query + + def has_perm(self, user_obj, perm, obj=None): + if user_obj is None or isinstance(user_obj, AnonymousUser): + return False + + if user_obj.is_superuser and get_current_session().get("permission_mask", 0) >= 42: + return True + + if obj is None: + return True + + perm = perm.split('.')[-1].split('_', 2) + perm_type = perm[0] + perm_field = perm[2] if len(perm) == 3 else None + ct = ContentType.objects.get_for_model(obj) + if any(permission.applies(obj, perm_type, perm_field) + for permission in self.permissions(user_obj, ct, perm_type)): + return True + return False + + def has_module_perms(self, user_obj, app_label): + return False + + def get_all_permissions(self, user_obj, obj=None): + ct = ContentType.objects.get_for_model(obj) + return list(self.permissions(user_obj, ct, "view")) diff --git a/apps/permission/fixtures/initial.json b/apps/permission/fixtures/initial.json new file mode 100644 index 00000000..4c7de16d --- /dev/null +++ b/apps/permission/fixtures/initial.json @@ -0,0 +1,653 @@ +[ + { + "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": [ + "auth", + "user" + ], + "query": "{\"pk\": [\"user\", \"pk\"]}", + "type": "view", + "mask": 1, + "field": "", + "description": "View our User object" + } + }, + { + "model": "permission.permission", + "pk": 2, + "fields": { + "model": [ + "member", + "profile" + ], + "query": "{\"user\": [\"user\"]}", + "type": "view", + "mask": 1, + "field": "", + "description": "View our profile" + } + }, + { + "model": "permission.permission", + "pk": 3, + "fields": { + "model": [ + "note", + "noteuser" + ], + "query": "{\"pk\": [\"user\", \"note\", \"pk\"]}", + "type": "view", + "mask": 1, + "field": "", + "description": "View our own note" + } + }, + { + "model": "permission.permission", + "pk": 4, + "fields": { + "model": [ + "authtoken", + "token" + ], + "query": "{\"user\": [\"user\"]}", + "type": "view", + "mask": 1, + "field": "", + "description": "View our API token" + } + }, + { + "model": "permission.permission", + "pk": 5, + "fields": { + "model": [ + "note", + "transaction" + ], + "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": [ + "note", + "alias" + ], + "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": [ + "auth", + "user" + ], + "query": "{\"pk\": [\"user\", \"pk\"]}", + "type": "change", + "mask": 1, + "field": "last_login", + "description": "Change myself's last login" + } + }, + { + "model": "permission.permission", + "pk": 8, + "fields": { + "model": [ + "auth", + "user" + ], + "query": "{\"pk\": [\"user\", \"pk\"]}", + "type": "change", + "mask": 1, + "field": "username", + "description": "Change myself's username" + } + }, + { + "model": "permission.permission", + "pk": 9, + "fields": { + "model": [ + "auth", + "user" + ], + "query": "{\"pk\": [\"user\", \"pk\"]}", + "type": "change", + "mask": 1, + "field": "first_name", + "description": "Change myself's first name" + } + }, + { + "model": "permission.permission", + "pk": 10, + "fields": { + "model": [ + "auth", + "user" + ], + "query": "{\"pk\": [\"user\", \"pk\"]}", + "type": "change", + "mask": 1, + "field": "last_name", + "description": "Change myself's last name" + } + }, + { + "model": "permission.permission", + "pk": 11, + "fields": { + "model": [ + "auth", + "user" + ], + "query": "{\"pk\": [\"user\", \"pk\"]}", + "type": "change", + "mask": 1, + "field": "email", + "description": "Change myself's email" + } + }, + { + "model": "permission.permission", + "pk": 12, + "fields": { + "model": [ + "authtoken", + "token" + ], + "query": "{\"user\": [\"user\"]}", + "type": "delete", + "mask": 1, + "field": "", + "description": "Delete API Token" + } + }, + { + "model": "permission.permission", + "pk": 13, + "fields": { + "model": [ + "authtoken", + "token" + ], + "query": "{\"user\": [\"user\"]}", + "type": "add", + "mask": 1, + "field": "", + "description": "Create API Token" + } + }, + { + "model": "permission.permission", + "pk": 14, + "fields": { + "model": [ + "note", + "alias" + ], + "query": "{\"note\": [\"user\", \"note\"]}", + "type": "delete", + "mask": 1, + "field": "", + "description": "Remove alias" + } + }, + { + "model": "permission.permission", + "pk": 15, + "fields": { + "model": [ + "note", + "alias" + ], + "query": "{\"note\": [\"user\", \"note\"]}", + "type": "add", + "mask": 1, + "field": "", + "description": "Add alias" + } + }, + { + "model": "permission.permission", + "pk": 16, + "fields": { + "model": [ + "note", + "noteuser" + ], + "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": [ + "note", + "transaction" + ], + "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": [ + "note", + "note" + ], + "query": "{}", + "type": "change", + "mask": 1, + "field": "balance", + "description": "Update a note balance with a transaction" + } + }, + { + "model": "permission.permission", + "pk": 19, + "fields": { + "model": [ + "note", + "note" + ], + "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": [ + "note", + "transaction" + ], + "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": [ + "note", + "recurrenttransaction" + ], + "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": [ + "member", + "club" + ], + "query": "{\"pk\": [\"club\", \"pk\"]}", + "type": "view", + "mask": 1, + "field": "", + "description": "View club infos" + } + }, + { + "model": "permission.permission", + "pk": 23, + "fields": { + "model": [ + "note", + "transaction" + ], + "query": "{}", + "type": "change", + "mask": 1, + "field": "valid", + "description": "Update validation status of a transaction" + } + }, + { + "model": "permission.permission", + "pk": 24, + "fields": { + "model": [ + "note", + "transaction" + ], + "query": "{}", + "type": "view", + "mask": 2, + "field": "", + "description": "View all transactions" + } + }, + { + "model": "permission.permission", + "pk": 25, + "fields": { + "model": [ + "note", + "notespecial" + ], + "query": "{}", + "type": "view", + "mask": 2, + "field": "", + "description": "Display credit/debit interface" + } + }, + { + "model": "permission.permission", + "pk": 26, + "fields": { + "model": [ + "note", + "specialtransaction" + ], + "query": "{}", + "type": "add", + "mask": 2, + "field": "", + "description": "Create credit/debit transaction" + } + }, + { + "model": "permission.permission", + "pk": 27, + "fields": { + "model": [ + "note", + "templatecategory" + ], + "query": "{}", + "type": "view", + "mask": 2, + "field": "", + "description": "View button categories" + } + }, + { + "model": "permission.permission", + "pk": 28, + "fields": { + "model": [ + "note", + "templatecategory" + ], + "query": "{}", + "type": "change", + "mask": 3, + "field": "", + "description": "Change button category" + } + }, + { + "model": "permission.permission", + "pk": 29, + "fields": { + "model": [ + "note", + "templatecategory" + ], + "query": "{}", + "type": "add", + "mask": 3, + "field": "", + "description": "Add button category" + } + }, + { + "model": "permission.permission", + "pk": 30, + "fields": { + "model": [ + "note", + "transactiontemplate" + ], + "query": "{}", + "type": "view", + "mask": 2, + "field": "", + "description": "View buttons" + } + }, + { + "model": "permission.permission", + "pk": 31, + "fields": { + "model": [ + "note", + "transactiontemplate" + ], + "query": "{}", + "type": "add", + "mask": 3, + "field": "", + "description": "Add buttons" + } + }, + { + "model": "permission.permission", + "pk": 32, + "fields": { + "model": [ + "note", + "transactiontemplate" + ], + "query": "{}", + "type": "change", + "mask": 3, + "field": "", + "description": "Update buttons" + } + }, + { + "model": "permission.permission", + "pk": 33, + "fields": { + "model": [ + "note", + "transaction" + ], + "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 + ] + } + } +] diff --git a/apps/permission/migrations/__init__.py b/apps/permission/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/permission/models.py b/apps/permission/models.py new file mode 100644 index 00000000..109c1875 --- /dev/null +++ b/apps/permission/models.py @@ -0,0 +1,284 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +import functools +import json +import operator + +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import F, Q, Model +from django.utils.translation import gettext_lazy as _ + +from member.models import Role + + +class InstancedPermission: + + def __init__(self, model, query, type, field, mask, **kwargs): + self.model = model + self.raw_query = query + self.query = None + self.type = type + self.field = field + self.mask = mask + self.kwargs = kwargs + + def applies(self, obj, permission_type, field_name=None): + """ + Returns True if the permission applies to + the field `field_name` object `obj` + """ + + if not isinstance(obj, self.model.model_class()): + # The permission does not apply to the model + return False + + if self.type == 'add': + if permission_type == self.type: + self.update_query() + + # Don't increase indexes + obj.pk = 0 + # Force insertion, no data verification, no trigger + Model.save(obj, force_insert=True) + ret = obj in self.model.model_class().objects.filter(self.query).all() + # Delete testing object + Model.delete(obj) + return ret + + if permission_type == self.type: + if self.field and field_name != self.field: + return False + self.update_query() + return obj in self.model.model_class().objects.filter(self.query).all() + else: + return False + + def update_query(self): + """ + The query is not analysed in a first time. It is analysed at most once if needed. + :return: + """ + if not self.query: + # noinspection PyProtectedMember + self.query = Permission._about(self.raw_query, **self.kwargs) + + def __repr__(self): + if self.field: + return _("Can {type} {model}.{field} in {query}").format(type=self.type, model=self.model, field=self.field, query=self.query) + else: + return _("Can {type} {model} in {query}").format(type=self.type, model=self.model, query=self.query) + + def __str__(self): + return self.__repr__() + + +class PermissionMask(models.Model): + """ + Permissions that are hidden behind a mask + """ + + rank = models.PositiveSmallIntegerField( + unique=True, + verbose_name=_('rank'), + ) + + description = models.CharField( + max_length=255, + unique=True, + verbose_name=_('description'), + ) + + def __str__(self): + return self.description + + +class Permission(models.Model): + + PERMISSION_TYPES = [ + ('add', 'add'), + ('view', 'view'), + ('change', 'change'), + ('delete', 'delete') + ] + + model = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name='+') + + # A json encoded Q object with the following grammar + # query -> [] | {} (the empty query representing all objects) + # query -> ["AND", query, …] AND multiple queries + # | ["OR", query, …] OR multiple queries + # | ["NOT", query] Opposite of query + # query -> {key: value, …} A list of fields and values of a Q object + # key -> string A field name + # value -> int | string | bool | null Literal values + # | [parameter, …] A parameter. See compute_param for more details. + # | {"F": oper} An F object + # oper -> [string, …] A parameter. See compute_param for more details. + # | ["ADD", oper, …] Sum multiple F objects or literal + # | ["SUB", oper, oper] Substract two F objects or literal + # | ["MUL", oper, …] Multiply F objects or literals + # | int | string | bool | null Literal values + # | ["F", string] A field + # + # Examples: + # Q(is_superuser=True) := {"is_superuser": true} + # ~Q(is_superuser=True) := ["NOT", {"is_superuser": true}] + query = models.TextField() + + type = models.CharField(max_length=15, choices=PERMISSION_TYPES) + + mask = models.ForeignKey( + PermissionMask, + on_delete=models.PROTECT, + ) + + field = models.CharField(max_length=255, blank=True) + + description = models.CharField(max_length=255, blank=True) + + class Meta: + unique_together = ('model', 'query', 'type', 'field') + + def clean(self): + self.query = json.dumps(json.loads(self.query)) + if self.field and self.type not in {'view', 'change'}: + raise ValidationError(_("Specifying field applies only to view and change permission types.")) + + def save(self, **kwargs): + self.full_clean() + super().save() + + @staticmethod + def compute_f(oper, **kwargs): + if isinstance(oper, list): + if oper[0] == 'ADD': + return functools.reduce(operator.add, [Permission.compute_f(oper, **kwargs) for oper in oper[1:]]) + elif oper[0] == 'SUB': + return Permission.compute_f(oper[1], **kwargs) - Permission.compute_f(oper[2], **kwargs) + elif oper[0] == 'MUL': + return functools.reduce(operator.mul, [Permission.compute_f(oper, **kwargs) for oper in oper[1:]]) + elif oper[0] == 'F': + return F(oper[1]) + else: + field = kwargs[oper[0]] + for i in range(1, len(oper)): + field = getattr(field, oper[i]) + return field + else: + return oper + + @staticmethod + def compute_param(value, **kwargs): + """ + A parameter is given by a list. The first argument is the name of the parameter. + The parameters are the user, the club, and some classes (Note, ...) + If there are more arguments in the list, then attributes are queried. + For example, ["user", "note", "balance"] will return the balance of the note of the user. + If an argument is a list, then this is interpreted with a function call: + First argument is the name of the function, next arguments are parameters, and if there is a dict, + then the dict is given as kwargs. + For example: NoteUser.objects.filter(user__memberships__club__name="Kfet").all() is translated by: + ["NoteUser", "objects", ["filter", {"user__memberships__club__name": "Kfet"}], ["all"]] + """ + + if not isinstance(value, list): + return value + + field = kwargs[value[0]] + for i in range(1, len(value)): + if isinstance(value[i], list): + if value[i][0] in kwargs: + field = Permission.compute_param(value[i], **kwargs) + continue + + field = getattr(field, value[i][0]) + params = [] + call_kwargs = {} + for j in range(1, len(value[i])): + param = Permission.compute_param(value[i][j], **kwargs) + if isinstance(param, dict): + for key in param: + val = Permission.compute_param(param[key], **kwargs) + call_kwargs[key] = val + else: + params.append(param) + field = field(*params, **call_kwargs) + else: + field = getattr(field, value[i]) + return field + + @staticmethod + def _about(query, **kwargs): + """ + Translate JSON query into a Q query. + :param query: The JSON query + :param kwargs: Additional params + :return: A Q object + """ + if len(query) == 0: + # The query is either [] or {} and + # applies to all objects of the model + # to represent this we return a trivial request + return Q(pk=F("pk")) + if isinstance(query, list): + if query[0] == 'AND': + return functools.reduce(operator.and_, [Permission._about(query, **kwargs) for query in query[1:]]) + elif query[0] == 'OR': + return functools.reduce(operator.or_, [Permission._about(query, **kwargs) for query in query[1:]]) + elif query[0] == 'NOT': + return ~Permission._about(query[1], **kwargs) + else: + return Q(pk=F("pk")) + elif isinstance(query, dict): + q_kwargs = {} + for key in query: + value = query[key] + if isinstance(value, list): + # It is a parameter we query its return value + q_kwargs[key] = Permission.compute_param(value, **kwargs) + elif isinstance(value, dict): + # It is an F object + q_kwargs[key] = Permission.compute_f(value['F'], **kwargs) + else: + q_kwargs[key] = value + return Q(**q_kwargs) + else: + # TODO: find a better way to crash here + raise Exception("query {} is wrong".format(query)) + + def about(self, **kwargs): + """ + Return an InstancedPermission with the parameters + replaced by their values and the query interpreted + """ + query = json.loads(self.query) + # query = self._about(query, **kwargs) + return InstancedPermission(self.model, query, self.type, self.field, self.mask, **kwargs) + + def __str__(self): + if self.field: + return _("Can {type} {model}.{field} in {query}").format(type=self.type, model=self.model, field=self.field, query=self.query) + else: + return _("Can {type} {model} in {query}").format(type=self.type, model=self.model, query=self.query) + + +class RolePermissions(models.Model): + """ + Permissions associated with a Role + """ + role = models.ForeignKey( + Role, + on_delete=models.PROTECT, + related_name='+', + verbose_name=_('role'), + ) + permissions = models.ManyToManyField( + Permission, + ) + + def __str__(self): + return str(self.role) + diff --git a/apps/permission/permissions.py b/apps/permission/permissions.py new file mode 100644 index 00000000..d9816a63 --- /dev/null +++ b/apps/permission/permissions.py @@ -0,0 +1,63 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from rest_framework.permissions import DjangoObjectPermissions + +SAFE_METHODS = ('HEAD', 'OPTIONS', ) + + +class StrongDjangoObjectPermissions(DjangoObjectPermissions): + """ + Default DjangoObjectPermissions grant view permission to all. + This is a simple patch of this class that controls view access. + """ + + perms_map = { + 'GET': ['%(app_label)s.view_%(model_name)s'], + 'OPTIONS': [], + 'HEAD': [], + 'POST': ['%(app_label)s.add_%(model_name)s'], + 'PUT': ['%(app_label)s.change_%(model_name)s'], + 'PATCH': ['%(app_label)s.change_%(model_name)s'], + 'DELETE': ['%(app_label)s.delete_%(model_name)s'], + } + + def get_required_object_permissions(self, method, model_cls): + kwargs = { + 'app_label': model_cls._meta.app_label, + 'model_name': model_cls._meta.model_name + } + + if method not in self.perms_map: + from rest_framework import exceptions + raise exceptions.MethodNotAllowed(method) + + return [perm % kwargs for perm in self.perms_map[method]] + + def has_object_permission(self, request, view, obj): + # authentication checks have already executed via has_permission + queryset = self._queryset(view) + model_cls = queryset.model + user = request.user + + perms = self.get_required_object_permissions(request.method, model_cls) + + if not user.has_perms(perms, obj): + # If the user does not have permissions we need to determine if + # they have read permissions to see 403, or not, and simply see + # a 404 response. + from django.http import Http404 + + if request.method in SAFE_METHODS: + # Read permissions already checked and failed, no need + # to make another lookup. + raise Http404 + + read_perms = self.get_required_object_permissions('GET', model_cls) + if not user.has_perms(read_perms, obj): + raise Http404 + + # Has read permissions. + return False + + return True diff --git a/apps/permission/signals.py b/apps/permission/signals.py new file mode 100644 index 00000000..aebca39d --- /dev/null +++ b/apps/permission/signals.py @@ -0,0 +1,106 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.core.exceptions import PermissionDenied +from django.db.models.signals import pre_save, pre_delete, post_save, post_delete + +from logs import signals as logs_signals +from permission.backends import PermissionBackend +from note_kfet.middlewares import get_current_authenticated_user + + +EXCLUDED = [ + 'cas_server.proxygrantingticket', + 'cas_server.proxyticket', + 'cas_server.serviceticket', + 'cas_server.user', + 'cas_server.userattributes', + 'contenttypes.contenttype', + 'logs.changelog', + 'migrations.migration', + 'sessions.session', +] + + +def pre_save_object(sender, instance, **kwargs): + """ + Before a model get saved, we check the permissions + """ + # noinspection PyProtectedMember + if instance._meta.label_lower in EXCLUDED: + return + + user = get_current_authenticated_user() + if user is None: + # Action performed on shell is always granted + return + + qs = sender.objects.filter(pk=instance.pk).all() + model_name_full = instance._meta.label_lower.split(".") + app_label = model_name_full[0] + model_name = model_name_full[1] + + if qs.exists(): + # We check if the user can change the model + + # If the user has all right on a model, then OK + if PermissionBackend().has_perm(user, app_label + ".change_" + model_name, instance): + return + + # In the other case, we check if he/she has the right to change one field + previous = qs.get() + for field in instance._meta.fields: + field_name = field.name + old_value = getattr(previous, field.name) + new_value = getattr(instance, field.name) + # If the field wasn't modified, no need to check the permissions + if old_value == new_value: + continue + if not PermissionBackend().has_perm(user, app_label + ".change_" + model_name + "_" + field_name, instance): + raise PermissionDenied + else: + # We check if the user can add the model + + # While checking permissions, the object will be inserted in the DB, then removed. + # We disable temporary the connectors + pre_save.disconnect(pre_save_object) + pre_delete.disconnect(pre_delete_object) + # We disable also logs connectors + pre_save.disconnect(logs_signals.pre_save_object) + post_save.disconnect(logs_signals.save_object) + post_delete.disconnect(logs_signals.delete_object) + + # We check if the user has right to add the object + has_perm = PermissionBackend().has_perm(user, app_label + ".add_" + model_name, instance) + + # Then we reconnect all + pre_save.connect(pre_save_object) + pre_delete.connect(pre_delete_object) + pre_save.connect(logs_signals.pre_save_object) + post_save.connect(logs_signals.save_object) + post_delete.connect(logs_signals.delete_object) + + if not has_perm: + raise PermissionDenied + + +def pre_delete_object(sender, instance, **kwargs): + """ + Before a model get deleted, we check the permissions + """ + # noinspection PyProtectedMember + if instance._meta.label_lower in EXCLUDED: + return + + user = get_current_authenticated_user() + if user is None: + # Action performed on shell is always granted + return + + model_name_full = instance._meta.label_lower.split(".") + app_label = model_name_full[0] + model_name = model_name_full[1] + + # We check if the user has rights to delete the object + if not PermissionBackend().has_perm(user, app_label + ".delete_" + model_name, instance): + raise PermissionDenied diff --git a/apps/permission/templatetags/__init__.py b/apps/permission/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/permission/templatetags/perms.py b/apps/permission/templatetags/perms.py new file mode 100644 index 00000000..8f2a0006 --- /dev/null +++ b/apps/permission/templatetags/perms.py @@ -0,0 +1,55 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.contrib.contenttypes.models import ContentType +from django.template.defaultfilters import stringfilter + +from note_kfet.middlewares import get_current_authenticated_user, get_current_session +from django import template + +from permission.backends import PermissionBackend + + +@stringfilter +def not_empty_model_list(model_name): + """ + Return True if and only if the current user has right to see any object of the given model. + """ + user = get_current_authenticated_user() + session = get_current_session() + if user is None: + return False + elif user.is_superuser and session.get("permission_mask", 0) >= 42: + return True + if session.get("not_empty_model_list_" + model_name, None): + return session.get("not_empty_model_list_" + model_name, None) == 1 + spl = model_name.split(".") + ct = ContentType.objects.get(app_label=spl[0], model=spl[1]) + qs = ct.model_class().objects.filter(PermissionBackend.filter_queryset(user, ct, "view")).all() + session["not_empty_model_list_" + model_name] = 1 if qs.exists() else 2 + return session.get("not_empty_model_list_" + model_name) == 1 + + +@stringfilter +def not_empty_model_change_list(model_name): + """ + Return True if and only if the current user has right to change any object of the given model. + """ + user = get_current_authenticated_user() + session = get_current_session() + if user is None: + return False + elif user.is_superuser and session.get("permission_mask", 0) >= 42: + return True + if session.get("not_empty_model_change_list_" + model_name, None): + return session.get("not_empty_model_change_list_" + model_name, None) == 1 + spl = model_name.split(".") + ct = ContentType.objects.get(app_label=spl[0], model=spl[1]) + qs = ct.model_class().objects.filter(PermissionBackend.filter_queryset(user, ct, "change")) + session["not_empty_model_change_list_" + model_name] = 1 if qs.exists() else 2 + return session.get("not_empty_model_change_list_" + model_name) == 1 + + +register = template.Library() +register.filter('not_empty_model_list', not_empty_model_list) +register.filter('not_empty_model_change_list', not_empty_model_change_list) diff --git a/entrypoint.sh b/entrypoint.sh index f05e962a..4d0177e8 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -2,12 +2,17 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +if [ -z ${NOTE_URL+x} ]; then + echo "Warning: your env files are not configurated." +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/REPLACEME/La Note Kfet \\\\ud83c\\\\udf7b/g" /code/note_kfet/fixtures/cas.json +fi + python manage.py compilemessages python manage.py makemigrations - -# Wait for database -sleep 5 python manage.py migrate -# TODO: use uwsgi in production python manage.py runserver 0.0.0.0:8000 diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index ce17f5de..e61efb2a 100644 --- a/locale/de/LC_MESSAGES/django.po +++ b/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-03-07 18:01+0100\n" +"POT-Creation-Date: 2020-03-16 11:53+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -23,9 +23,10 @@ msgid "activity" msgstr "" #: apps/activity/models.py:19 apps/activity/models.py:44 -#: apps/member/models.py:60 apps/member/models.py:111 -#: apps/note/models/notes.py:187 apps/note/models/transactions.py:24 -#: apps/note/models/transactions.py:44 templates/member/profile_detail.html:15 +#: 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 apps/note/models/transactions.py:202 +#: templates/member/profile_detail.html:15 msgid "name" msgstr "" @@ -49,8 +50,8 @@ msgstr "" msgid "description" msgstr "" -#: apps/activity/models.py:54 apps/note/models/notes.py:163 -#: apps/note/models/transactions.py:62 +#: apps/activity/models.py:54 apps/note/models/notes.py:164 +#: apps/note/models/transactions.py:62 apps/note/models/transactions.py:115 msgid "type" msgstr "" @@ -86,11 +87,11 @@ msgstr "" msgid "API" msgstr "" -#: apps/logs/apps.py:10 +#: apps/logs/apps.py:11 msgid "Logs" msgstr "" -#: apps/logs/models.py:21 apps/note/models/notes.py:116 +#: apps/logs/models.py:21 apps/note/models/notes.py:117 msgid "user" msgstr "" @@ -114,15 +115,27 @@ msgstr "" msgid "new data" msgstr "" -#: apps/logs/models.py:59 +#: apps/logs/models.py:60 +msgid "create" +msgstr "" + +#: apps/logs/models.py:61 +msgid "edit" +msgstr "" + +#: apps/logs/models.py:62 +msgid "delete" +msgstr "" + +#: apps/logs/models.py:65 msgid "action" msgstr "" -#: apps/logs/models.py:67 +#: apps/logs/models.py:73 msgid "timestamp" msgstr "" -#: apps/logs/models.py:71 +#: apps/logs/models.py:77 msgid "Logs cannot be destroyed." msgstr "" @@ -154,73 +167,73 @@ msgstr "" msgid "user profile" msgstr "" -#: apps/member/models.py:65 +#: apps/member/models.py:66 msgid "email" msgstr "" -#: apps/member/models.py:70 +#: apps/member/models.py:71 msgid "membership fee" msgstr "" -#: apps/member/models.py:74 +#: apps/member/models.py:75 msgid "membership duration" msgstr "" -#: apps/member/models.py:75 +#: apps/member/models.py:76 msgid "The longest time a membership can last (NULL = infinite)." msgstr "" -#: apps/member/models.py:80 +#: apps/member/models.py:81 msgid "membership start" msgstr "" -#: apps/member/models.py:81 +#: apps/member/models.py:82 msgid "How long after January 1st the members can renew their membership." msgstr "" -#: apps/member/models.py:86 +#: apps/member/models.py:87 msgid "membership end" msgstr "" -#: apps/member/models.py:87 +#: apps/member/models.py:88 msgid "" "How long the membership can last after January 1st of the next year after " "members can renew their membership." msgstr "" -#: apps/member/models.py:93 apps/note/models/notes.py:138 +#: apps/member/models.py:94 apps/note/models/notes.py:139 msgid "club" msgstr "" -#: apps/member/models.py:94 +#: apps/member/models.py:95 msgid "clubs" msgstr "" -#: apps/member/models.py:117 +#: apps/member/models.py:118 msgid "role" msgstr "" -#: apps/member/models.py:118 +#: apps/member/models.py:119 msgid "roles" msgstr "" -#: apps/member/models.py:142 +#: apps/member/models.py:143 msgid "membership starts on" msgstr "" -#: apps/member/models.py:145 +#: apps/member/models.py:146 msgid "membership ends on" msgstr "" -#: apps/member/models.py:149 +#: apps/member/models.py:150 msgid "fee" msgstr "" -#: apps/member/models.py:153 +#: apps/member/models.py:154 msgid "membership" msgstr "" -#: apps/member/models.py:154 +#: apps/member/models.py:155 msgid "memberships" msgstr "" @@ -237,140 +250,136 @@ msgstr "" msgid "Account #%(id)s: %(username)s" msgstr "" -#: apps/member/views.py:200 +#: apps/member/views.py:202 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 "" -#: apps/note/apps.py:14 apps/note/models/notes.py:57 +#: apps/note/apps.py:14 apps/note/models/notes.py:58 msgid "note" msgstr "" -#: apps/note/forms.py:26 +#: apps/note/forms.py:20 msgid "New Alias" msgstr "" -#: apps/note/forms.py:31 +#: apps/note/forms.py:25 msgid "select an image" msgstr "" -#: apps/note/forms.py:32 +#: apps/note/forms.py:26 msgid "Maximal size: 2MB" msgstr "" -#: apps/note/forms.py:77 -msgid "Source and destination must be different." -msgstr "" - -#: apps/note/models/notes.py:26 +#: apps/note/models/notes.py:27 msgid "account balance" msgstr "" -#: apps/note/models/notes.py:27 +#: apps/note/models/notes.py:28 msgid "in centimes, money credited for this instance" msgstr "" -#: apps/note/models/notes.py:31 +#: apps/note/models/notes.py:32 msgid "last negative date" msgstr "" -#: apps/note/models/notes.py:32 +#: apps/note/models/notes.py:33 msgid "last time the balance was negative" msgstr "" -#: apps/note/models/notes.py:37 +#: apps/note/models/notes.py:38 msgid "active" msgstr "" -#: apps/note/models/notes.py:40 +#: apps/note/models/notes.py:41 msgid "" "Designates whether this note should be treated as active. Unselect this " "instead of deleting notes." msgstr "" -#: apps/note/models/notes.py:44 +#: apps/note/models/notes.py:45 msgid "display image" msgstr "" -#: apps/note/models/notes.py:52 apps/note/models/transactions.py:102 +#: apps/note/models/notes.py:53 apps/note/models/transactions.py:103 msgid "created at" msgstr "" -#: apps/note/models/notes.py:58 +#: apps/note/models/notes.py:59 msgid "notes" msgstr "" -#: apps/note/models/notes.py:66 +#: apps/note/models/notes.py:67 msgid "Note" msgstr "" -#: apps/note/models/notes.py:76 apps/note/models/notes.py:100 +#: apps/note/models/notes.py:77 apps/note/models/notes.py:101 msgid "This alias is already taken." msgstr "" -#: apps/note/models/notes.py:120 +#: apps/note/models/notes.py:121 msgid "one's note" msgstr "" -#: apps/note/models/notes.py:121 +#: apps/note/models/notes.py:122 msgid "users note" msgstr "" -#: apps/note/models/notes.py:127 +#: apps/note/models/notes.py:128 #, python-format msgid "%(user)s's note" msgstr "" -#: apps/note/models/notes.py:142 +#: apps/note/models/notes.py:143 msgid "club note" msgstr "" -#: apps/note/models/notes.py:143 +#: apps/note/models/notes.py:144 msgid "clubs notes" msgstr "" -#: apps/note/models/notes.py:149 +#: apps/note/models/notes.py:150 #, python-format msgid "Note of %(club)s club" msgstr "" -#: apps/note/models/notes.py:169 +#: apps/note/models/notes.py:170 msgid "special note" msgstr "" -#: apps/note/models/notes.py:170 +#: apps/note/models/notes.py:171 msgid "special notes" msgstr "" -#: apps/note/models/notes.py:193 +#: apps/note/models/notes.py:194 msgid "Invalid alias" msgstr "" -#: apps/note/models/notes.py:209 +#: apps/note/models/notes.py:210 msgid "alias" msgstr "" -#: apps/note/models/notes.py:210 templates/member/profile_detail.html:37 +#: apps/note/models/notes.py:211 templates/member/profile_detail.html:37 msgid "aliases" msgstr "" -#: apps/note/models/notes.py:228 +#: apps/note/models/notes.py:233 msgid "Alias is too long." msgstr "" -#: apps/note/models/notes.py:233 +#: apps/note/models/notes.py:238 msgid "An alias with a similar name already exists: {} " msgstr "" -#: apps/note/models/notes.py:242 +#: apps/note/models/notes.py:247 msgid "You can't delete your main alias." msgstr "" @@ -386,7 +395,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 "" @@ -394,74 +403,116 @@ 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 -msgid "reason" +#: apps/note/models/transactions.py:117 templates/note/transaction_form.html:15 +msgid "Gift" msgstr "" -#: apps/note/models/transactions.py:115 -msgid "valid" +#: 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:120 -msgid "transaction" +#: apps/note/models/transactions.py:119 +msgid "Template" msgstr "" -#: apps/note/models/transactions.py:121 -msgid "transactions" +#: apps/note/models/transactions.py:120 templates/note/transaction_form.html:23 +msgid "Credit" msgstr "" -#: apps/note/models/transactions.py:184 +#: 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:185 +#: apps/note/models/transactions.py:129 +msgid "reason" +msgstr "" + +#: apps/note/models/transactions.py:133 +msgid "valid" +msgstr "" + +#: apps/note/models/transactions.py:138 +msgid "transaction" +msgstr "" + +#: apps/note/models/transactions.py:139 +msgid "transactions" +msgstr "" + +#: apps/note/models/transactions.py:207 +msgid "first_name" +msgstr "" + +#: apps/note/models/transactions.py:212 +msgid "bank" +msgstr "" + +#: apps/note/models/transactions.py:231 msgid "membership transactions" msgstr "" -#: apps/note/views.py:29 -msgid "Transfer money from your account to one or others" +#: apps/note/views.py:31 +msgid "Transfer money" msgstr "" -#: apps/note/views.py:138 -msgid "Consommations" +#: apps/note/views.py:132 templates/base.html:78 +msgid "Consumptions" msgstr "" -#: note_kfet/settings/base.py:162 -msgid "German" -msgstr "" - -#: note_kfet/settings/base.py:163 -msgid "English" -msgstr "" - -#: note_kfet/settings/base.py:164 -msgid "French" -msgstr "" - -#: note_kfet/settings/base.py:215 +#: note_kfet/settings/__init__.py:61 msgid "" "The Central Authentication Service grants you access to most of our websites " "by authenticating only once, so you don't need to type your credentials " "again unless your session expires or you logout." msgstr "" +#: note_kfet/settings/base.py:156 +msgid "German" +msgstr "" + +#: note_kfet/settings/base.py:157 +msgid "English" +msgstr "" + +#: note_kfet/settings/base.py:158 +msgid "French" +msgstr "" + #: templates/base.html:13 msgid "The ENS Paris-Saclay BDE note." msgstr "" -#: templates/cas_server/base.html:7 templates/cas_server/base.html:26 +#: templates/base.html:81 +msgid "Clubs" +msgstr "" + +#: templates/base.html:84 +msgid "Activities" +msgstr "" + +#: templates/base.html:87 +msgid "Buttons" +msgstr "" + +#: templates/cas_server/base.html:7 msgid "Central Authentication Service" msgstr "" @@ -511,6 +562,16 @@ msgstr "" msgid "Connect to the service" msgstr "" +#: templates/django_filters/rest_framework/crispy_form.html:4 +#: templates/django_filters/rest_framework/form.html:2 +msgid "Field filters" +msgstr "" + +#: templates/django_filters/rest_framework/form.html:5 +#: templates/member/club_form.html:10 +msgid "Submit" +msgstr "" + #: templates/member/club_detail.html:10 msgid "Membership starts on" msgstr "" @@ -531,6 +592,14 @@ msgstr "" msgid "Transaction history" msgstr "" +#: templates/member/club_form.html:6 +msgid "Clubs list" +msgstr "" + +#: templates/member/club_list.html:8 +msgid "New club" +msgstr "" + #: templates/member/manage_auth_tokens.html:16 msgid "Token" msgstr "" @@ -579,12 +648,87 @@ msgstr "" msgid "Save Changes" msgstr "" +#: templates/member/signup.html:5 templates/member/signup.html:8 #: templates/member/signup.html:14 -msgid "Sign Up" +msgid "Sign up" msgstr "" -#: templates/note/transaction_form.html:35 -msgid "Transfer" +#: 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 "" + +#: templates/note/transactiontemplate_form.html:6 +msgid "Buttons list" msgstr "" #: templates/registration/logged_out.html:8 @@ -596,7 +740,7 @@ msgid "Log in again" msgstr "" #: templates/registration/login.html:7 templates/registration/login.html:8 -#: templates/registration/login.html:22 +#: templates/registration/login.html:26 #: templates/registration/password_reset_complete.html:10 msgid "Log in" msgstr "" @@ -608,7 +752,7 @@ msgid "" "page. Would you like to login to a different account?" msgstr "" -#: templates/registration/login.html:23 +#: templates/registration/login.html:27 msgid "Forgotten your password or username?" msgstr "" diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 3a8cfb79..5e6e9470 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-03-07 18:01+0100\n" +"POT-Creation-Date: 2020-03-16 11:53+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,9 +18,10 @@ msgid "activity" msgstr "activité" #: apps/activity/models.py:19 apps/activity/models.py:44 -#: apps/member/models.py:60 apps/member/models.py:111 -#: apps/note/models/notes.py:187 apps/note/models/transactions.py:24 -#: apps/note/models/transactions.py:44 templates/member/profile_detail.html:15 +#: 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 apps/note/models/transactions.py:202 +#: templates/member/profile_detail.html:15 msgid "name" msgstr "nom" @@ -44,8 +45,8 @@ msgstr "types d'activité" msgid "description" msgstr "description" -#: apps/activity/models.py:54 apps/note/models/notes.py:163 -#: apps/note/models/transactions.py:62 +#: apps/activity/models.py:54 apps/note/models/notes.py:164 +#: apps/note/models/transactions.py:62 apps/note/models/transactions.py:115 msgid "type" msgstr "type" @@ -81,19 +82,17 @@ msgstr "invités" msgid "API" msgstr "" -#: apps/logs/apps.py:10 +#: apps/logs/apps.py:11 msgid "Logs" msgstr "" -#: apps/logs/models.py:21 apps/note/models/notes.py:116 +#: apps/logs/models.py:21 apps/note/models/notes.py:117 msgid "user" msgstr "utilisateur" #: apps/logs/models.py:27 -#, fuzzy -#| msgid "address" msgid "IP Address" -msgstr "adresse" +msgstr "Adresse IP" #: apps/logs/models.py:35 msgid "model" @@ -108,22 +107,30 @@ msgid "previous data" msgstr "Données précédentes" #: apps/logs/models.py:52 -#, fuzzy -#| msgid "end date" msgid "new data" msgstr "Nouvelles données" -#: apps/logs/models.py:59 -#, fuzzy -#| msgid "section" +#: apps/logs/models.py:60 +msgid "create" +msgstr "Créer" + +#: apps/logs/models.py:61 +msgid "edit" +msgstr "Modifier" + +#: apps/logs/models.py:62 +msgid "delete" +msgstr "Supprimer" + +#: apps/logs/models.py:65 msgid "action" msgstr "Action" -#: apps/logs/models.py:67 +#: apps/logs/models.py:73 msgid "timestamp" msgstr "Date" -#: apps/logs/models.py:71 +#: apps/logs/models.py:77 msgid "Logs cannot be destroyed." msgstr "Les logs ne peuvent pas être détruits." @@ -155,37 +162,37 @@ msgstr "payé" msgid "user profile" msgstr "profil utilisateur" -#: apps/member/models.py:65 +#: apps/member/models.py:66 msgid "email" msgstr "courriel" -#: apps/member/models.py:70 +#: apps/member/models.py:71 msgid "membership fee" msgstr "cotisation pour adhérer" -#: apps/member/models.py:74 +#: apps/member/models.py:75 msgid "membership duration" msgstr "durée de l'adhésion" -#: apps/member/models.py:75 +#: apps/member/models.py:76 msgid "The longest time a membership can last (NULL = infinite)." msgstr "La durée maximale d'une adhésion (NULL = infinie)." -#: apps/member/models.py:80 +#: apps/member/models.py:81 msgid "membership start" msgstr "début de l'adhésion" -#: apps/member/models.py:81 +#: apps/member/models.py:82 msgid "How long after January 1st the members can renew their membership." msgstr "" "Combien de temps après le 1er Janvier les adhérents peuvent renouveler leur " "adhésion." -#: apps/member/models.py:86 +#: apps/member/models.py:87 msgid "membership end" msgstr "fin de l'adhésion" -#: apps/member/models.py:87 +#: apps/member/models.py:88 msgid "" "How long the membership can last after January 1st of the next year after " "members can renew their membership." @@ -193,39 +200,39 @@ msgstr "" "Combien de temps l'adhésion peut durer après le 1er Janvier de l'année " "suivante avant que les adhérents peuvent renouveler leur adhésion." -#: apps/member/models.py:93 apps/note/models/notes.py:138 +#: apps/member/models.py:94 apps/note/models/notes.py:139 msgid "club" msgstr "club" -#: apps/member/models.py:94 +#: apps/member/models.py:95 msgid "clubs" msgstr "clubs" -#: apps/member/models.py:117 +#: apps/member/models.py:118 msgid "role" msgstr "rôle" -#: apps/member/models.py:118 +#: apps/member/models.py:119 msgid "roles" msgstr "rôles" -#: apps/member/models.py:142 +#: apps/member/models.py:143 msgid "membership starts on" msgstr "l'adhésion commence le" -#: apps/member/models.py:145 +#: apps/member/models.py:146 msgid "membership ends on" msgstr "l'adhésion finie le" -#: apps/member/models.py:149 +#: apps/member/models.py:150 msgid "fee" msgstr "cotisation" -#: apps/member/models.py:153 +#: apps/member/models.py:154 msgid "membership" msgstr "adhésion" -#: apps/member/models.py:154 +#: apps/member/models.py:155 msgid "memberships" msgstr "adhésions" @@ -242,145 +249,137 @@ msgstr "Un alias avec un nom similaire existe déjà." msgid "Account #%(id)s: %(username)s" msgstr "Compte n°%(id)s : %(username)s" -#: apps/member/views.py:200 +#: apps/member/views.py:202 msgid "Alias successfully deleted" -msgstr "" +msgstr "L'alias a bien été supprimé" -#: 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 "source" #: 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 "destination" -#: apps/note/apps.py:14 apps/note/models/notes.py:57 +#: apps/note/apps.py:14 apps/note/models/notes.py:58 msgid "note" msgstr "note" -#: apps/note/forms.py:26 +#: apps/note/forms.py:20 msgid "New Alias" -msgstr "" +msgstr "Nouvel alias" -#: apps/note/forms.py:31 -#, fuzzy -#| msgid "display image" +#: apps/note/forms.py:25 msgid "select an image" -msgstr "image affichée" +msgstr "Choisissez une image" -#: apps/note/forms.py:32 +#: apps/note/forms.py:26 msgid "Maximal size: 2MB" -msgstr "" +msgstr "Taille maximale : 2 Mo" -#: apps/note/forms.py:77 -msgid "Source and destination must be different." -msgstr "La source et la destination doivent être différentes." - -#: apps/note/models/notes.py:26 +#: apps/note/models/notes.py:27 msgid "account balance" msgstr "solde du compte" -#: apps/note/models/notes.py:27 +#: apps/note/models/notes.py:28 msgid "in centimes, money credited for this instance" msgstr "en centimes, argent crédité pour cette instance" -#: apps/note/models/notes.py:31 +#: apps/note/models/notes.py:32 msgid "last negative date" msgstr "dernier date de négatif" -#: apps/note/models/notes.py:32 +#: apps/note/models/notes.py:33 msgid "last time the balance was negative" msgstr "dernier instant où la note était en négatif" -#: apps/note/models/notes.py:37 +#: apps/note/models/notes.py:38 msgid "active" msgstr "actif" -#: apps/note/models/notes.py:40 +#: apps/note/models/notes.py:41 msgid "" "Designates whether this note should be treated as active. Unselect this " "instead of deleting notes." msgstr "" "Indique si la note est active. Désactiver cela plutôt que supprimer la note." -#: apps/note/models/notes.py:44 +#: apps/note/models/notes.py:45 msgid "display image" msgstr "image affichée" -#: apps/note/models/notes.py:52 apps/note/models/transactions.py:102 +#: apps/note/models/notes.py:53 apps/note/models/transactions.py:103 msgid "created at" msgstr "créée le" -#: apps/note/models/notes.py:58 +#: apps/note/models/notes.py:59 msgid "notes" msgstr "notes" -#: apps/note/models/notes.py:66 +#: apps/note/models/notes.py:67 msgid "Note" msgstr "Note" -#: apps/note/models/notes.py:76 apps/note/models/notes.py:100 +#: apps/note/models/notes.py:77 apps/note/models/notes.py:101 msgid "This alias is already taken." msgstr "Cet alias est déjà pris." -#: apps/note/models/notes.py:120 +#: apps/note/models/notes.py:121 msgid "one's note" msgstr "note d'un utilisateur" -#: apps/note/models/notes.py:121 +#: apps/note/models/notes.py:122 msgid "users note" msgstr "notes des utilisateurs" -#: apps/note/models/notes.py:127 +#: apps/note/models/notes.py:128 #, python-format msgid "%(user)s's note" msgstr "Note de %(user)s" -#: apps/note/models/notes.py:142 +#: apps/note/models/notes.py:143 msgid "club note" msgstr "note d'un club" -#: apps/note/models/notes.py:143 +#: apps/note/models/notes.py:144 msgid "clubs notes" msgstr "notes des clubs" -#: apps/note/models/notes.py:149 +#: apps/note/models/notes.py:150 #, python-format msgid "Note of %(club)s club" msgstr "Note du club %(club)s" -#: apps/note/models/notes.py:169 +#: apps/note/models/notes.py:170 msgid "special note" msgstr "note spéciale" -#: apps/note/models/notes.py:170 +#: apps/note/models/notes.py:171 msgid "special notes" msgstr "notes spéciales" -#: apps/note/models/notes.py:193 +#: apps/note/models/notes.py:194 msgid "Invalid alias" msgstr "Alias invalide" -#: apps/note/models/notes.py:209 +#: apps/note/models/notes.py:210 msgid "alias" msgstr "alias" -#: apps/note/models/notes.py:210 templates/member/profile_detail.html:37 +#: apps/note/models/notes.py:211 templates/member/profile_detail.html:37 msgid "aliases" msgstr "alias" -#: apps/note/models/notes.py:228 +#: apps/note/models/notes.py:233 msgid "Alias is too long." msgstr "L'alias est trop long." -#: apps/note/models/notes.py:233 -#, fuzzy -#| msgid "An alias with a similar name already exists:" +#: apps/note/models/notes.py:238 msgid "An alias with a similar name already exists: {} " -msgstr "Un alias avec un nom similaire existe déjà." +msgstr "Un alias avec un nom similaire existe déjà : {}" -#: apps/note/models/notes.py:242 +#: apps/note/models/notes.py:247 msgid "You can't delete your main alias." msgstr "Vous ne pouvez pas supprimer votre alias principal." @@ -393,11 +392,10 @@ msgid "transaction categories" msgstr "catégories de transaction" #: apps/note/models/transactions.py:47 -#, fuzzy msgid "A template with this name already exist" msgstr "Un modèle de transaction avec un nom similaire existe déjà." -#: 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 "montant" @@ -405,74 +403,116 @@ msgstr "montant" msgid "in centimes" msgstr "en centimes" -#: apps/note/models/transactions.py:74 +#: apps/note/models/transactions.py:75 msgid "transaction template" msgstr "modèle de transaction" -#: apps/note/models/transactions.py:75 +#: apps/note/models/transactions.py:76 msgid "transaction templates" msgstr "modèles de transaction" -#: apps/note/models/transactions.py:106 +#: apps/note/models/transactions.py:107 msgid "quantity" msgstr "quantité" -#: apps/note/models/transactions.py:111 -msgid "reason" -msgstr "raison" +#: apps/note/models/transactions.py:117 templates/note/transaction_form.html:15 +msgid "Gift" +msgstr "Don" -#: apps/note/models/transactions.py:115 -msgid "valid" -msgstr "valide" +#: 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 "Virement" -#: apps/note/models/transactions.py:120 -msgid "transaction" -msgstr "transaction" +#: apps/note/models/transactions.py:119 +msgid "Template" +msgstr "Bouton" -#: apps/note/models/transactions.py:121 -msgid "transactions" -msgstr "transactions" +#: apps/note/models/transactions.py:120 templates/note/transaction_form.html:23 +msgid "Credit" +msgstr "Crédit" -#: apps/note/models/transactions.py:184 +#: apps/note/models/transactions.py:121 templates/note/transaction_form.html:27 +msgid "Debit" +msgstr "Retrait" + +#: apps/note/models/transactions.py:122 apps/note/models/transactions.py:230 msgid "membership transaction" msgstr "transaction d'adhésion" -#: apps/note/models/transactions.py:185 +#: apps/note/models/transactions.py:129 +msgid "reason" +msgstr "raison" + +#: apps/note/models/transactions.py:133 +msgid "valid" +msgstr "valide" + +#: apps/note/models/transactions.py:138 +msgid "transaction" +msgstr "transaction" + +#: apps/note/models/transactions.py:139 +msgid "transactions" +msgstr "transactions" + +#: apps/note/models/transactions.py:207 +msgid "first_name" +msgstr "Prénom" + +#: apps/note/models/transactions.py:212 +msgid "bank" +msgstr "Banque" + +#: apps/note/models/transactions.py:231 msgid "membership transactions" msgstr "transactions d'adhésion" -#: apps/note/views.py:29 -msgid "Transfer money from your account to one or others" -msgstr "Transfert d'argent de ton compte vers un ou plusieurs autres" +#: apps/note/views.py:31 +msgid "Transfer money" +msgstr "Transferts d'argent" -#: apps/note/views.py:138 -msgid "Consommations" -msgstr "transactions" +#: apps/note/views.py:132 templates/base.html:78 +msgid "Consumptions" +msgstr "Consommations" -#: note_kfet/settings/base.py:162 -msgid "German" -msgstr "" - -#: note_kfet/settings/base.py:163 -msgid "English" -msgstr "" - -#: note_kfet/settings/base.py:164 -msgid "French" -msgstr "" - -#: note_kfet/settings/base.py:215 +#: note_kfet/settings/__init__.py:61 msgid "" "The Central Authentication Service grants you access to most of our websites " "by authenticating only once, so you don't need to type your credentials " "again unless your session expires or you logout." msgstr "" +#: note_kfet/settings/base.py:156 +msgid "German" +msgstr "" + +#: note_kfet/settings/base.py:157 +msgid "English" +msgstr "" + +#: note_kfet/settings/base.py:158 +msgid "French" +msgstr "" + #: templates/base.html:13 msgid "The ENS Paris-Saclay BDE note." msgstr "La note du BDE de l'ENS Paris-Saclay." -#: templates/cas_server/base.html:7 templates/cas_server/base.html:26 +#: templates/base.html:81 +msgid "Clubs" +msgstr "Clubs" + +#: templates/base.html:84 +msgid "Activities" +msgstr "Activités" + +#: templates/base.html:87 +msgid "Buttons" +msgstr "Boutons" + +#: templates/cas_server/base.html:7 msgid "Central Authentication Service" msgstr "" @@ -510,11 +550,11 @@ msgstr "" #: templates/cas_server/login.html:11 msgid "" -"If you don't have any Note Kfet account, please follow this link to sign up." +"If you don't have any Note Kfet account, please follow this link to sign up." msgstr "" -"Si vous n'avez pas de compte Note Kfet, veuillez suivre ce lien pour vous inscrire." +"Si vous n'avez pas de compte Note Kfet, veuillez suivre ce lien pour vous inscrire." #: templates/cas_server/login.html:17 msgid "Login" @@ -524,6 +564,16 @@ msgstr "" msgid "Connect to the service" msgstr "" +#: templates/django_filters/rest_framework/crispy_form.html:4 +#: templates/django_filters/rest_framework/form.html:2 +msgid "Field filters" +msgstr "" + +#: templates/django_filters/rest_framework/form.html:5 +#: templates/member/club_form.html:10 +msgid "Submit" +msgstr "Envoyer" + #: templates/member/club_detail.html:10 msgid "Membership starts on" msgstr "L'adhésion commence le" @@ -544,6 +594,14 @@ msgstr "solde du compte" msgid "Transaction history" msgstr "Historique des transactions" +#: templates/member/club_form.html:6 +msgid "Clubs list" +msgstr "Liste des clubs" + +#: templates/member/club_list.html:8 +msgid "New club" +msgstr "Nouveau club" + #: templates/member/manage_auth_tokens.html:16 msgid "Token" msgstr "Jeton" @@ -557,10 +615,8 @@ msgid "Regenerate token" msgstr "Regénérer le jeton" #: templates/member/profile_alias.html:10 -#, fuzzy -#| msgid "alias" msgid "Add alias" -msgstr "alias" +msgstr "Ajouter un alias" #: templates/member/profile_detail.html:15 msgid "first name" @@ -583,10 +639,8 @@ msgid "Manage auth token" msgstr "Gérer les jetons d'authentification" #: templates/member/profile_detail.html:49 -#, fuzzy -#| msgid "Update Profile" msgid "View Profile" -msgstr "Modifier le profil" +msgstr "Voir le profil" #: templates/member/profile_detail.html:62 msgid "View my memberships" @@ -596,13 +650,88 @@ msgstr "Voir mes adhésions" msgid "Save Changes" msgstr "Sauvegarder les changements" +#: templates/member/signup.html:5 templates/member/signup.html:8 #: templates/member/signup.html:14 -msgid "Sign Up" -msgstr "" +msgid "Sign up" +msgstr "Inscription" -#: templates/note/transaction_form.html:35 -msgid "Transfer" -msgstr "Virement" +#: templates/note/conso_form.html:28 templates/note/transaction_form.html:38 +msgid "Select emitters" +msgstr "Sélection des émetteurs" + +#: templates/note/conso_form.html:45 +msgid "Select consumptions" +msgstr "Consommations" + +#: templates/note/conso_form.html:51 +msgid "Consume!" +msgstr "Consommer !" + +#: templates/note/conso_form.html:64 +msgid "Most used buttons" +msgstr "Boutons les plus utilisés" + +#: templates/note/conso_form.html:121 +msgid "Edit" +msgstr "Éditer" + +#: templates/note/conso_form.html:126 +msgid "Single consumptions" +msgstr "Consos simples" + +#: templates/note/conso_form.html:130 +msgid "Double consumptions" +msgstr "Consos doubles" + +#: templates/note/conso_form.html:141 +msgid "Recent transactions history" +msgstr "Historique des transactions récentes" + +#: templates/note/transaction_form.html:55 +msgid "External payment" +msgstr "Paiement extérieur" + +#: templates/note/transaction_form.html:63 +msgid "Transfer type" +msgstr "Type de transfert" + +#: templates/note/transaction_form.html:73 +msgid "Name" +msgstr "Nom" + +#: templates/note/transaction_form.html:79 +msgid "First name" +msgstr "Prénom" + +#: templates/note/transaction_form.html:85 +msgid "Bank" +msgstr "Banque" + +#: templates/note/transaction_form.html:97 +#: templates/note/transaction_form.html:179 +#: templates/note/transaction_form.html:186 +msgid "Select receivers" +msgstr "Sélection des destinataires" + +#: templates/note/transaction_form.html:114 +msgid "Amount" +msgstr "Montant" + +#: templates/note/transaction_form.html:119 +msgid "Reason" +msgstr "Raison" + +#: templates/note/transaction_form.html:193 +msgid "Credit note" +msgstr "Note à créditer" + +#: templates/note/transaction_form.html:200 +msgid "Debit note" +msgstr "Note à débiter" + +#: templates/note/transactiontemplate_form.html:6 +msgid "Buttons list" +msgstr "Liste des boutons" #: templates/registration/logged_out.html:8 msgid "Thanks for spending some quality time with the Web site today." @@ -613,7 +742,7 @@ msgid "Log in again" msgstr "" #: templates/registration/login.html:7 templates/registration/login.html:8 -#: templates/registration/login.html:22 +#: templates/registration/login.html:26 #: templates/registration/password_reset_complete.html:10 msgid "Log in" msgstr "" @@ -625,7 +754,7 @@ msgid "" "page. Would you like to login to a different account?" msgstr "" -#: templates/registration/login.html:23 +#: templates/registration/login.html:27 msgid "Forgotten your password or username?" msgstr "" diff --git a/note_kfet/middlewares.py b/note_kfet/middlewares.py index b034e2be..fff824c5 100644 --- a/note_kfet/middlewares.py +++ b/note_kfet/middlewares.py @@ -1,6 +1,66 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +from django.conf import settings +from django.contrib.auth.models import AnonymousUser, User + +from threading import local + +from django.contrib.sessions.backends.db import SessionStore + +USER_ATTR_NAME = getattr(settings, 'LOCAL_USER_ATTR_NAME', '_current_user') +SESSION_ATTR_NAME = getattr(settings, 'LOCAL_SESSION_ATTR_NAME', '_current_session') +IP_ATTR_NAME = getattr(settings, 'LOCAL_IP_ATTR_NAME', '_current_ip') + +_thread_locals = local() + + +def _set_current_user_and_ip(user=None, session=None, ip=None): + setattr(_thread_locals, USER_ATTR_NAME, user) + setattr(_thread_locals, SESSION_ATTR_NAME, session) + setattr(_thread_locals, IP_ATTR_NAME, ip) + + +def get_current_user() -> User: + return getattr(_thread_locals, USER_ATTR_NAME, None) + + +def get_current_session() -> SessionStore: + return getattr(_thread_locals, SESSION_ATTR_NAME, None) + + +def get_current_ip() -> str: + return getattr(_thread_locals, IP_ATTR_NAME, None) + + +def get_current_authenticated_user(): + current_user = get_current_user() + if isinstance(current_user, AnonymousUser): + return None + return current_user + + +class SessionMiddleware(object): + """ + This middleware get the current user with his or her IP address on each request. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + user = request.user + if 'HTTP_X_FORWARDED_FOR' in request.META: + ip = request.META.get('HTTP_X_FORWARDED_FOR') + else: + ip = request.META.get('REMOTE_ADDR') + + _set_current_user_and_ip(user, request.session, ip) + response = self.get_response(request) + _set_current_user_and_ip(None, None, None) + + return response + class TurbolinksMiddleware(object): """ diff --git a/note_kfet/settings/__init__.py b/note_kfet/settings/__init__.py index 6d871599..1ab06b9c 100644 --- a/note_kfet/settings/__init__.py +++ b/note_kfet/settings/__init__.py @@ -1,4 +1,9 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.utils.translation import gettext_lazy as _ import re + from .base import * @@ -30,28 +35,28 @@ read_env() app_stage = os.environ.get('DJANGO_APP_STAGE', 'dev') if app_stage == 'prod': from .production import * - - DATABASES["default"]["PASSWORD"] = os.environ.get('DJANGO_DB_PASSWORD', 'CHANGE_ME_IN_ENV_SETTINGS') - SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'CHANGE_ME_IN_ENV_SETTINGS') - ALLOWED_HOSTS = [os.environ.get('ALLOWED_HOSTS', 'localhost')] else: from .development import * try: #in secrets.py defines everything you want from .secrets import * + INSTALLED_APPS += OPTIONAL_APPS + except ImportError: pass if "cas" in INSTALLED_APPS: MIDDLEWARE += ['cas.middleware.CASMiddleware'] # CAS Settings + CAS_SERVER_URL = "https://" + os.getenv("NOTE_URL", "note.example.com") + "/cas/" CAS_AUTO_CREATE_USER = False CAS_LOGO_URL = "/static/img/Saperlistpopette.png" CAS_FAVICON_URL = "/static/favicon/favicon-32x32.png" CAS_SHOW_SERVICE_MESSAGES = True CAS_SHOW_POWERED = False CAS_REDIRECT_TO_LOGIN_AFTER_LOGOUT = False + CAS_PROVIDE_URL_TO_LOGOUT = True CAS_INFO_MESSAGES = { "cas_explained": { "message": _( @@ -68,7 +73,11 @@ if "cas" in INSTALLED_APPS: 'cas_explained', ] AUTHENTICATION_BACKENDS += ('cas.backends.CASBackend',) - + + +if "logs" in INSTALLED_APPS: + MIDDLEWARE += ('note_kfet.middlewares.SessionMiddleware',) + if "debug_toolbar" in INSTALLED_APPS: - MIDDLEWARE.insert(1,"debug_toolbar.middleware.DebugToolbarMiddleware") - INTERNAL_IPS = [ '127.0.0.1'] + MIDDLEWARE.insert(1, "debug_toolbar.middleware.DebugToolbarMiddleware") + INTERNAL_IPS = ['127.0.0.1'] diff --git a/note_kfet/settings/base.py b/note_kfet/settings/base.py index 4fe12fbf..216199de 100644 --- a/note_kfet/settings/base.py +++ b/note_kfet/settings/base.py @@ -59,6 +59,7 @@ INSTALLED_APPS = [ 'activity', 'member', 'note', + 'permission', 'api', 'logs', ] @@ -124,22 +125,21 @@ 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 ) 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.DjangoModelPermissionsOrAnonReadOnly' + # Control API access with our role-based permission system + 'permission.permissions.StrongDjangoObjectPermissions', ], 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.TokenAuthentication', - ] + ], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 20, } # Internationalization diff --git a/note_kfet/settings/development.py b/note_kfet/settings/development.py index cf738f33..66ad4fd4 100644 --- a/note_kfet/settings/development.py +++ b/note_kfet/settings/development.py @@ -17,12 +17,24 @@ import os # https://docs.djangoproject.com/en/2.2/ref/settings/#databases from . import * -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), +if os.getenv("DJANGO_DEV_STORE_METHOD", "sqllite") == "postgresql": + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': os.environ.get('DJANGO_DB_NAME', 'note_db'), + 'USER': os.environ.get('DJANGO_DB_USER', 'note'), + 'PASSWORD': os.environ.get('DJANGO_DB_PASSWORD', 'CHANGE_ME_IN_ENV_SETTINGS'), + 'HOST': os.environ.get('DJANGO_DB_HOST', 'localhost'), + 'PORT': os.environ.get('DJANGO_DB_PORT', ''), # Use default port + } + } +else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } } -} # Break it, fix it! DEBUG = True @@ -39,7 +51,7 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' # EMAIL_HOST_USER = 'change_me' # EMAIL_HOST_PASSWORD = 'change_me' -SERVER_EMAIL = 'no-reply@example.org' +SERVER_EMAIL = 'no-reply@' + os.getenv("DOMAIN", "example.com") # Security settings SECURE_CONTENT_TYPE_NOSNIFF = False diff --git a/note_kfet/settings/production.py b/note_kfet/settings/production.py index 4512dc85..5be8a3b8 100644 --- a/note_kfet/settings/production.py +++ b/note_kfet/settings/production.py @@ -1,6 +1,8 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +import os + ######################## # Production Settings # ######################## @@ -14,11 +16,11 @@ DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': 'note_db', - 'USER': 'note', - 'PASSWORD': 'update_in_env_variable', - 'HOST': '127.0.0.1', - 'PORT': '', + 'NAME': os.environ.get('DJANGO_DB_NAME', 'note_db'), + 'USER': os.environ.get('DJANGO_DB_USER', 'note'), + 'PASSWORD': os.environ.get('DJANGO_DB_PASSWORD', 'CHANGE_ME_IN_ENV_SETTINGS'), + 'HOST': os.environ.get('DJANGO_DB_HOST', 'localhost'), + 'PORT': os.environ.get('DJANGO_DB_PORT', ''), # Use default port } } @@ -26,7 +28,9 @@ DATABASES = { DEBUG = True # Mandatory ! -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = [os.environ.get('NOTE_URL', 'localhost')] + +SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'CHANGE_ME_IN_ENV_SETTINGS') # Emails EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' @@ -37,7 +41,7 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' # EMAIL_HOST_USER = 'change_me' # EMAIL_HOST_PASSWORD = 'change_me' -SERVER_EMAIL = 'no-reply@example.org' +SERVER_EMAIL = 'no-reply@' + os.getenv("DOMAIN", "example.com") # Security settings SECURE_CONTENT_TYPE_NOSNIFF = False @@ -49,4 +53,4 @@ X_FRAME_OPTIONS = 'DENY' SESSION_COOKIE_AGE = 60 * 60 * 3 # CAS Client settings -CAS_SERVER_URL = "https://note.crans.org/cas/" +CAS_SERVER_URL = "https://" + os.getenv("NOTE_URL", "note.example.com") + "/cas/" diff --git a/note_kfet/urls.py b/note_kfet/urls.py index 896c0655..9170c62e 100644 --- a/note_kfet/urls.py +++ b/note_kfet/urls.py @@ -7,6 +7,8 @@ from django.contrib import admin from django.urls import path, include from django.views.generic import RedirectView +from member.views import CustomLoginView + urlpatterns = [ # Dev so redirect to something random path('', RedirectView.as_view(pattern_name='note:transfer'), name='index'), @@ -16,12 +18,12 @@ 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('logs/', include('logs.urls')), - path('api/', include('api.urls')), + path('accounts/', include('member.urls')), + path('accounts/login/', CustomLoginView.as_view()), + path('accounts/', include('django.contrib.auth.urls')), + path('api/', include('api.urls')), ] urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) @@ -37,8 +39,8 @@ if "cas" in settings.INSTALLED_APPS: from cas import views as cas_views urlpatterns += [ # Include CAS Client routers - path('accounts/login/', cas_views.login, name='login'), - path('accounts/logout/', cas_views.logout, name='logout'), + path('accounts/login/cas/', cas_views.login, name='cas_login'), + path('accounts/logout/cas/', cas_views.logout, name='cas_logout'), ] if "debug_toolbar" in settings.INSTALLED_APPS: diff --git a/requirements/api.txt b/requirements/api.txt deleted file mode 100644 index 8dd9f5f2..00000000 --- a/requirements/api.txt +++ /dev/null @@ -1,3 +0,0 @@ -djangorestframework==3.9.0 -django-rest-polymorphic==0.1.8 - diff --git a/requirements/base.txt b/requirements/base.txt index e9dc7635..6c5fbc4c 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -19,4 +19,6 @@ requests==2.22.0 requests-oauthlib==1.2.0 six==1.12.0 sqlparse==0.3.0 +djangorestframework==3.9.0 +django-rest-polymorphic==0.1.8 urllib3==1.25.3 diff --git a/static/js/base.js b/static/js/base.js new file mode 100644 index 00000000..f7085850 --- /dev/null +++ b/static/js/base.js @@ -0,0 +1,297 @@ +// Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +// SPDX-License-Identifier: GPL-3.0-or-later + + +/** + * Convert balance in cents to a human readable amount + * @param value the balance, in cents + * @returns {string} + */ +function pretty_money(value) { + if (value % 100 === 0) + return (value < 0 ? "- " : "") + Math.floor(Math.abs(value) / 100) + " €"; + else + return (value < 0 ? "- " : "") + Math.floor(Math.abs(value) / 100) + "." + + (Math.abs(value) % 100 < 10 ? "0" : "") + (Math.abs(value) % 100) + " €"; +} + +/** + * Add a message on the top of the page. + * @param msg The message to display + * @param alert_type The type of the alert. Choices: info, success, warning, danger + */ +function addMsg(msg, alert_type) { + let msgDiv = $("#messages"); + let html = msgDiv.html(); + html += "
" + + "" + + msg + "
\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
  • entry with a given id and text + */ +function li(id, text) { + return "
  • " + text + "
  • \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
  • 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 + + "" + disp.quantity + ""); + } + }); + + 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
  • blocks for the matched aliases + * @param note_prefix The prefix of the
  • 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 + + "" + disp.quantity + ""); + }); + + // 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("⟳ ..."); + + // 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(); + } + }); +} diff --git a/static/js/consos.js b/static/js/consos.js new file mode 100644 index 00000000..896f996c --- /dev/null +++ b/static/js/consos.js @@ -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 + + "" + button.quantity + ""); + }); + + $("#" + 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"); + }); +} diff --git a/static/js/transfer.js b/static/js/transfer.js new file mode 100644 index 00000000..c615f932 --- /dev/null +++ b/static/js/transfer.js @@ -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(); + }); + } +}); \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 887bc970..fae86443 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,4 +1,4 @@ -{% load static i18n pretty_money static %} +{% 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"> + {# Si un formulaire requiert des données supplémentaires (notamment JS), les données sont chargées #} {% if form.media %} {{ form.media }} {% endif %} + + {% block extracss %}{% endblock %} @@ -66,27 +74,36 @@ SPDX-License-Identifier: GPL-3.0-or-later @@ -84,3 +86,12 @@ {% endblock %} + +{% block extrajavascript %} + +{% endblock %} diff --git a/templates/member/signup.html b/templates/member/signup.html index e682bd9b..d7b3c23e 100644 --- a/templates/member/signup.html +++ b/templates/member/signup.html @@ -2,16 +2,16 @@ {% extends 'base.html' %} {% load crispy_forms_tags %} {% load i18n %} -{% block title %}Sign Up{% endblock %} +{% block title %}{% trans "Sign up" %}{% endblock %} {% block content %} -

    Sign up

    +

    {% trans "Sign up" %}

    {% csrf_token %} {{ form|crispy }} {{ profile_form|crispy }}
    {% endblock %} diff --git a/templates/note/conso_form.html b/templates/note/conso_form.html index 10b06589..b108a96f 100644 --- a/templates/note/conso_form.html +++ b/templates/note/conso_form.html @@ -1,97 +1,171 @@ {% extends "base.html" %} -{% load i18n static pretty_money %} +{% load i18n static pretty_money django_tables2 %} {# Remove page title #} {% block contenttitle %}{% endblock %} {% block content %} - {# Regroup buttons under categories #} - {% regroup transaction_templates by category as categories %} - -
    - {% csrf_token %} - -
    -
    - {% if form.non_field_errors %} -

    - {% for error in form.non_field_errors %} - {{ error }} - {% endfor %} -

    - {% endif %} - {% for field in form %} -
    - {{ field.errors }} -
    - {{ field.label_tag }} - {% if field.is_readonly %} -
    {{ field.contents }}
    - {% else %} - {{ field }} - {% endif %} - {% if field.field.help_text %} -
    {{ field.field.help_text|safe }}
    - {% endif %} +
    +
    +
    + {# User details column #} +
    +
    + +
    +
    - {% endfor %} -
    +
    -
    -
    - {# Tabs for button categories #} -
    -
    - + + {# Buttons column #} +
    + {# Show last used buttons #} +
    +
    +

    + {% trans "Most used buttons" %} +

    +
    +
    +
    + {% for button in most_used %} + {% if button.display %} + + {% endif %} + {% endfor %} +
    +
    +
    + + {# Regroup buttons under categories #} + {% regroup transaction_templates by category as categories %} + +
    + {# Tabs for button categories #} +
    + +
    + + {# Tabs content #} +
    +
    + {% for category in categories %} +
    +
    + {% for button in category.list %} + {% if button.display %} + + {% endif %} + {% endfor %} +
    +
    + {% endfor %} +
    +
    + + {# Mode switch #} + +
    +
    +
    + +
    +
    +

    + {% trans "Recent transactions history" %} +

    +
    + {% render_table table %} +
    {% endblock %} {% block extrajavascript %} + {% endblock %} diff --git a/templates/note/transaction_form.html b/templates/note/transaction_form.html index ff8504bc..347db056 100644 --- a/templates/note/transaction_form.html +++ b/templates/note/transaction_form.html @@ -3,35 +3,192 @@ SPDX-License-Identifier: GPL-2.0-or-later {% endcomment %} -{% load i18n static %} +{% load i18n static django_tables2 perms %} {% block content %} -
    {% csrf_token %} - {% if form.non_field_errors %} -

    - {% for error in form.non_field_errors %} - {{ error }} - {% endfor %} -

    - {% endif %} -
    - {% for field in form %} -
    - {{ field.errors }} -
    - {{ field.label_tag }} - {% if field.is_readonly %} -
    {{ field.contents }}
    - {% else %} - {{ field }} - {% endif %} - {% if field.field.help_text %} -
    {{ field.field.help_text|safe }}
    - {% endif %} + +
    +
    +
    + + + {% if "note.notespecial"|not_empty_model_list %} + + + {% endif %} +
    +
    +
    + +
    + + +
    +
    + +
    + +
    +
    +
    + + {% if "note.notespecial"|not_empty_model_list %} +
    - -
    +
    + {% endif %} + +
    +
    +
    +

    + {% trans "Select receivers" %} +

    +
    +
      +
    +
    + +
      +
    +
    +
    +
    +
    + + +
    +
    + +
    + +
    + +
    +
    +
    + +
    + + +
    +
    + +
    +
    + +
    +
    + +
    +
    +

    + {% trans "Recent transactions history" %} +

    +
    + {% render_table table %} +
    +{% endblock %} + +{% block extrajavascript %} + + {% endblock %} diff --git a/templates/note/transactiontemplate_form.html b/templates/note/transactiontemplate_form.html index 3fc2dd8b..1f9a574a 100644 --- a/templates/note/transactiontemplate_form.html +++ b/templates/note/transactiontemplate_form.html @@ -1,8 +1,9 @@ {% extends "base.html" %} {% load static %} +{% load i18n %} {% load crispy_forms_tags %} {% block content %} -

    Template Listing

    +

    {% trans "Buttons list" %}

    {% csrf_token %} {{form|crispy}} diff --git a/templates/registration/login.html b/templates/registration/login.html index 04ef8d7d..175d37e0 100644 --- a/templates/registration/login.html +++ b/templates/registration/login.html @@ -16,7 +16,13 @@ SPDX-License-Identifier: GPL-2.0-or-later {% endblocktrans %}

    {% endif %} - + {%url 'cas_login' as cas_url %} + {% if cas_url %} +
    + {% trans "You can also register via the central authentification server " %} + {% trans "using this link "%} +
    + {%endif%} {% csrf_token %} {{ form | crispy }} diff --git a/tox.ini b/tox.ini index 7c432d55..0b5c20c9 100644 --- a/tox.ini +++ b/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 @@ -32,7 +30,7 @@ deps = pep8-naming pyflakes commands = - flake8 apps/activity apps/api apps/member apps/note + flake8 apps/activity apps/api apps/logs apps/member apps/note [flake8] # Ignore too many errors, should be reduced in the future