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 2c840829..d42bdd1f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,10 +9,13 @@ RUN apt update && \ apt install -y gettext nginx uwsgi uwsgi-plugin-python3 && \ rm -rf /var/lib/apt/lists/* -COPY requirements.txt /code/ -RUN pip install -r requirements.txt - 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 + ENTRYPOINT ["/code/entrypoint.sh"] EXPOSE 8000 diff --git a/README.md b/README.md index 5ae8a396..91f2f17d 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,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.txt + (env)$ pip3 install -r requirements/base.txt (env)$ deactivate 4. uwsgi et Nginx @@ -40,14 +40,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é serveu : + 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 +84,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 +95,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" + 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" -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 +132,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 : @@ -159,17 +169,20 @@ un serveur de développement par exemple sur son ordinateur. $ source venv/bin/activate (env)$ pip install -r requirements.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 +197,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/admin.py b/apps/activity/admin.py index 5ceb4e81..0529d306 100644 --- a/apps/activity/admin.py +++ b/apps/activity/admin.py @@ -11,7 +11,7 @@ class ActivityAdmin(admin.ModelAdmin): Admin customisation for Activity """ list_display = ('name', 'activity_type', 'organizer') - list_filter = ('activity_type', ) + list_filter = ('activity_type',) search_fields = ['name', 'organizer__name'] # Organize activities by start date diff --git a/apps/activity/api/serializers.py b/apps/activity/api/serializers.py index 0b9302f1..514515ef 100644 --- a/apps/activity/api/serializers.py +++ b/apps/activity/api/serializers.py @@ -11,6 +11,7 @@ class ActivityTypeSerializer(serializers.ModelSerializer): REST API Serializer for Activity types. The djangorestframework plugin will analyse the model `ActivityType` and parse all fields in the API. """ + class Meta: model = ActivityType fields = '__all__' @@ -21,6 +22,7 @@ class ActivitySerializer(serializers.ModelSerializer): REST API Serializer for Activities. The djangorestframework plugin will analyse the model `Activity` and parse all fields in the API. """ + class Meta: model = Activity fields = '__all__' @@ -31,6 +33,7 @@ class GuestSerializer(serializers.ModelSerializer): REST API Serializer for Guests. The djangorestframework plugin will analyse the model `Guest` and parse all fields in the API. """ + class Meta: model = Guest fields = '__all__' diff --git a/apps/activity/api/views.py b/apps/activity/api/views.py index 5683d458..4ee2194d 100644 --- a/apps/activity/api/views.py +++ b/apps/activity/api/views.py @@ -1,10 +1,11 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later - +from django_filters.rest_framework import DjangoFilterBackend from rest_framework import viewsets +from rest_framework.filters import SearchFilter -from ..models import ActivityType, Activity, Guest from .serializers import ActivityTypeSerializer, ActivitySerializer, GuestSerializer +from ..models import ActivityType, Activity, Guest class ActivityTypeViewSet(viewsets.ModelViewSet): @@ -15,6 +16,8 @@ class ActivityTypeViewSet(viewsets.ModelViewSet): """ queryset = ActivityType.objects.all() serializer_class = ActivityTypeSerializer + filter_backends = [DjangoFilterBackend] + filterset_fields = ['name', 'can_invite', ] class ActivityViewSet(viewsets.ModelViewSet): @@ -25,6 +28,8 @@ class ActivityViewSet(viewsets.ModelViewSet): """ queryset = Activity.objects.all() serializer_class = ActivitySerializer + filter_backends = [DjangoFilterBackend] + filterset_fields = ['name', 'description', 'activity_type', ] class GuestViewSet(viewsets.ModelViewSet): @@ -35,3 +40,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 7e59a8c0..95ed5f99 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -3,10 +3,14 @@ from django.conf.urls import url, include from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType +from django_filters.rest_framework import DjangoFilterBackend from rest_framework import routers, serializers, viewsets +from rest_framework.filters import SearchFilter from activity.api.urls import register_activity_urls from member.api.urls import register_members_urls from note.api.urls import register_note_urls +from logs.api.urls import register_logs_urls class UserSerializer(serializers.ModelSerializer): @@ -14,6 +18,7 @@ class UserSerializer(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 = User exclude = ( @@ -23,6 +28,17 @@ class UserSerializer(serializers.ModelSerializer): ) +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(viewsets.ModelViewSet): """ REST API View set. @@ -31,15 +47,30 @@ 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', ] + + +class ContentTypeViewSet(viewsets.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_logs_urls(router, 'logs') app_name = 'api' 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..2c47b7a2 --- /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 import viewsets +from rest_framework.filters import OrderingFilter + +from .serializers import ChangelogSerializer +from ..models import Changelog + + +class ChangelogViewSet(viewsets.ReadOnlyModelViewSet): + """ + 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/middlewares.py b/apps/logs/middlewares.py new file mode 100644 index 00000000..77f749b9 --- /dev/null +++ b/apps/logs/middlewares.py @@ -0,0 +1,55 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.conf import settings +from django.contrib.auth.models import AnonymousUser + +from threading import local + + +USER_ATTR_NAME = getattr(settings, 'LOCAL_USER_ATTR_NAME', '_current_user') +IP_ATTR_NAME = getattr(settings, 'LOCAL_IP_ATTR_NAME', '_current_ip') + +_thread_locals = local() + + +def _set_current_user_and_ip(user=None, ip=None): + setattr(_thread_locals, USER_ATTR_NAME, user) + setattr(_thread_locals, IP_ATTR_NAME, ip) + + +def get_current_user(): + return getattr(_thread_locals, USER_ATTR_NAME, None) + + +def get_current_ip(): + return getattr(_thread_locals, IP_ATTR_NAME, None) + + +def get_current_authenticated_user(): + current_user = get_current_user() + if isinstance(current_user, AnonymousUser): + return None + return current_user + + +class LogsMiddleware(object): + """ + This middleware get the current user with his or her IP address on each request. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + user = request.user + if 'HTTP_X_FORWARDED_FOR' in request.META: + ip = request.META.get('HTTP_X_FORWARDED_FOR') + else: + ip = request.META.get('REMOTE_ADDR') + + _set_current_user_and_ip(user, ip) + response = self.get_response(request) + _set_current_user_and_ip(None, None) + + return response diff --git a/apps/logs/models.py b/apps/logs/models.py index 337315bb..10e2651f 100644 --- a/apps/logs/models.py +++ b/apps/logs/models.py @@ -1,16 +1,16 @@ # 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.utils.translation import gettext_lazy as _ from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models +from django.utils.translation import gettext_lazy as _ class Changelog(models.Model): """ - Store each modification on the database (except sessions and logging), + Store each modification in the database (except sessions and logging), including creating, editing and deleting models. """ @@ -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 13194e5b..fb17157a 100644 --- a/apps/logs/signals.py +++ b/apps/logs/signals.py @@ -1,66 +1,40 @@ # 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 + +import getpass + +from note.models import NoteUser, Alias + +from .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 - - +# Ces modèles ne nécessitent pas de logs EXCLUDED = [ - 'admin.logentry', - 'authtoken.token', - 'cas_server.user', - 'cas_server.userattributes', - 'contenttypes.contenttype', - 'logs.changelog', - 'migrations.migration', - 'note.noteuser', - 'note.noteclub', - 'note.notespecial', - 'sessions.session', - 'reversion.revision', - 'reversion.version', - ] + 'admin.logentry', + 'authtoken.token', + 'cas_server.proxygrantingticket', + 'cas_server.proxyticket', + 'cas_server.serviceticket', + 'cas_server.user', + 'cas_server.userattributes', + 'contenttypes.contenttype', + 'logs.changelog', # Never remove this line + 'migrations.migration', + 'note.note' # We only store the subclasses + 'note.transaction', + 'sessions.session', +] -@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() @@ -68,30 +42,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, @@ -104,15 +99,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/admin.py b/apps/member/admin.py index 70b00459..48fbc035 100644 --- a/apps/member/admin.py +++ b/apps/member/admin.py @@ -18,9 +18,9 @@ class ProfileInline(admin.StackedInline): class CustomUserAdmin(UserAdmin): - inlines = (ProfileInline, ) + inlines = (ProfileInline,) list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff') - list_select_related = ('profile', ) + list_select_related = ('profile',) form = ProfileForm def get_inline_instances(self, request, obj=None): diff --git a/apps/member/api/serializers.py b/apps/member/api/serializers.py index f4df6799..962841ae 100644 --- a/apps/member/api/serializers.py +++ b/apps/member/api/serializers.py @@ -11,6 +11,7 @@ class ProfileSerializer(serializers.ModelSerializer): REST API Serializer for Profiles. The djangorestframework plugin will analyse the model `Profile` and parse all fields in the API. """ + class Meta: model = Profile fields = '__all__' @@ -21,6 +22,7 @@ class ClubSerializer(serializers.ModelSerializer): REST API Serializer for Clubs. The djangorestframework plugin will analyse the model `Club` and parse all fields in the API. """ + class Meta: model = Club fields = '__all__' @@ -31,6 +33,7 @@ class RoleSerializer(serializers.ModelSerializer): REST API Serializer for Roles. The djangorestframework plugin will analyse the model `Role` and parse all fields in the API. """ + class Meta: model = Role fields = '__all__' @@ -41,6 +44,7 @@ class MembershipSerializer(serializers.ModelSerializer): REST API Serializer for Memberships. The djangorestframework plugin will analyse the model `Memberships` and parse all fields in the API. """ + class Meta: model = Membership fields = '__all__' diff --git a/apps/member/api/views.py b/apps/member/api/views.py index 79ba4c12..c85df903 100644 --- a/apps/member/api/views.py +++ b/apps/member/api/views.py @@ -2,9 +2,10 @@ # SPDX-License-Identifier: GPL-3.0-or-later from rest_framework import viewsets +from rest_framework.filters import SearchFilter -from ..models import Profile, Club, Role, Membership from .serializers import ProfileSerializer, ClubSerializer, RoleSerializer, MembershipSerializer +from ..models import Profile, Club, Role, Membership class ProfileViewSet(viewsets.ModelViewSet): @@ -25,6 +26,8 @@ class ClubViewSet(viewsets.ModelViewSet): """ queryset = Club.objects.all() serializer_class = ClubSerializer + filter_backends = [SearchFilter] + search_fields = ['$name', ] class RoleViewSet(viewsets.ModelViewSet): @@ -35,6 +38,8 @@ class RoleViewSet(viewsets.ModelViewSet): """ queryset = Role.objects.all() serializer_class = RoleSerializer + filter_backends = [SearchFilter] + search_fields = ['$name', ] class MembershipViewSet(viewsets.ModelViewSet): diff --git a/apps/member/filters.py b/apps/member/filters.py index 418e52fc..951723e8 100644 --- a/apps/member/filters.py +++ b/apps/member/filters.py @@ -1,11 +1,11 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -from django_filters import FilterSet, CharFilter -from django.contrib.auth.models import User -from django.db.models import CharField from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout, Submit +from django.contrib.auth.models import User +from django.db.models import CharField +from django_filters import FilterSet, CharFilter class UserFilter(FilterSet): diff --git a/apps/member/forms.py b/apps/member/forms.py index abb35cd9..d2134cdd 100644 --- a/apps/member/forms.py +++ b/apps/member/forms.py @@ -1,23 +1,22 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +from crispy_forms.bootstrap import Div +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.models import User -from django import forms from .models import Profile, Club, Membership -from crispy_forms.helper import FormHelper -from crispy_forms.bootstrap import Div -from crispy_forms.layout import Layout - class SignUpForm(UserCreationForm): - def __init__(self,*args,**kwargs): - super().__init__(*args,**kwargs) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.fields['username'].widget.attrs.pop("autofocus", None) - self.fields['first_name'].widget.attrs.update({"autofocus":"autofocus"}) + self.fields['first_name'].widget.attrs.update({"autofocus": "autofocus"}) class Meta: model = User @@ -28,6 +27,7 @@ class ProfileForm(forms.ModelForm): """ A form for the extras field provided by the :model:`member.Profile` model. """ + class Meta: model = Profile fields = '__all__' @@ -42,7 +42,7 @@ class ClubForm(forms.ModelForm): class AddMembersForm(forms.Form): class Meta: - fields = ('', ) + fields = ('',) class MembershipForm(forms.ModelForm): @@ -54,13 +54,13 @@ class MembershipForm(forms.ModelForm): # et récupère les noms d'utilisateur valides widgets = { 'user': - autocomplete.ModelSelect2( - url='member:user_autocomplete', - attrs={ - 'data-placeholder': 'Nom ...', - 'data-minimum-input-length': 1, - }, - ), + autocomplete.ModelSelect2( + url='member:user_autocomplete', + attrs={ + 'data-placeholder': 'Nom ...', + 'data-minimum-input-length': 1, + }, + ), } diff --git a/apps/member/models.py b/apps/member/models.py index 1ca82af0..24e58830 100644 --- a/apps/member/models.py +++ b/apps/member/models.py @@ -5,8 +5,8 @@ import datetime from django.conf import settings from django.db import models -from django.utils.translation import gettext_lazy as _ from django.urls import reverse, reverse_lazy +from django.utils.translation import gettext_lazy as _ class Profile(models.Model): @@ -48,9 +48,10 @@ 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, )) + return reverse('user_detail', args=(self.pk,)) @@ -100,7 +101,7 @@ class Club(models.Model): return self.name def get_absolute_url(self): - return reverse_lazy('member:club_detail', args=(self.pk, )) + return reverse_lazy('member:club_detail', args=(self.pk,)) class Role(models.Model): @@ -161,7 +162,7 @@ class Membership(models.Model): class Meta: verbose_name = _('membership') verbose_name_plural = _('memberships') - + indexes = [models.Index(fields=['user'])] class RolePermissions(models.Model): """ diff --git a/apps/member/signals.py b/apps/member/signals.py index b17b3ae8..2b03e3ce 100644 --- a/apps/member/signals.py +++ b/apps/member/signals.py @@ -1,6 +1,7 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later + def save_user_profile(instance, created, raw, **_kwargs): """ Hook to create and save a profile when an user is updated if it is not registered with the signup form diff --git a/apps/member/views.py b/apps/member/views.py index 870079cc..dacfde33 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -1,33 +1,33 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -from django.contrib.auth.mixins import LoginRequiredMixin -from django.shortcuts import redirect -from django.utils.translation import gettext_lazy as _ -from django.views.generic import CreateView, DetailView, UpdateView, TemplateView,DeleteView -from django.views.generic.edit import FormMixin -from django.contrib.auth.models import User -from django.contrib import messages -from django.urls import reverse_lazy -from django.http import HttpResponseRedirect -from django.db.models import Q -from django.core.exceptions import ValidationError -from django.conf import settings -from django_tables2.views import SingleTableView -from rest_framework.authtoken.models import Token -from dal import autocomplete -from PIL import Image import io +from PIL import Image +from dal import autocomplete +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.core.exceptions import ValidationError +from django.db.models import Q +from django.http import HttpResponseRedirect +from django.shortcuts import redirect +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ +from django.views.generic import CreateView, DetailView, UpdateView, TemplateView, DeleteView +from django.views.generic.edit import FormMixin +from django_tables2.views import SingleTableView +from rest_framework.authtoken.models import Token +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 note.forms import AliasForm, ImageForm -from .models import Profile, Club, Membership -from .forms import SignUpForm, ProfileForm, ClubForm, MembershipForm, MemberFormSet, FormSetHelper -from .tables import ClubTable, UserTable from .filters import UserFilter, UserFilterFormHelper +from .forms import SignUpForm, ProfileForm, ClubForm, MembershipForm, MemberFormSet, FormSetHelper +from .models import Club, Membership +from .tables import ClubTable, UserTable class UserCreateView(CreateView): @@ -49,10 +49,10 @@ class UserCreateView(CreateView): def form_valid(self, form): profile_form = ProfileForm(self.request.POST) if form.is_valid() and profile_form.is_valid(): - user = form.save() - profile = profile_form.save(commit=False) - profile.user = user - profile.save() + user = form.save(commit=False) + user.profile = profile_form.save(commit=False) + user.save() + user.profile.save() return super().form_valid(form) @@ -109,7 +109,7 @@ class UserUpdateView(LoginRequiredMixin, UpdateView): return reverse_lazy('member:user_detail', kwargs={'pk': kwargs['id']}) else: - return reverse_lazy('member:user_detail', args=(self.object.id, )) + return reverse_lazy('member:user_detail', args=(self.object.id,)) class UserDetailView(LoginRequiredMixin, DetailView): @@ -124,7 +124,7 @@ class UserDetailView(LoginRequiredMixin, DetailView): 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") @@ -157,13 +157,14 @@ class UserListView(LoginRequiredMixin, SingleTableView): context["filter"] = self.filter return context -class AliasView(LoginRequiredMixin,FormMixin,DetailView): + +class AliasView(LoginRequiredMixin, FormMixin, DetailView): model = User template_name = 'member/profile_alias.html' context_object_name = 'user_object' form_class = AliasForm - def get_context_data(self,**kwargs): + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) note = context['user_object'].note context["aliases"] = AliasTable(note.alias_set.all()) @@ -172,7 +173,7 @@ class AliasView(LoginRequiredMixin,FormMixin,DetailView): def get_success_url(self): return reverse_lazy('member:user_alias', kwargs={'pk': self.object.id}) - def post(self,request,*args,**kwargs): + def post(self, request, *args, **kwargs): self.object = self.get_object() form = self.get_form() if form.is_valid(): @@ -186,42 +187,45 @@ class AliasView(LoginRequiredMixin,FormMixin,DetailView): alias.save() return super().form_valid(form) + class DeleteAliasView(LoginRequiredMixin, DeleteView): model = Alias - def delete(self,request,*args,**kwargs): + def delete(self, request, *args, **kwargs): try: self.object = self.get_object() self.object.delete() except ValidationError as e: # TODO: pass message to redirected view. - messages.error(self.request,str(e)) + messages.error(self.request, str(e)) else: - messages.success(self.request,_("Alias successfully deleted")) + messages.success(self.request, _("Alias successfully deleted")) 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}) + return reverse_lazy('member:user_alias', kwargs={'pk': self.object.note.user.pk}) def get(self, request, *args, **kwargs): return self.post(request, *args, **kwargs) + class ProfilePictureUpdateView(LoginRequiredMixin, FormMixin, DetailView): model = User template_name = 'member/profile_picture_update.html' context_object_name = 'user_object' form_class = ImageForm - def get_context_data(self,*args,**kwargs): - context = super().get_context_data(*args,**kwargs) - context['form'] = self.form_class(self.request.POST,self.request.FILES) + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + context['form'] = self.form_class(self.request.POST, self.request.FILES) return context - + def get_success_url(self): return reverse_lazy('member:user_detail', kwargs={'pk': self.object.id}) - def post(self,request,*args,**kwargs): - form = self.get_form() + def post(self, request, *args, **kwargs): + form = self.get_form() self.object = self.get_object() if form.is_valid(): return self.form_valid(form) @@ -230,7 +234,7 @@ class ProfilePictureUpdateView(LoginRequiredMixin, FormMixin, DetailView): print(form) return self.form_invalid(form) - def form_valid(self,form): + def form_valid(self, form): image_field = form.cleaned_data['image'] x = form.cleaned_data['x'] y = form.cleaned_data['y'] @@ -238,23 +242,24 @@ class ProfilePictureUpdateView(LoginRequiredMixin, FormMixin, DetailView): h = form.cleaned_data['height'] # image crop and resize image_file = io.BytesIO(image_field.read()) - ext = image_field.name.split('.')[-1] + # ext = image_field.name.split('.')[-1].lower() + # TODO: support GIF format image = Image.open(image_file) - image = image.crop((x, y, x+w, y+h)) + image = image.crop((x, y, x + w, y + h)) image_clean = image.resize((settings.PIC_WIDTH, - settings.PIC_RATIO*settings.PIC_WIDTH), - Image.ANTIALIAS) + settings.PIC_RATIO * settings.PIC_WIDTH), + Image.ANTIALIAS) image_file = io.BytesIO() - image_clean.save(image_file,ext) + image_clean.save(image_file, "PNG") image_field.file = image_file # renaming - filename = "{}_pic.{}".format(self.object.note.pk, ext) + filename = "{}_pic.png".format(self.object.note.pk) image_field.name = filename self.object.note.display_image = image_field self.object.note.save() return super().form_valid(form) - + class ManageAuthTokens(LoginRequiredMixin, TemplateView): """ Affiche le jeton d'authentification, et permet de le regénérer @@ -282,6 +287,7 @@ class UserAutocomplete(autocomplete.Select2QuerySetView): """ Auto complete users by usernames """ + def get_queryset(self): """ Quand une personne cherche un utilisateur par pseudo, une requête est envoyée sur l'API dédiée à l'auto-complétion. @@ -294,7 +300,7 @@ class UserAutocomplete(autocomplete.Select2QuerySetView): qs = User.objects.all() if self.q: - qs = qs.filter(username__regex=self.q) + qs = qs.filter(username__regex="^" + self.q) return qs @@ -330,7 +336,7 @@ class ClubDetailView(LoginRequiredMixin, DetailView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) club = context["club"] - club_transactions = \ + club_transactions = \ Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note)) context['history_list'] = HistoryTable(club_transactions) club_member = \ diff --git a/apps/note/admin.py b/apps/note/admin.py index 52c1cc17..a0928641 100644 --- a/apps/note/admin.py +++ b/apps/note/admin.py @@ -47,11 +47,11 @@ class NoteClubAdmin(PolymorphicChildModelAdmin): """ Child for a club note, see NoteAdmin """ - inlines = (AliasInlines, ) + inlines = (AliasInlines,) # We can't change club after creation or the balance readonly_fields = ('club', 'balance') - search_fields = ('club', ) + search_fields = ('club',) def has_add_permission(self, request): """ @@ -71,7 +71,7 @@ class NoteSpecialAdmin(PolymorphicChildModelAdmin): """ Child for a special note, see NoteAdmin """ - readonly_fields = ('balance', ) + readonly_fields = ('balance',) @admin.register(NoteUser) @@ -79,7 +79,7 @@ class NoteUserAdmin(PolymorphicChildModelAdmin): """ Child for an user note, see NoteAdmin """ - inlines = (AliasInlines, ) + inlines = (AliasInlines,) # We can't change user after creation or the balance readonly_fields = ('user', 'balance') @@ -133,7 +133,7 @@ class TransactionAdmin(PolymorphicParentModelAdmin): Else the amount of money would not be transferred """ if obj: # user is editing an existing object - return 'created_at', 'source', 'destination', 'quantity',\ + return 'created_at', 'source', 'destination', 'quantity', \ 'amount' return [] @@ -143,9 +143,9 @@ class TransactionTemplateAdmin(admin.ModelAdmin): """ Admin customisation for TransactionTemplate """ - list_display = ('name', 'poly_destination', 'amount', 'category', 'display', ) + list_display = ('name', 'poly_destination', 'amount', 'category', 'display',) list_filter = ('category', 'display') - autocomplete_fields = ('destination', ) + autocomplete_fields = ('destination',) def poly_destination(self, obj): """ @@ -161,5 +161,5 @@ class TemplateCategoryAdmin(admin.ModelAdmin): """ Admin customisation for TransactionTemplate """ - list_display = ('name', ) - list_filter = ('name', ) + list_display = ('name',) + list_filter = ('name',) diff --git a/apps/note/api/serializers.py b/apps/note/api/serializers.py index db0e3531..85f500ed 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, \ + TemplateTransaction, SpecialTransaction class NoteSerializer(serializers.ModelSerializer): @@ -13,15 +14,10 @@ class NoteSerializer(serializers.ModelSerializer): REST API Serializer for Notes. The djangorestframework plugin will analyse the model `Note` and parse all fields in the API. """ + class Meta: model = Note fields = '__all__' - extra_kwargs = { - 'url': { - 'view_name': 'project-detail', - 'lookup_field': 'pk' - }, - } class NoteClubSerializer(serializers.ModelSerializer): @@ -29,40 +25,60 @@ 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__' + def get_name(self, obj): + return str(obj) + 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__' + def get_name(self, obj): + return str(obj) + 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__' + def get_name(self, obj): + return str(obj) + class AliasSerializer(serializers.ModelSerializer): """ REST API Serializer for Aliases. The djangorestframework plugin will analyse the model `Alias` and parse all fields in the API. """ + note = serializers.SerializerMethodField() + class Meta: model = Alias fields = '__all__' + def get_note(self, alias): + return NotePolymorphicSerializer().to_representation(alias.note) + class NotePolymorphicSerializer(PolymorphicSerializer): model_serializer_mapping = { @@ -73,11 +89,23 @@ class NotePolymorphicSerializer(PolymorphicSerializer): } +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): """ REST API Serializer for Transaction templates. The djangorestframework plugin will analyse the model `TransactionTemplate` and parse all fields in the API. """ + class Meta: model = TransactionTemplate fields = '__all__' @@ -88,16 +116,49 @@ class TransactionSerializer(serializers.ModelSerializer): REST API Serializer for Transactions. The djangorestframework plugin will analyse the model `Transaction` and parse all fields in the API. """ + class Meta: model = Transaction fields = '__all__' +class TemplateTransactionSerializer(serializers.ModelSerializer): + """ + REST API Serializer for Transactions. + The djangorestframework plugin will analyse the model `TemplateTransaction` and parse all fields in the API. + """ + + class Meta: + model = TemplateTransaction + fields = '__all__' + + class MembershipTransactionSerializer(serializers.ModelSerializer): """ REST API Serializer for Membership transactions. The djangorestframework plugin will analyse the model `MembershipTransaction` and parse all fields in the API. """ + 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, + TemplateTransaction: TemplateTransactionSerializer, + MembershipTransaction: MembershipTransactionSerializer, + SpecialTransaction: SpecialTransactionSerializer, + } 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 94b4a47a..29c79bd8 100644 --- a/apps/note/api/views.py +++ b/apps/note/api/views.py @@ -2,13 +2,15 @@ # SPDX-License-Identifier: GPL-3.0-or-later from django.db.models import Q +from django_filters.rest_framework import DjangoFilterBackend from rest_framework import viewsets +from rest_framework.filters import OrderingFilter, SearchFilter -from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias -from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction from .serializers import NoteSerializer, NotePolymorphicSerializer, NoteClubSerializer, NoteSpecialSerializer, \ NoteUserSerializer, AliasSerializer, \ - TransactionTemplateSerializer, TransactionSerializer, MembershipTransactionSerializer + TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer +from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias +from ..models.transactions import TransactionTemplate, Transaction, TemplateCategory class NoteViewSet(viewsets.ModelViewSet): @@ -59,6 +61,9 @@ 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): """ @@ -69,8 +74,8 @@ class NotePolymorphicViewSet(viewsets.ModelViewSet): 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.lower())) note_type = self.request.query_params.get("type", None) if note_type: @@ -80,12 +85,11 @@ class NotePolymorphicViewSet(viewsets.ModelViewSet): elif "club" in types: queryset = queryset.filter(polymorphic_ctype__model="noteclub") elif "special" in types: - queryset = queryset.filter( - polymorphic_ctype__model="notespecial") + queryset = queryset.filter(polymorphic_ctype__model="notespecial") else: queryset = queryset.none() - return queryset + return queryset.distinct() class AliasViewSet(viewsets.ModelViewSet): @@ -96,6 +100,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): """ @@ -107,7 +114,7 @@ class AliasViewSet(viewsets.ModelViewSet): alias = self.request.query_params.get("alias", ".*") queryset = queryset.filter( - Q(name__regex=alias) | Q(normalized_name__regex=alias.lower())) + Q(name__regex="^" + alias) | Q(normalized_name__regex="^" + alias.lower())) note_id = self.request.query_params.get("note", None) if note_id: @@ -131,6 +138,18 @@ class AliasViewSet(viewsets.ModelViewSet): return queryset +class TemplateCategoryViewSet(viewsets.ModelViewSet): + """ + 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(viewsets.ModelViewSet): """ REST API View set. @@ -139,6 +158,8 @@ class TransactionTemplateViewSet(viewsets.ModelViewSet): """ queryset = TransactionTemplate.objects.all() serializer_class = TransactionTemplateSerializer + filter_backends = [DjangoFilterBackend] + filterset_fields = ['name', 'amount', 'display', 'category', ] class TransactionViewSet(viewsets.ModelViewSet): @@ -148,14 +169,6 @@ class TransactionViewSet(viewsets.ModelViewSet): 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 c0e92bda..3654fa2f 100644 --- a/apps/note/fixtures/initial.json +++ b/apps/note/fixtures/initial.json @@ -3,7 +3,7 @@ "model": "note.note", "pk": 1, "fields": { - "polymorphic_ctype": 22, + "polymorphic_ctype": 40, "balance": 0, "is_active": true, "display_image": "", @@ -14,7 +14,7 @@ "model": "note.note", "pk": 2, "fields": { - "polymorphic_ctype": 22, + "polymorphic_ctype": 40, "balance": 0, "is_active": true, "display_image": "", @@ -25,7 +25,7 @@ "model": "note.note", "pk": 3, "fields": { - "polymorphic_ctype": 22, + "polymorphic_ctype": 40, "balance": 0, "is_active": true, "display_image": "", @@ -36,7 +36,7 @@ "model": "note.note", "pk": 4, "fields": { - "polymorphic_ctype": 22, + "polymorphic_ctype": 40, "balance": 0, "is_active": true, "display_image": "", @@ -47,7 +47,7 @@ "model": "note.note", "pk": 5, "fields": { - "polymorphic_ctype": 21, + "polymorphic_ctype": 39, "balance": 0, "is_active": true, "display_image": "", @@ -58,7 +58,7 @@ "model": "note.note", "pk": 6, "fields": { - "polymorphic_ctype": 21, + "polymorphic_ctype": 39, "balance": 0, "is_active": true, "display_image": "", diff --git a/apps/note/forms.py b/apps/note/forms.py index 819ed97a..ac6adaaf 100644 --- a/apps/note/forms.py +++ b/apps/note/forms.py @@ -3,31 +3,25 @@ from dal import autocomplete from django import forms -from django.conf import settings from django.utils.translation import gettext_lazy as _ -import os +from .models import Alias +from .models import TransactionTemplate -from crispy_forms.helper import FormHelper -from crispy_forms.bootstrap import Div -from crispy_forms.layout import Layout, HTML - -from .models import Transaction, TransactionTemplate, TemplateTransaction -from .models import Note, Alias class AliasForm(forms.ModelForm): class Meta: model = Alias fields = ("name",) - def __init__(self,*args,**kwargs): - super().__init__(*args,**kwargs) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.fields["name"].label = False - self.fields["name"].widget.attrs={"placeholder":_('New Alias')} - + self.fields["name"].widget.attrs = {"placeholder": _('New Alias')} + class ImageForm(forms.Form): - image = forms.ImageField(required = False, + image = forms.ImageField(required=False, label=_('select an image'), help_text=_('Maximal size: 2MB')) x = forms.FloatField(widget=forms.HiddenInput()) @@ -35,7 +29,7 @@ class ImageForm(forms.Form): width = forms.FloatField(widget=forms.HiddenInput()) height = forms.FloatField(widget=forms.HiddenInput()) - + class TransactionTemplateForm(forms.ModelForm): class Meta: model = TransactionTemplate @@ -48,92 +42,11 @@ class TransactionTemplateForm(forms.ModelForm): # forward=(forward.Const('TYPE', 'note_type') où TYPE est dans {user, club, special} widgets = { 'destination': - autocomplete.ModelSelect2( - url='note:note_autocomplete', - attrs={ - 'data-placeholder': 'Note ...', - 'data-minimum-input-length': 1, - }, - ), - } - - -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 not "source" 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.name = button.name - 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, - }, - ), + autocomplete.ModelSelect2( + url='note:note_autocomplete', + attrs={ + 'data-placeholder': 'Note ...', + 'data-minimum-input-length': 1, + }, + ), } diff --git a/apps/note/models/notes.py b/apps/note/models/notes.py index 4b06c93a..b6b00aa8 100644 --- a/apps/note/models/notes.py +++ b/apps/note/models/notes.py @@ -9,6 +9,7 @@ from django.core.validators import RegexValidator from django.db import models from django.utils.translation import gettext_lazy as _ from polymorphic.models import PolymorphicModel + """ Defines each note types """ @@ -27,7 +28,7 @@ class Note(PolymorphicModel): help_text=_('in centimes, money credited for this instance'), default=0, ) - last_negative= models.DateTimeField( + last_negative = models.DateTimeField( verbose_name=_('last negative date'), help_text=_('last time the balance was negative'), null=True, @@ -98,7 +99,7 @@ class Note(PolymorphicModel): # Alias exists, so check if it is linked to this note if aliases.first().note != self: raise ValidationError(_('This alias is already taken.'), - code="same_alias",) + code="same_alias", ) else: # Alias does not exist yet, so check if it can exist a = Alias(name=str(self)) @@ -208,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 @@ -230,13 +235,13 @@ 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)), - code="same_alias" - ) + raise ValidationError(_('An alias with a similar name already exists: {} ').format(sim_alias), + code="same_alias" + ) except Alias.DoesNotExist: pass self.normalized_name = normalized_name - + def delete(self, using=None, keep_parents=False): if self.name == str(self.note): raise ValidationError(_("You can't delete your main alias."), diff --git a/apps/note/models/transactions.py b/apps/note/models/transactions.py index 598c119b..86c00737 100644 --- a/apps/note/models/transactions.py +++ b/apps/note/models/transactions.py @@ -2,12 +2,12 @@ # SPDX-License-Identifier: GPL-3.0-or-later from django.db import models +from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from django.urls import reverse from polymorphic.models import PolymorphicModel -from .notes import Note, NoteClub +from .notes import Note, NoteClub, NoteSpecial """ Defines transactions @@ -44,7 +44,7 @@ class TransactionTemplate(models.Model): verbose_name=_('name'), max_length=255, unique=True, - error_messages={'unique':_("A template with this name already exist")}, + error_messages={'unique': _("A template with this name already exist")}, ) destination = models.ForeignKey( NoteClub, @@ -63,11 +63,12 @@ class TransactionTemplate(models.Model): max_length=31, ) display = models.BooleanField( - default = True, + default=True, ) description = models.CharField( verbose_name=_('description'), max_length=255, + blank=True, ) class Meta: @@ -75,7 +76,7 @@ class TransactionTemplate(models.Model): verbose_name_plural = _("transaction templates") def get_absolute_url(self): - return reverse('note:template_update', args=(self.pk, )) + return reverse('note:template_update', args=(self.pk,)) class Transaction(PolymorphicModel): @@ -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 @@ -151,11 +161,14 @@ class Transaction(PolymorphicModel): def total(self): return self.amount * self.quantity + @property + def type(self): + return _('Transfer') + class TemplateTransaction(Transaction): """ Special type of :model:`note.Transaction` associated to a :model:`note.TransactionTemplate`. - """ template = models.ForeignKey( @@ -168,6 +181,37 @@ 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): """ Special type of :model:`note.Transaction` associated to a :model:`member.Membership`. @@ -183,3 +227,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 20476cb6..b9dac051 100644 --- a/apps/note/tables.py +++ b/apps/note/tables.py @@ -1,45 +1,77 @@ # 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 .models.transactions import Transaction +from django.utils.translation import gettext_lazy as _ + from .models.notes import Alias +from .models.transactions import Transaction +from .templatetags.pretty_money import pretty_money + class HistoryTable(tables.Table): class Meta: attrs = { 'class': - 'table table-condensed table-striped table-hover' + 'table table-condensed table-striped table-hover' } model = Transaction + 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) + + 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: attrs = { 'class': - 'table table condensed table-striped table-hover' + 'table table condensed table-striped table-hover' } model = Alias - fields =('name',) + fields = ('name',) template_name = 'django_tables2/bootstrap4.html' show_header = False - name = tables.Column(attrs={'td':{'class':'text-center'}}) + name = tables.Column(attrs={'td': {'class': 'text-center'}}) delete = tables.LinkColumn('member:user_alias_delete', args=[A('pk')], attrs={ - 'td': {'class':'col-sm-2'}, - 'a': {'class': 'btn btn-danger'} }, - text='delete',accessor='pk') + 'td': {'class': 'col-sm-2'}, + 'a': {'class': 'btn btn-danger'}}, + text='delete', accessor='pk') 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 5038df16..31a79be7 100644 --- a/apps/note/views.py +++ b/apps/note/views.py @@ -3,56 +3,49 @@ 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 .models import Transaction, TransactionTemplate, Alias, TemplateTransaction -from .forms import TransactionForm, TransactionTemplateForm, ConsoForm +from .forms import TransactionTemplateForm +from .models import Transaction, TransactionTemplate, Alias, TemplateTransaction, NoteSpecial +from .models.transactions import SpecialTransaction +from .tables import HistoryTable -class TransactionCreate(LoginRequiredMixin, CreateView): +class TransactionCreate(LoginRequiredMixin, SingleTableView): """ Show transfer page TODO: If user have sufficient rights, they can transfer from an other note """ - model = Transaction - form_class = TransactionForm + queryset = Transaction.objects.order_by("-id").all()[:50] + template_name = "note/transaction_form.html" + + # Transaction history table + table_class = HistoryTable + table_pagination = {"per_page": 50} def get_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): """ Auto complete note by aliases """ + def get_queryset(self): """ Quand une personne cherche un alias, une requête est envoyée sur l'API dédiée à l'auto-complétion. @@ -66,7 +59,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) @@ -120,31 +113,31 @@ class TransactionTemplateUpdateView(LoginRequiredMixin, UpdateView): form_class = TransactionTemplateForm -class ConsoView(LoginRequiredMixin, CreateView): +class ConsoView(LoginRequiredMixin, SingleTableView): """ Consume """ - model = TemplateTransaction + queryset = Transaction.objects.order_by("-id").all()[:50] template_name = "note/conso_form.html" - form_class = ConsoForm + + # Transaction history table + table_class = HistoryTable + table_pagination = {"per_page": 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(display=True) \ + .annotate(clicks=Count('templatetransaction')).order_by('category__name', 'name') + context['transaction_templates'] = buttons + context['most_used'] = buttons.order_by('-clicks', 'name')[:10] + context['title'] = _("Consumptions") + context['polymorphic_ctype'] = ContentType.objects.get_for_model(TemplateTransaction).pk # 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/entrypoint.sh b/entrypoint.sh index f05e962a..e5a22a5a 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 386db34c..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-02-27 17:39+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:184 apps/note/models/transactions.py:24 -#: apps/note/models/transactions.py:44 templates/member/profile_detail.html:11 +#: 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:160 -#: 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,43 +87,59 @@ msgstr "" msgid "API" msgstr "" -#: apps/logs/apps.py:10 +#: apps/logs/apps.py:11 msgid "Logs" msgstr "" -#: apps/logs/models.py:20 apps/note/models/notes.py:105 +#: apps/logs/models.py:21 apps/note/models/notes.py:117 msgid "user" msgstr "" #: apps/logs/models.py:27 +msgid "IP Address" +msgstr "" + +#: apps/logs/models.py:35 msgid "model" msgstr "" -#: apps/logs/models.py:34 +#: apps/logs/models.py:42 msgid "identifier" msgstr "" -#: apps/logs/models.py:39 +#: apps/logs/models.py:47 msgid "previous data" msgstr "" -#: apps/logs/models.py:44 +#: apps/logs/models.py:52 msgid "new data" msgstr "" -#: apps/logs/models.py:51 +#: 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:59 +#: apps/logs/models.py:73 msgid "timestamp" msgstr "" -#: apps/logs/models.py:63 +#: apps/logs/models.py:77 msgid "Logs cannot be destroyed." msgstr "" -#: apps/member/apps.py:10 +#: apps/member/apps.py:14 msgid "member" msgstr "" @@ -130,7 +147,7 @@ msgstr "" msgid "phone number" msgstr "" -#: apps/member/models.py:29 templates/member/profile_detail.html:24 +#: apps/member/models.py:29 templates/member/profile_detail.html:28 msgid "section" msgstr "" @@ -138,7 +155,7 @@ msgstr "" msgid "e.g. \"1A0\", \"9A♥\", \"SAPHIRE\"" msgstr "" -#: apps/member/models.py:36 templates/member/profile_detail.html:27 +#: apps/member/models.py:36 templates/member/profile_detail.html:31 msgid "address" msgstr "" @@ -150,199 +167,207 @@ 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:135 +#: 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 "" -#: apps/member/views.py:63 templates/member/profile_detail.html:42 +#: apps/member/views.py:69 templates/member/profile_detail.html:46 msgid "Update Profile" msgstr "" -#: apps/member/views.py:79 +#: apps/member/views.py:82 msgid "An alias with a similar name already exists." msgstr "" -#: apps/member/views.py:130 +#: apps/member/views.py:132 #, python-format msgid "Account #%(id)s: %(username)s" msgstr "" -#: apps/note/admin.py:120 apps/note/models/transactions.py:93 +#: apps/member/views.py:202 +msgid "Alias successfully deleted" +msgstr "" + +#: 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:54 +#: apps/note/apps.py:14 apps/note/models/notes.py:58 msgid "note" msgstr "" -#: apps/note/forms.py:49 -msgid "Source and destination must be different." +#: apps/note/forms.py:20 +msgid "New Alias" msgstr "" -#: apps/note/models/notes.py:26 -msgid "account balance" +#: apps/note/forms.py:25 +msgid "select an image" +msgstr "" + +#: apps/note/forms.py:26 +msgid "Maximal size: 2MB" msgstr "" #: apps/note/models/notes.py:27 +msgid "account balance" +msgstr "" + +#: 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:49 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:55 +#: apps/note/models/notes.py:59 msgid "notes" msgstr "" -#: apps/note/models/notes.py:63 +#: apps/note/models/notes.py:67 msgid "Note" msgstr "" -#: apps/note/models/notes.py:73 apps/note/models/notes.py:97 +#: apps/note/models/notes.py:77 apps/note/models/notes.py:101 msgid "This alias is already taken." msgstr "" -#: apps/note/models/notes.py:113 -msgid "user" -msgstr "" - -#: apps/note/models/notes.py:117 +#: apps/note/models/notes.py:121 msgid "one's note" msgstr "" -#: apps/note/models/notes.py:118 +#: apps/note/models/notes.py:122 msgid "users note" msgstr "" -#: apps/note/models/notes.py:124 +#: apps/note/models/notes.py:128 #, python-format msgid "%(user)s's note" msgstr "" -#: apps/note/models/notes.py:139 +#: apps/note/models/notes.py:143 msgid "club note" msgstr "" -#: apps/note/models/notes.py:140 +#: apps/note/models/notes.py:144 msgid "clubs notes" msgstr "" -#: apps/note/models/notes.py:146 +#: apps/note/models/notes.py:150 #, python-format msgid "Note of %(club)s club" msgstr "" -#: apps/note/models/notes.py:166 +#: apps/note/models/notes.py:170 msgid "special note" msgstr "" -#: apps/note/models/notes.py:167 +#: apps/note/models/notes.py:171 msgid "special notes" msgstr "" -#: apps/note/models/notes.py:190 +#: apps/note/models/notes.py:194 msgid "Invalid alias" msgstr "" -#: apps/note/models/notes.py:206 +#: apps/note/models/notes.py:210 msgid "alias" msgstr "" -#: apps/note/models/notes.py:207 templates/member/profile_detail.html:33 +#: apps/note/models/notes.py:211 templates/member/profile_detail.html:37 msgid "aliases" msgstr "" @@ -351,10 +376,10 @@ msgid "Alias is too long." msgstr "" #: apps/note/models/notes.py:238 -msgid "An alias with a similar name already exists:" +msgid "An alias with a similar name already exists: {} " msgstr "" -#: apps/note/models/notes.py:246 +#: apps/note/models/notes.py:247 msgid "You can't delete your main alias." msgstr "" @@ -370,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 "" @@ -378,59 +403,96 @@ 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:155 -msgid "German" +#: 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 "English" +msgid "German" msgstr "" #: note_kfet/settings/base.py:157 +msgid "English" +msgstr "" + +#: note_kfet/settings/base.py:158 msgid "French" msgstr "" @@ -438,6 +500,78 @@ msgstr "" msgid "The ENS Paris-Saclay BDE note." msgstr "" +#: 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 "" + +#: templates/cas_server/base.html:43 +#, python-format +msgid "" +"A new version of the application is available. This instance runs " +"%(VERSION)s and the last version is %(LAST_VERSION)s. Please consider " +"upgrading." +msgstr "" + +#: templates/cas_server/logged.html:4 +msgid "" +"

Log In Successful

You have successfully logged into the Central " +"Authentication Service.
For security reasons, please Log Out and Exit " +"your web browser when you are done accessing services that require " +"authentication!" +msgstr "" + +#: templates/cas_server/logged.html:8 +msgid "Log me out from all my sessions" +msgstr "" + +#: templates/cas_server/logged.html:14 +msgid "Forget the identity provider" +msgstr "" + +#: templates/cas_server/logged.html:18 +msgid "Logout" +msgstr "" + +#: templates/cas_server/login.html:6 +msgid "Please log in" +msgstr "" + +#: templates/cas_server/login.html:11 +msgid "" +"If you don't have any Note Kfet account, please follow this link to sign up." +msgstr "" + +#: templates/cas_server/login.html:17 +msgid "Login" +msgstr "" + +#: templates/cas_server/warn.html:9 +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 "" @@ -450,10 +584,22 @@ msgstr "" msgid "Membership duration" msgstr "" -#: templates/member/club_detail.html:18 templates/member/profile_detail.html:30 +#: templates/member/club_detail.html:18 templates/member/profile_detail.html:34 msgid "balance" msgstr "" +#: templates/member/club_detail.html:51 templates/member/profile_detail.html:75 +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 "" @@ -466,27 +612,35 @@ msgstr "" msgid "Regenerate token" msgstr "" -#: templates/member/profile_detail.html:11 +#: templates/member/profile_alias.html:10 +msgid "Add alias" +msgstr "" + +#: templates/member/profile_detail.html:15 msgid "first name" msgstr "" -#: templates/member/profile_detail.html:14 +#: templates/member/profile_detail.html:18 msgid "username" msgstr "" -#: templates/member/profile_detail.html:17 +#: templates/member/profile_detail.html:21 msgid "password" msgstr "" -#: templates/member/profile_detail.html:20 +#: templates/member/profile_detail.html:24 msgid "Change password" msgstr "" -#: templates/member/profile_detail.html:38 +#: templates/member/profile_detail.html:42 msgid "Manage auth token" msgstr "" -#: templates/member/profile_detail.html:54 +#: templates/member/profile_detail.html:49 +msgid "View Profile" +msgstr "" + +#: templates/member/profile_detail.html:62 msgid "View my memberships" msgstr "" @@ -494,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 @@ -511,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 "" @@ -523,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 e7341740..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-02-27 17:39+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:184 apps/note/models/transactions.py:24 -#: apps/note/models/transactions.py:44 templates/member/profile_detail.html:11 +#: 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:160 -#: 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,47 +82,59 @@ msgstr "invités" msgid "API" msgstr "" -#: apps/logs/apps.py:10 +#: apps/logs/apps.py:11 msgid "Logs" msgstr "" -#: apps/logs/models.py:20 apps/note/models/notes.py:105 +#: apps/logs/models.py:21 apps/note/models/notes.py:117 msgid "user" msgstr "utilisateur" #: apps/logs/models.py:27 +msgid "IP Address" +msgstr "Adresse IP" + +#: apps/logs/models.py:35 msgid "model" msgstr "Modèle" -#: apps/logs/models.py:34 +#: apps/logs/models.py:42 msgid "identifier" msgstr "Identifiant" -#: apps/logs/models.py:39 +#: apps/logs/models.py:47 msgid "previous data" msgstr "Données précédentes" -#: apps/logs/models.py:44 -#, fuzzy -#| msgid "end date" +#: apps/logs/models.py:52 msgid "new data" msgstr "Nouvelles données" -#: apps/logs/models.py:51 -#, 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:59 +#: apps/logs/models.py:73 msgid "timestamp" msgstr "Date" -#: apps/logs/models.py:63 +#: apps/logs/models.py:77 msgid "Logs cannot be destroyed." msgstr "Les logs ne peuvent pas être détruits." -#: apps/member/apps.py:10 +#: apps/member/apps.py:14 msgid "member" msgstr "adhérent" @@ -129,7 +142,7 @@ msgstr "adhérent" msgid "phone number" msgstr "numéro de téléphone" -#: apps/member/models.py:29 templates/member/profile_detail.html:24 +#: apps/member/models.py:29 templates/member/profile_detail.html:28 msgid "section" msgstr "section" @@ -137,7 +150,7 @@ msgstr "section" msgid "e.g. \"1A0\", \"9A♥\", \"SAPHIRE\"" msgstr "e.g. \"1A0\", \"9A♥\", \"SAPHIRE\"" -#: apps/member/models.py:36 templates/member/profile_detail.html:27 +#: apps/member/models.py:36 templates/member/profile_detail.html:31 msgid "address" msgstr "adresse" @@ -149,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." @@ -187,166 +200,174 @@ 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:135 +#: 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" -#: apps/member/views.py:63 templates/member/profile_detail.html:42 +#: apps/member/views.py:69 templates/member/profile_detail.html:46 msgid "Update Profile" msgstr "Modifier le profil" -#: apps/member/views.py:79 +#: apps/member/views.py:82 msgid "An alias with a similar name already exists." msgstr "Un alias avec un nom similaire existe déjà." -#: apps/member/views.py:130 +#: apps/member/views.py:132 #, python-format msgid "Account #%(id)s: %(username)s" msgstr "Compte n°%(id)s : %(username)s" -#: apps/note/admin.py:120 apps/note/models/transactions.py:93 +#: apps/member/views.py:202 +msgid "Alias successfully deleted" +msgstr "L'alias a bien été supprimé" + +#: 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:54 +#: apps/note/apps.py:14 apps/note/models/notes.py:58 msgid "note" msgstr "note" -#: apps/note/forms.py:49 -msgid "Source and destination must be different." -msgstr "La source et la destination doivent être différentes." +#: apps/note/forms.py:20 +msgid "New Alias" +msgstr "Nouvel alias" -#: apps/note/models/notes.py:26 +#: apps/note/forms.py:25 +msgid "select an image" +msgstr "Choisissez une image" + +#: apps/note/forms.py:26 +msgid "Maximal size: 2MB" +msgstr "Taille maximale : 2 Mo" + +#: 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:49 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:55 +#: apps/note/models/notes.py:59 msgid "notes" msgstr "notes" -#: apps/note/models/notes.py:63 +#: apps/note/models/notes.py:67 msgid "Note" msgstr "Note" -#: apps/note/models/notes.py:73 apps/note/models/notes.py:97 +#: 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:113 -msgid "user" -msgstr "utilisateur" - -#: apps/note/models/notes.py:117 +#: apps/note/models/notes.py:121 msgid "one's note" msgstr "note d'un utilisateur" -#: apps/note/models/notes.py:118 +#: apps/note/models/notes.py:122 msgid "users note" msgstr "notes des utilisateurs" -#: apps/note/models/notes.py:124 +#: apps/note/models/notes.py:128 #, python-format msgid "%(user)s's note" msgstr "Note de %(user)s" -#: apps/note/models/notes.py:139 +#: apps/note/models/notes.py:143 msgid "club note" msgstr "note d'un club" -#: apps/note/models/notes.py:140 +#: apps/note/models/notes.py:144 msgid "clubs notes" msgstr "notes des clubs" -#: apps/note/models/notes.py:146 +#: 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:166 +#: apps/note/models/notes.py:170 msgid "special note" msgstr "note spéciale" -#: apps/note/models/notes.py:167 +#: apps/note/models/notes.py:171 msgid "special notes" msgstr "notes spéciales" -#: apps/note/models/notes.py:190 +#: apps/note/models/notes.py:194 msgid "Invalid alias" msgstr "Alias invalide" -#: apps/note/models/notes.py:206 +#: apps/note/models/notes.py:210 msgid "alias" msgstr "alias" -#: apps/note/models/notes.py:207 templates/member/profile_detail.html:33 +#: apps/note/models/notes.py:211 templates/member/profile_detail.html:37 msgid "aliases" msgstr "alias" @@ -355,10 +376,10 @@ msgid "Alias is too long." msgstr "L'alias est trop long." #: 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à." +msgid "An alias with a similar name already exists: {} " +msgstr "Un alias avec un nom similaire existe déjà : {}" -#: apps/note/models/notes.py:246 +#: apps/note/models/notes.py:247 msgid "You can't delete your main alias." msgstr "Vous ne pouvez pas supprimer votre alias principal." @@ -371,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" @@ -383,59 +403,96 @@ 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:155 -msgid "German" +#: 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 "English" +msgid "German" msgstr "" #: note_kfet/settings/base.py:157 +msgid "English" +msgstr "" + +#: note_kfet/settings/base.py:158 msgid "French" msgstr "" @@ -443,6 +500,80 @@ msgstr "" msgid "The ENS Paris-Saclay BDE note." msgstr "La note du BDE de l'ENS Paris-Saclay." +#: 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 "" + +#: templates/cas_server/base.html:43 +#, python-format +msgid "" +"A new version of the application is available. This instance runs " +"%(VERSION)s and the last version is %(LAST_VERSION)s. Please consider " +"upgrading." +msgstr "" + +#: templates/cas_server/logged.html:4 +msgid "" +"

Log In Successful

You have successfully logged into the Central " +"Authentication Service.
For security reasons, please Log Out and Exit " +"your web browser when you are done accessing services that require " +"authentication!" +msgstr "" + +#: templates/cas_server/logged.html:8 +msgid "Log me out from all my sessions" +msgstr "" + +#: templates/cas_server/logged.html:14 +msgid "Forget the identity provider" +msgstr "" + +#: templates/cas_server/logged.html:18 +msgid "Logout" +msgstr "" + +#: templates/cas_server/login.html:6 +msgid "Please log in" +msgstr "" + +#: templates/cas_server/login.html:11 +msgid "" +"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." + +#: templates/cas_server/login.html:17 +msgid "Login" +msgstr "" + +#: templates/cas_server/warn.html:9 +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" @@ -455,10 +586,22 @@ msgstr "L'adhésion finie le" msgid "Membership duration" msgstr "Durée de l'adhésion" -#: templates/member/club_detail.html:18 templates/member/profile_detail.html:30 +#: templates/member/club_detail.html:18 templates/member/profile_detail.html:34 msgid "balance" msgstr "solde du compte" +#: templates/member/club_detail.html:51 templates/member/profile_detail.html:75 +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" @@ -471,33 +614,35 @@ msgstr "Créé le" msgid "Regenerate token" msgstr "Regénérer le jeton" -#: templates/member/profile_detail.html:11 +#: templates/member/profile_alias.html:10 +msgid "Add alias" +msgstr "Ajouter un alias" + +#: templates/member/profile_detail.html:15 msgid "first name" msgstr "" -#: templates/member/profile_detail.html:14 +#: templates/member/profile_detail.html:18 msgid "username" msgstr "" -#: templates/member/profile_detail.html:17 -#, fuzzy -#| msgid "Change password" +#: templates/member/profile_detail.html:21 msgid "password" msgstr "" -#: templates/member/profile_detail.html:20 +#: templates/member/profile_detail.html:24 msgid "Change password" msgstr "Changer le mot de passe" -#: templates/member/profile_detail.html:38 +#: templates/member/profile_detail.html:42 msgid "Manage auth token" msgstr "Gérer les jetons d'authentification" -#: templates/member/profile_detail.html:51 -msgid "Transaction history" -msgstr "Historique des transactions" +#: templates/member/profile_detail.html:49 +msgid "View Profile" +msgstr "Voir le profil" -#: templates/member/profile_detail.html:54 +#: templates/member/profile_detail.html:62 msgid "View my memberships" msgstr "Voir mes adhésions" @@ -505,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." @@ -522,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 "" @@ -534,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/nginx_note.conf_example b/nginx_note.conf_example index 1f7ce4ca..204784d0 100644 --- a/nginx_note.conf_example +++ b/nginx_note.conf_example @@ -9,7 +9,7 @@ server { # the port your site will be served on listen 80; # the domain name it will serve for - server_name note.comby.xyz; # substitute your machine's IP address or FQDN + server_name note.example.org; # substitute your machine's IP address or FQDN charset utf-8; # max upload size diff --git a/note_kfet/fixtures/cas.json b/note_kfet/fixtures/cas.json new file mode 100644 index 00000000..c3109d19 --- /dev/null +++ b/note_kfet/fixtures/cas.json @@ -0,0 +1,11 @@ +[ + { + "model": "cas_server.servicepattern", + "pk": 1, + "fields": { + "pos": 1, + "pattern": ".*", + "name": "REPLACEME" + } + } +] diff --git a/note_kfet/fixtures/initial.json b/note_kfet/fixtures/initial.json index 1b779980..72e47234 100644 --- a/note_kfet/fixtures/initial.json +++ b/note_kfet/fixtures/initial.json @@ -6,14 +6,5 @@ "domain": "localhost", "name": "La Note Kfet \ud83c\udf7b" } - }, - { - "model": "cas_server.servicepattern", - "pk": 1, - "fields": { - "pos": 1, - "pattern": ".*", - "name": "REPLACEME" - } } -] \ No newline at end of file +] diff --git a/note_kfet/middlewares.py b/note_kfet/middlewares.py index 73b87e36..b034e2be 100644 --- a/note_kfet/middlewares.py +++ b/note_kfet/middlewares.py @@ -1,10 +1,6 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -from django.http import HttpResponseRedirect - -from urllib.parse import urlencode, parse_qs, urlsplit, urlunsplit - class TurbolinksMiddleware(object): """ @@ -35,4 +31,3 @@ class TurbolinksMiddleware(object): location = request.session.pop('_turbolinks_redirect_to') response['Turbolinks-Location'] = location return response - diff --git a/note_kfet/settings/__init__.py b/note_kfet/settings/__init__.py index 68a40b88..28935deb 100644 --- a/note_kfet/settings/__init__.py +++ b/note_kfet/settings/__init__.py @@ -1,8 +1,12 @@ -import os +# 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 * + def read_env(): """Pulled from Honcho code with minor updates, reads local default environment variables from a .env file located in the project root @@ -25,22 +29,53 @@ def read_env(): val = re.sub(r'\\(.)', r'\1', m3.group(1)) os.environ.setdefault(key, val) + 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.append(os.environ.get('ALLOWED_HOSTS','localhost')) else: from .development import * try: + #in secrets.py defines everything you want from .secrets import * except ImportError: pass -# env variables set at the of in /env/bin/activate -# don't forget to unset in deactivate ! +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": _( + u"The Central Authentication Service grants you access to most of our websites by " + u"authenticating only once, so you don't need to type your credentials again unless " + u"your session expires or you logout." + ), + "discardable": True, + "type": "info", # one of info, success, info, warning, danger + }, + } + CAS_INFO_MESSAGES_ORDER = [ + 'cas_explained', + ] + AUTHENTICATION_BACKENDS += ('cas.backends.CASBackend',) + + +if "logs" in INSTALLED_APPS: + MIDDLEWARE += ('logs.middlewares.LogsMiddleware',) + +if "debug_toolbar" in INSTALLED_APPS: + 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 20937fac..29ff49c5 100644 --- a/note_kfet/settings/base.py +++ b/note_kfet/settings/base.py @@ -37,9 +37,10 @@ INSTALLED_APPS = [ # External apps 'polymorphic', - 'reversion', 'crispy_forms', 'django_tables2', + 'cas_server', + 'cas', # Django contrib 'django.contrib.admin', 'django.contrib.admindocs', @@ -55,9 +56,6 @@ INSTALLED_APPS = [ # Autocomplete 'dal', 'dal_select2', - # CAS - 'cas_server', - 'cas', # Note apps 'activity', @@ -81,7 +79,6 @@ MIDDLEWARE = [ 'django.middleware.locale.LocaleMiddleware', 'django.contrib.sites.middleware.CurrentSiteMiddleware', 'note_kfet.middlewares.TurbolinksMiddleware', - 'cas.middleware.CASMiddleware', ] ROOT_URLCONF = 'note_kfet.urls' @@ -98,7 +95,7 @@ TEMPLATES = [ 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', 'django.template.context_processors.request', - # 'django.template.context_processors.media', + # 'django.template.context_processors.media', ], }, }, @@ -133,7 +130,7 @@ PASSWORD_HASHERS = [ # Django Guardian object permissions AUTHENTICATION_BACKENDS = ( - #'django.contrib.auth.backends.ModelBackend', # this is default + # 'django.contrib.auth.backends.ModelBackend', # this is default 'member.backends.PermissionBackend', 'cas.backends.CASBackend', ) @@ -146,12 +143,13 @@ REST_FRAMEWORK = { 'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly' ], 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.TokenAuthentication', - ] + ], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 20, } -ANONYMOUS_USER_NAME = None # Disable guardian anonymous user - # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ @@ -182,7 +180,7 @@ FIXTURE_DIRS = [os.path.join(BASE_DIR, "note_kfet/fixtures")] # Don't put anything in this directory yourself; store your static files # in apps' "static/" subdirectories and in STATICFILES_DIRS. # Example: "/var/www/example.com/static/" -STATIC_ROOT = os.path.join(BASE_DIR,"static/") +STATIC_ROOT = os.path.join(BASE_DIR, "static/") # STATICFILES_DIRS = [ # os.path.join(BASE_DIR, 'static')] STATICFILES_DIRS = [] @@ -194,15 +192,9 @@ STATIC_URL = '/static/' ALIAS_VALIDATOR_REGEX = r'' -MEDIA_ROOT=os.path.join(BASE_DIR,"media") -MEDIA_URL='/media/' +MEDIA_ROOT = os.path.join(BASE_DIR, "media") +MEDIA_URL = '/media/' # Profile Picture Settings PIC_WIDTH = 200 PIC_RATIO = 1 - -# CAS Settings -CAS_AUTO_CREATE_USER = False -CAS_LOGO_URL = "/static/img/Saperlistpopette.png" -CAS_FAVICON_URL = "/static/favicon/favicon-32x32.png" - diff --git a/note_kfet/settings/development.py b/note_kfet/settings/development.py index ad2cd2f1..66ad4fd4 100644 --- a/note_kfet/settings/development.py +++ b/note_kfet/settings/development.py @@ -11,17 +11,30 @@ # - and more ... +import os + # Database # https://docs.djangoproject.com/en/2.2/ref/settings/#databases from . import * -import os -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 @@ -38,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 @@ -51,4 +64,8 @@ SESSION_COOKIE_AGE = 60 * 60 * 3 # CAS Client settings # Can be modified in secrets.py -CAS_SERVER_URL = "https://note.comby.xyz/cas/" +CAS_SERVER_URL = "http://localhost:8000/cas/" + +STATIC_ROOT = '' # not needed in development settings +STATICFILES_DIRS = [ + os.path.join(BASE_DIR, 'static')] diff --git a/note_kfet/settings/production.py b/note_kfet/settings/production.py index 353d7b8a..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 = ['127.0.0.1','note.comby.xyz'] +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/apps/logs/urls.py b/note_kfet/settings/secrets_example.py similarity index 56% rename from apps/logs/urls.py rename to note_kfet/settings/secrets_example.py index 6d76674c..70d17ad4 100644 --- a/apps/logs/urls.py +++ b/note_kfet/settings/secrets_example.py @@ -1,8 +1,9 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -app_name = 'logs' - -# TODO User interface -urlpatterns = [ +# CAS +OPTIONAL_APPS = [ +# 'cas_server', +# 'cas', +# 'debug_toolbar' ] diff --git a/note_kfet/urls.py b/note_kfet/urls.py index a261a9eb..da2f9d6c 100644 --- a/note_kfet/urls.py +++ b/note_kfet/urls.py @@ -1,13 +1,11 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +from django.conf import settings +from django.conf.urls.static import static from django.contrib import admin from django.urls import path, include from django.views.generic import RedirectView -from django.conf.urls.static import static -from django.conf import settings - -from cas import views as cas_views urlpatterns = [ # Dev so redirect to something random @@ -16,25 +14,34 @@ urlpatterns = [ # Include project routers path('note/', include('note.urls')), - # Include CAS Client routers - path('accounts/login/', cas_views.login, name='login'), - path('accounts/logout/', cas_views.logout, name='logout'), - # 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), - - # Include CAS Server routers - path('cas/', include('cas_server.urls', namespace="cas_server")), - - # Include Django REST API path('api/', include('api.urls')), - - path('logs/', include('logs.urls')), ] -urlpatterns += static(settings.MEDIA_URL,document_root=settings.MEDIA_ROOT) -urlpatterns += static(settings.STATIC_URL,document_root=settings.STATIC_ROOT) +urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) +urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + + +if "cas_server" in settings.INSTALLED_APPS: + urlpatterns += [ + # Include CAS Server routers + path('cas/', include('cas_server.urls', namespace="cas_server")), + ] +if "cas" in settings.INSTALLED_APPS: + from cas import views as cas_views + urlpatterns += [ + # Include CAS Client routers + 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: + import debug_toolbar + urlpatterns = [ + path('__debug__/', include(debug_toolbar.urls)), + ] + urlpatterns diff --git a/requirements/api.txt b/requirements/api.txt new file mode 100644 index 00000000..8dd9f5f2 --- /dev/null +++ b/requirements/api.txt @@ -0,0 +1,3 @@ +djangorestframework==3.9.0 +django-rest-polymorphic==0.1.8 + diff --git a/requirements.txt b/requirements/base.txt similarity index 73% rename from requirements.txt rename to requirements/base.txt index 9a5eaa22..e9dc7635 100644 --- a/requirements.txt +++ b/requirements/base.txt @@ -4,18 +4,12 @@ defusedxml==0.6.0 Django~=2.2 django-allauth==0.39.1 django-autocomplete-light==3.5.1 -django-cas-client==1.5.3 -django-cas-server==1.1.0 django-crispy-forms==1.7.2 django-extensions==2.1.9 django-filter==2.2.0 django-polymorphic==2.0.3 -djangorestframework==3.9.0 -django-rest-polymorphic==0.1.8 -django-reversion==3.0.3 django-tables2==2.1.0 docutils==0.14 -psycopg2==2.8.4 idna==2.8 oauthlib==3.1.0 Pillow==6.1.0 diff --git a/requirements/cas.txt b/requirements/cas.txt new file mode 100644 index 00000000..d468d2d5 --- /dev/null +++ b/requirements/cas.txt @@ -0,0 +1,2 @@ +django-cas-client==1.5.3 +django-cas-server==1.1.0 diff --git a/requirements/production.txt b/requirements/production.txt new file mode 100644 index 00000000..f0b52228 --- /dev/null +++ b/requirements/production.txt @@ -0,0 +1 @@ +psycopg2==2.8.4 diff --git a/static/js/base.js b/static/js/base.js new file mode 100644 index 00000000..2362375b --- /dev/null +++ b/static/js/base.js @@ -0,0 +1,281 @@ +// 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) { + let img = note == null ? null : note.display_image; + if (img == null) + img = '/media/pic/default.png'; + if (note !== null && alias !== note.name) + alias += " (aka. " + note.name + ")"; + if (note !== null && user_note_field !== null) + $("#" + user_note_field).text(alias + " : " + 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; + aliases_matched_html += li(alias_prefix + "_" + alias.id, alias.name); + note.alias = alias; + 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(""); + // 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": "TemplateTransaction", + 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..5f7a314a --- /dev/null +++ b/static/js/consos.js @@ -0,0 +1,205 @@ +// 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 TemplateTransaction) + * @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(""); + displayNote(null, ""); + 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 TemplateTransaction) + * @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": "TemplateTransaction", + "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..a0c2d88a --- /dev/null +++ b/static/js/transfer.js @@ -0,0 +1,157 @@ +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(""); + 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() { + 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 6814bedf..e6193702 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 %} {% 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 %} @@ -67,23 +75,27 @@ 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..f320083e 100644 --- a/templates/note/transaction_form.html +++ b/templates/note/transaction_form.html @@ -3,35 +3,188 @@ SPDX-License-Identifier: GPL-2.0-or-later {% endcomment %} -{% load i18n static %} +{% load i18n static django_tables2 %} {% 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 %} + +
    +
    +
    + + + + +
    +
    +
    + +
    + + +
    +
    + +
    + +
    +
    +
    + +
    - -
    +
    +
    + +
    +
    +
    +

    + {% 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/note/transactiontemplate_list.html b/templates/note/transactiontemplate_list.html index 62e4d164..49600236 100644 --- a/templates/note/transactiontemplate_list.html +++ b/templates/note/transactiontemplate_list.html @@ -15,7 +15,7 @@ {{ object.name }} {{ object.destination }} {{ object.amount | pretty_money }} - {{ object.template_type }} + {{ object.category }} {% endfor %} diff --git a/templates/registration/login.html b/templates/registration/login.html index 04ef8d7d..5a4322d1 100644 --- a/templates/registration/login.html +++ b/templates/registration/login.html @@ -17,6 +17,10 @@ SPDX-License-Identifier: GPL-2.0-or-later

    {% endif %} +
    + Vous pouvez aussi vous connecter via l'authentification centralisée en suivant ce lien. +
    + {% csrf_token %} {{ form | crispy }} diff --git a/tox.ini b/tox.ini index c4e88c78..2217b6bf 100644 --- a/tox.ini +++ b/tox.ini @@ -9,7 +9,10 @@ skipsdist = True setenv = PYTHONWARNINGS = all deps = - -r{toxinidir}/requirements.txt + -r{toxinidir}/requirements/base.txt + -r{toxinidir}/requirements/api.txt + -r{toxinidir}/requirements/cas.txt + -r{toxinidir}/requirements/production.txt coverage commands = ./manage.py makemigrations @@ -18,7 +21,10 @@ commands = [testenv:linters] deps = - -r{toxinidir}/requirements.txt + -r{toxinidir}/requirements/base.txt + -r{toxinidir}/requirements/api.txt + -r{toxinidir}/requirements/cas.txt + -r{toxinidir}/requirements/production.txt flake8 flake8-colors flake8-import-order @@ -26,7 +32,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