From 51d5733578b02018ca4e54f338458aa902561039 Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Fri, 30 Oct 2020 16:58:49 +0100 Subject: [PATCH 01/28] less hardcoded ansible config --- .gitignore | 5 +++++ README.md | 20 +++++++++++++++++++- ansible/{hosts => hosts_example} | 0 3 files changed, 24 insertions(+), 1 deletion(-) rename ansible/{hosts => hosts_example} (100%) diff --git a/.gitignore b/.gitignore index f541ab85..affc851f 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,8 @@ backups/ env/ venv/ db.sqlite3 + +# ansibles customs host +ansible/host_vars/*.yaml +!ansible/host_vars/bde* +ansible/hosts diff --git a/README.md b/README.md index d9c436ec..6fed71e4 100644 --- a/README.md +++ b/README.md @@ -69,13 +69,31 @@ accessible depuis l'ensemble de votre réseau, pratique pour tester le rendu de la note sur un téléphone ! ## Installation d'une instance de production +Pour déployer facilement la note il est possible d'utiliser le playbook Ansible (sinon vous pouvez toujours le faire a la main, voir plus bas). +### Avec ansible +Il vous faudra un serveur sous debian ou ubuntu connecté à internet et que vous souhaiterez accéder à cette instance de la note sur `note.nomdedomaine.tld`. + +0. Installer Ansible sur votre machine personnelle. + +0. (bis) cloner le dépot sur votre machine personelle. + +1. Copier le fichier `ansible/host_example` +``` bash +$ cp ansible/hosts_example ansible/hosts +``` +et ajouter sous [dev] et/ou [prod] les serveurs sur lesquels vous souhaitez installer la note. +2. Créer un fichier `ansible/host_vars/` sur le modèle des fichiers existants dans `ansible/hosts` et compléter les variables nécessaires. + +3. lancer `ansible/base.yaml -l ` +4. Aller vous faire un café, ca peux durer un moment. + +### Installation manuelle **En production on souhaite absolument utiliser les modules Python packagées dans le gestionnaire de paquet.** Cela permet de mettre à jour facilement les dépendances critiques telles que Django. L'installation d'une instance de production néccessite **une installation de Debian Buster ou d'Ubuntu 20.04**. -Pour aller vite vous pouvez lancer le Playbook Ansible fournit dans ce dépôt en l'adaptant. Sinon vous pouvez suivre les étapes décrites ci-dessous. 0. Sous Debian Buster, **activer Debian Backports.** En effet Django 2.2 LTS n'est que disponible dans les backports. diff --git a/ansible/hosts b/ansible/hosts_example similarity index 100% rename from ansible/hosts rename to ansible/hosts_example From 78fe070cd35fc3391c1846a85fb19333ad9aa056 Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Fri, 30 Oct 2020 16:59:44 +0100 Subject: [PATCH 02/28] use debian backport only with debian --- ansible/roles/1-apt-basic/tasks/main.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ansible/roles/1-apt-basic/tasks/main.yml b/ansible/roles/1-apt-basic/tasks/main.yml index 9c01dd97..7c57646f 100644 --- a/ansible/roles/1-apt-basic/tasks/main.yml +++ b/ansible/roles/1-apt-basic/tasks/main.yml @@ -3,11 +3,12 @@ apt_repository: repo: deb http://{{ mirror }}/debian buster-backports main state: present + when: ansible_facts['distribution'] == "Debian" - name: Install note_kfet APT dependencies apt: update_cache: true - default_release: buster-backports + default_release: "{{ 'buster-backports' if ansible_facts['distribution'] == 'Debian' }}" install_recommends: false name: # Common tools From 950922d0418288809cc5a9a65f690d72ba2fbe0e Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Fri, 30 Oct 2020 17:01:26 +0100 Subject: [PATCH 03/28] do not hardcode mail --- ansible/host_vars/bde-nk20-beta.adh.crans.org.yml | 1 + ansible/host_vars/bde-note.adh.crans.org.yml | 4 ++-- ansible/host_vars/bde3-virt.adh.crans.org.yml | 1 + .../roles/4-certbot/templates/letsencrypt/conf.d/nk20.ini.j2 | 4 ++-- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/ansible/host_vars/bde-nk20-beta.adh.crans.org.yml b/ansible/host_vars/bde-nk20-beta.adh.crans.org.yml index d4ef70ef..d9d850da 100644 --- a/ansible/host_vars/bde-nk20-beta.adh.crans.org.yml +++ b/ansible/host_vars/bde-nk20-beta.adh.crans.org.yml @@ -3,3 +3,4 @@ note: server_name: note-beta.crans.org git_branch: beta cron_enabled: false + email: notekfet2020@lists.crans.org diff --git a/ansible/host_vars/bde-note.adh.crans.org.yml b/ansible/host_vars/bde-note.adh.crans.org.yml index ba085433..f6e4ff97 100644 --- a/ansible/host_vars/bde-note.adh.crans.org.yml +++ b/ansible/host_vars/bde-note.adh.crans.org.yml @@ -1,5 +1,5 @@ --- note: server_name: note.crans.org - git_branch: master - cron_enabled: true + git_branch: beta + cron_enabled: false diff --git a/ansible/host_vars/bde3-virt.adh.crans.org.yml b/ansible/host_vars/bde3-virt.adh.crans.org.yml index 477a4b7a..471f35f0 100644 --- a/ansible/host_vars/bde3-virt.adh.crans.org.yml +++ b/ansible/host_vars/bde3-virt.adh.crans.org.yml @@ -3,3 +3,4 @@ note: server_name: note-dev.crans.org git_branch: beta cron_enabled: false + email: notekfet2020@lists.crans.org diff --git a/ansible/roles/4-certbot/templates/letsencrypt/conf.d/nk20.ini.j2 b/ansible/roles/4-certbot/templates/letsencrypt/conf.d/nk20.ini.j2 index b02abf5a..272e160d 100644 --- a/ansible/roles/4-certbot/templates/letsencrypt/conf.d/nk20.ini.j2 +++ b/ansible/roles/4-certbot/templates/letsencrypt/conf.d/nk20.ini.j2 @@ -10,11 +10,11 @@ rsa-key-size = 4096 # server = https://acme-staging.api.letsencrypt.org/directory # Uncomment and update to register with the specified e-mail address -email = notekfet2020@lists.crans.org +email = {{ note.email }} # Uncomment to use a text interface instead of ncurses text = True # Use DNS-01 challenge -authenticator = nginx +authenticator = standalone From cbf7e6fe6cf93851ad84597fb814ea3dcde9a783 Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Fri, 30 Oct 2020 17:01:47 +0100 Subject: [PATCH 04/28] run certbot if necessary --- ansible/roles/4-certbot/tasks/main.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/ansible/roles/4-certbot/tasks/main.yml b/ansible/roles/4-certbot/tasks/main.yml index 52bc0d67..dbd6e477 100644 --- a/ansible/roles/4-certbot/tasks/main.yml +++ b/ansible/roles/4-certbot/tasks/main.yml @@ -9,6 +9,11 @@ retries: 3 until: pkg_result is succeeded +- name: Check if certificate already exists. + stat: + path: /etc/letsencrypt/live/{{note.server_name}}/cert.pem + register: letsencrypt_cert + - name: Create /etc/letsencrypt/conf.d file: path: /etc/letsencrypt/conf.d @@ -19,3 +24,17 @@ src: "letsencrypt/conf.d/nk20.ini.j2" dest: "/etc/letsencrypt/conf.d/nk20.ini" mode: 0644 + +- name: Stop services to allow certbot to generate a cert. + service: + name: nginx + state: stopped + +- name: Generate new certificate if one doesn't exist. + shell: "certbot certonly --non-interactive --config /etc/letsencrypt/conf.d/nk20.ini -d {{note.server_name}}" + when: letsencrypt_cert.stat.exists == False + +- name: Restart services to allow certbot to generate a cert. + service: + name: nginx + state: started From 1072e227b84ab4d51fd4e65df5e44c4eb496e4c3 Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Fri, 30 Oct 2020 17:07:03 +0100 Subject: [PATCH 05/28] don't copy personal config on prod --- ansible/host_vars/bde-note.adh.crans.org.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ansible/host_vars/bde-note.adh.crans.org.yml b/ansible/host_vars/bde-note.adh.crans.org.yml index f6e4ff97..ba085433 100644 --- a/ansible/host_vars/bde-note.adh.crans.org.yml +++ b/ansible/host_vars/bde-note.adh.crans.org.yml @@ -1,5 +1,5 @@ --- note: server_name: note.crans.org - git_branch: beta - cron_enabled: false + git_branch: master + cron_enabled: true From 39fd3a247166833ed24dc5cfe774b82e8a1c9324 Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Fri, 30 Oct 2020 20:54:41 +0100 Subject: [PATCH 06/28] set DB_PASSWORD in env file --- ansible/roles/2-nk20/tasks/main.yml | 2 +- ansible/roles/2-nk20/templates/env.j2 | 24 ++++++++++++++++++++++++ ansible/roles/6-psql/tasks/main.yml | 4 ++-- 3 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 ansible/roles/2-nk20/templates/env.j2 diff --git a/ansible/roles/2-nk20/tasks/main.yml b/ansible/roles/2-nk20/tasks/main.yml index 9652359d..3852894d 100644 --- a/ansible/roles/2-nk20/tasks/main.yml +++ b/ansible/roles/2-nk20/tasks/main.yml @@ -16,7 +16,7 @@ - name: Use default env vars (should be updated!) template: - src: "env_example" + src: "env.j2" dest: "/var/www/note_kfet/.env" mode: 0644 force: false diff --git a/ansible/roles/2-nk20/templates/env.j2 b/ansible/roles/2-nk20/templates/env.j2 new file mode 100644 index 00000000..fbef052d --- /dev/null +++ b/ansible/roles/2-nk20/templates/env.j2 @@ -0,0 +1,24 @@ +DJANGO_APP_STAGE=prod +# Only used in dev mode, change to "postgresql" if you want to use PostgreSQL in dev +DJANGO_DEV_STORE_METHOD=sqlite +DJANGO_DB_HOST=localhost +DJANGO_DB_NAME=note_db +DJANGO_DB_USER=note +DJANGO_DB_PASSWORD={{ DB_PASSWORD }} +DJANGO_DB_PORT= +DJANGO_SECRET_KEY=CHANGE_ME +DJANGO_SETTINGS_MODULE=note_kfet.settings +CONTACT_EMAIL=tresorerie.bde@localhost +NOTE_URL=localhost +DOMAIN=localhost + +# Config for mails. Only used in production +NOTE_MAIL=notekfet@localhost +EMAIL_HOST=smtp.localhost +EMAIL_PORT=25 +EMAIL_USER=notekfet@localhost +EMAIL_PASSWORD=CHANGE_ME + +# Wiki configuration +WIKI_USER=NoteKfet2020 +WIKI_PASSWORD= diff --git a/ansible/roles/6-psql/tasks/main.yml b/ansible/roles/6-psql/tasks/main.yml index c4349f5e..91da9132 100644 --- a/ansible/roles/6-psql/tasks/main.yml +++ b/ansible/roles/6-psql/tasks/main.yml @@ -11,14 +11,14 @@ until: pkg_result is succeeded - name: Create role note - when: "DB_PASSWORD|bool" # If the password is not defined, skip the installation + when: DB_PASSWORD|length > 0 # If the password is not defined, skip the installation postgresql_user: name: note password: "{{ DB_PASSWORD }}" become_user: postgres - name: Create NK20 database - when: "DB_PASSWORD|bool" + when: DB_PASSWORD|length >0 postgresql_db: name: note_db owner: note From e1f647bd0246d3f4fc804c71769cc575731c1d54 Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Fri, 30 Oct 2020 21:28:25 +0100 Subject: [PATCH 07/28] lesser hardcoded --- ansible/roles/2-nk20/templates/env.j2 | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ansible/roles/2-nk20/templates/env.j2 b/ansible/roles/2-nk20/templates/env.j2 index fbef052d..84213ac7 100644 --- a/ansible/roles/2-nk20/templates/env.j2 +++ b/ansible/roles/2-nk20/templates/env.j2 @@ -9,8 +9,7 @@ DJANGO_DB_PORT= DJANGO_SECRET_KEY=CHANGE_ME DJANGO_SETTINGS_MODULE=note_kfet.settings CONTACT_EMAIL=tresorerie.bde@localhost -NOTE_URL=localhost -DOMAIN=localhost +NOTE_URL= {{note.server_name}} # Config for mails. Only used in production NOTE_MAIL=notekfet@localhost From 290848f904ef41767f4ff300cf6a81eec652cf46 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 2 Dec 2020 14:58:14 +0100 Subject: [PATCH 08/28] Non-member people can update their profile everytime --- apps/permission/fixtures/initial.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/permission/fixtures/initial.json b/apps/permission/fixtures/initial.json index 2f3d064a..a7e07d89 100644 --- a/apps/permission/fixtures/initial.json +++ b/apps/permission/fixtures/initial.json @@ -819,7 +819,7 @@ "type": "change", "mask": 1, "field": "", - "permanent": false, + "permanent": true, "description": "Modifier son profil" } }, From 338c94ed0559943247cc23161ddfa7a4672849b1 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Tue, 22 Dec 2020 02:54:11 +0100 Subject: [PATCH 09/28] More API filters for the member app --- apps/member/api/views.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/member/api/views.py b/apps/member/api/views.py index 3dc07fe1..8e08aad7 100644 --- a/apps/member/api/views.py +++ b/apps/member/api/views.py @@ -1,7 +1,8 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -from rest_framework.filters import SearchFilter +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.filters import OrderingFilter, SearchFilter from api.viewsets import ReadProtectedModelViewSet from .serializers import ProfileSerializer, ClubSerializer, MembershipSerializer @@ -16,6 +17,13 @@ class ProfileViewSet(ReadProtectedModelViewSet): """ queryset = Profile.objects.all() serializer_class = ProfileSerializer + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['user', 'user__first_name', 'user__last_name', 'user__username', 'user__email', + 'user__note__alias__name', 'user__note__alias__normalized_name', 'phone_number', "section", + 'department', 'promotion', 'address', 'paid', 'ml_events_registration', 'ml_sport_registration', + 'ml_art_registration', 'report_frequency', 'email_confirmed', 'registration_valid', ] + search_fields = ['$user__first_name' '$user__last_name', '$user__username', '$user__email', + '$user__note__alias__name', '$user__note__alias__normalized_name', ] class ClubViewSet(ReadProtectedModelViewSet): @@ -26,8 +34,11 @@ class ClubViewSet(ReadProtectedModelViewSet): """ queryset = Club.objects.all() serializer_class = ClubSerializer - filter_backends = [SearchFilter] - search_fields = ['$name', ] + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['name', 'email', 'note__alias__name', 'note__alias__normalized_name', 'parent_club', + 'parent_club__name', 'require_memberships', 'membership_fee_paid', 'membership_fee_unpaid', + 'membership_duration', 'membership_start', 'membership_end', ] + search_fields = ['$name', '$email', '$note__alias__name', '$note__alias__normalized_name', ] class MembershipViewSet(ReadProtectedModelViewSet): @@ -38,3 +49,12 @@ class MembershipViewSet(ReadProtectedModelViewSet): """ queryset = Membership.objects.all() serializer_class = MembershipSerializer + filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter] + filterset_fields = ['club__name', 'club__email', 'club__note__alias__name', 'club__note__alias__normalized_name', + 'user__username', 'user__last_name', 'user__first_name', 'user__email', + 'user__note__alias__name', 'user__note__alias__normalized_name', + 'date_start', 'date_end', 'fee', ] + ordering_fields = ['id', 'date_start', 'date_end', ] + search_fields = ['$club__name', '$club__email', '$club__note__alias__name', '$club__note__alias__normalized_name', + '$user__username', '$user__last_name', '$user__first_name', '$user__email', + '$user__note__alias__name', '$user__note__alias__normalized_name', ] From aceb77ffb9405326c304c19806e428df740a8a61 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Tue, 22 Dec 2020 03:18:43 +0100 Subject: [PATCH 10/28] More API filters for the activity app --- apps/activity/api/views.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/activity/api/views.py b/apps/activity/api/views.py index 8d555b3b..16ca9054 100644 --- a/apps/activity/api/views.py +++ b/apps/activity/api/views.py @@ -18,7 +18,7 @@ class ActivityTypeViewSet(ReadProtectedModelViewSet): queryset = ActivityType.objects.all() serializer_class = ActivityTypeSerializer filter_backends = [DjangoFilterBackend] - filterset_fields = ['name', 'can_invite', ] + filterset_fields = ['name', 'manage_entries', 'can_invite', 'guest_entry_fee', ] class ActivityViewSet(ReadProtectedModelViewSet): @@ -29,8 +29,14 @@ class ActivityViewSet(ReadProtectedModelViewSet): """ queryset = Activity.objects.all() serializer_class = ActivitySerializer - filter_backends = [DjangoFilterBackend] - filterset_fields = ['name', 'description', 'activity_type', ] + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['name', 'description', 'activity_type', 'location', 'creater', 'organizer', 'attendees_club', + 'date_start', 'date_end', 'valid', 'open', ] + search_fields = ['$name', '$description', '$location', '$creater__last_name', '$creater__first_name', + '$creater__email', '$creater__note__alias__name', '$creater__note__alias__normalized_name', + '$organizer__name', '$organizer__email', '$organizer__note__alias__name', + '$organizer__note__alias__normalized_name', '$attendees_club__name', '$attendees_club__email', + '$attendees_club__note__alias__name', '$attendees_club__note__alias__normalized_name', ] class GuestViewSet(ReadProtectedModelViewSet): @@ -41,8 +47,11 @@ class GuestViewSet(ReadProtectedModelViewSet): """ queryset = Guest.objects.all() serializer_class = GuestSerializer - filter_backends = [SearchFilter] - search_fields = ['$last_name', '$first_name', '$inviter__alias__name', '$inviter__alias__normalized_name', ] + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['activity', 'activity__name', 'last_name', 'first_name', 'inviter', 'inviter__alias__name', + 'inviter__alias__normalized_name', ] + search_fields = ['$activity__name', '$last_name', '$first_name', '$inviter__user__email', '$inviter__alias__name', + '$inviter__alias__normalized_name', ] class EntryViewSet(ReadProtectedModelViewSet): @@ -53,5 +62,7 @@ class EntryViewSet(ReadProtectedModelViewSet): """ queryset = Entry.objects.all() serializer_class = EntrySerializer - filter_backends = [SearchFilter] - search_fields = ['$last_name', '$first_name', '$inviter__alias__name', '$inviter__alias__normalized_name', ] + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['activity', 'time', 'note', 'guest', ] + search_fields = ['$activity__name', '$note__user__email', '$note__alias__name', '$note__alias__normalized_name', + '$guest__last_name', '$guest__first_name', ] From eae091625aaf8389d42766cf43b44f98f0b480b3 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Tue, 22 Dec 2020 12:37:21 +0100 Subject: [PATCH 11/28] More API filters for the note app --- apps/note/api/views.py | 43 +++++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/apps/note/api/views.py b/apps/note/api/views.py index ae8bc94e..abcf69f2 100644 --- a/apps/note/api/views.py +++ b/apps/note/api/views.py @@ -22,15 +22,18 @@ from ..models.transactions import TransactionTemplate, Transaction, TemplateCate class NotePolymorphicViewSet(ReadProtectedModelViewSet): """ REST API View set. - The djangorestframework plugin will get all `Note` objects (with polymorhism), serialize it to JSON with the given serializer, + The djangorestframework plugin will get all `Note` objects (with polymorhism), + serialize it to JSON with the given serializer, then render it on /api/note/note/ """ queryset = Note.objects.all() serializer_class = NotePolymorphicSerializer filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] - filterset_fields = ['polymorphic_ctype', 'is_active', ] - search_fields = ['$alias__normalized_name', '$alias__name', '$polymorphic_ctype__model', ] - ordering_fields = ['alias__name', 'alias__normalized_name'] + filterset_fields = ['alias__name', 'polymorphic_ctype', 'is_active', 'balance', 'last_negative', 'created_at', ] + search_fields = ['$alias__normalized_name', '$alias__name', '$polymorphic_ctype__model', + '$noteuser__user__last_name', '$noteuser__user__first_name', '$noteuser__user__email', + '$noteuser__user__email', '$noteclub__club__email', ] + ordering_fields = ['alias__name', 'alias__normalized_name', 'balance', 'created_at', ] def get_queryset(self): """ @@ -59,8 +62,8 @@ class AliasViewSet(ReadProtectedModelViewSet): serializer_class = AliasSerializer filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter] search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ] - filterset_fields = ['note'] - ordering_fields = ['name', 'normalized_name'] + filterset_fields = ['note', 'note__noteuser__user', 'note__noteclub__club', 'note__polymorphic_ctype__model', ] + ordering_fields = ['name', 'normalized_name', ] def get_serializer_class(self): serializer_class = self.serializer_class @@ -110,8 +113,8 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet): serializer_class = ConsumerSerializer filter_backends = [SearchFilter, OrderingFilter, DjangoFilterBackend] search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ] - filterset_fields = ['note'] - ordering_fields = ['name', 'normalized_name'] + filterset_fields = ['note', 'note__noteuser__user', 'note__noteclub__club', 'note__polymorphic_ctype__model', ] + ordering_fields = ['name', 'normalized_name', ] def get_queryset(self): """ @@ -159,8 +162,9 @@ class TemplateCategoryViewSet(ReadProtectedModelViewSet): """ queryset = TemplateCategory.objects.order_by("name").all() serializer_class = TemplateCategorySerializer - filter_backends = [SearchFilter] - search_fields = ['$name', ] + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['name', 'templates', 'templates__name'] + search_fields = ['$name', '$templates__name', ] class TransactionTemplateViewSet(viewsets.ModelViewSet): @@ -171,9 +175,10 @@ class TransactionTemplateViewSet(viewsets.ModelViewSet): """ queryset = TransactionTemplate.objects.order_by("name").all() serializer_class = TransactionTemplateSerializer - filter_backends = [SearchFilter, DjangoFilterBackend] - filterset_fields = ['name', 'amount', 'display', 'category', ] - search_fields = ['$name', ] + filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter] + filterset_fields = ['name', 'amount', 'display', 'category', 'category__name', ] + search_fields = ['$name', '$category__name', ] + ordering_fields = ['amount', ] class TransactionViewSet(ReadProtectedModelViewSet): @@ -185,10 +190,14 @@ class TransactionViewSet(ReadProtectedModelViewSet): queryset = Transaction.objects.order_by("-created_at").all() serializer_class = TransactionPolymorphicSerializer filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter] - filterset_fields = ["source", "source_alias", "destination", "destination_alias", "quantity", - "polymorphic_ctype", "amount", "created_at", ] - search_fields = ['$reason', ] - ordering_fields = ['created_at', 'amount'] + filterset_fields = ['source', 'source_alias', '$source__alias__name', 'source__alias__normalized_name', + 'destination', 'destination_alias', 'destination__alias__name', + 'destination__alias__normalized_name', 'quantity', 'polymorphic_ctype', 'amount', + 'created_at', 'valid', 'invalidity_reason', ] + search_fields = ['$reason', '$source_alias', '$source__alias__name', '$source__alias__normalized_name', + '$destination_alias', '$destination__alias__name', '$destination__alias__normalized_name', + '$invalidity_reason', ] + ordering_fields = ['created_at', 'amount', ] def get_queryset(self): user = self.request.user From d47799e6eef0e4212ef6328ab0d1551b76752e3b Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Tue, 22 Dec 2020 12:42:54 +0100 Subject: [PATCH 12/28] More API filters for the permission app --- apps/member/api/views.py | 4 ++-- apps/permission/api/views.py | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/member/api/views.py b/apps/member/api/views.py index 8e08aad7..8c48cfea 100644 --- a/apps/member/api/views.py +++ b/apps/member/api/views.py @@ -53,8 +53,8 @@ class MembershipViewSet(ReadProtectedModelViewSet): filterset_fields = ['club__name', 'club__email', 'club__note__alias__name', 'club__note__alias__normalized_name', 'user__username', 'user__last_name', 'user__first_name', 'user__email', 'user__note__alias__name', 'user__note__alias__normalized_name', - 'date_start', 'date_end', 'fee', ] + 'date_start', 'date_end', 'fee', 'roles', ] ordering_fields = ['id', 'date_start', 'date_end', ] search_fields = ['$club__name', '$club__email', '$club__note__alias__name', '$club__note__alias__normalized_name', '$user__username', '$user__last_name', '$user__first_name', '$user__email', - '$user__note__alias__name', '$user__note__alias__normalized_name', ] + '$user__note__alias__name', '$user__note__alias__normalized_name', '$roles__name', ] diff --git a/apps/permission/api/views.py b/apps/permission/api/views.py index 1ec67aa3..a2ba8b7a 100644 --- a/apps/permission/api/views.py +++ b/apps/permission/api/views.py @@ -2,6 +2,8 @@ # SPDX-License-Identifier: GPL-3.0-or-later from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.filters import SearchFilter + from api.viewsets import ReadOnlyProtectedModelViewSet from .serializers import PermissionSerializer, RoleSerializer @@ -16,8 +18,9 @@ class PermissionViewSet(ReadOnlyProtectedModelViewSet): """ queryset = Permission.objects.all() serializer_class = PermissionSerializer - filter_backends = [DjangoFilterBackend] - filterset_fields = ['model', 'type', ] + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['model', 'type', 'query', 'mask', 'field', 'permanent', ] + search_fields = ['$model__name', '$query', '$description', ] class RoleViewSet(ReadOnlyProtectedModelViewSet): @@ -28,5 +31,6 @@ class RoleViewSet(ReadOnlyProtectedModelViewSet): """ queryset = Role.objects.all() serializer_class = RoleSerializer - filter_backends = [DjangoFilterBackend] - filterset_fields = ['role', ] + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['name', 'permissions', 'for_club', 'membership_set__user', ] + SearchFilter = ['$name', '$for_club__name', ] From e0030771e498dab846bb8563857bce7f6fec0d69 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Tue, 22 Dec 2020 12:53:35 +0100 Subject: [PATCH 13/28] More API filters for the treasury app --- apps/treasury/api/views.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/treasury/api/views.py b/apps/treasury/api/views.py index 82a0ed1e..0618b924 100644 --- a/apps/treasury/api/views.py +++ b/apps/treasury/api/views.py @@ -18,8 +18,9 @@ class InvoiceViewSet(ReadProtectedModelViewSet): """ queryset = Invoice.objects.order_by("id").all() serializer_class = InvoiceSerializer - filter_backends = [DjangoFilterBackend] - filterset_fields = ['bde', ] + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['bde', 'object', 'description', 'name', 'address', 'date', 'acquitted', 'locked', ] + search_fields = ['$object', '$description', '$name', '$address', ] class ProductViewSet(ReadProtectedModelViewSet): @@ -30,8 +31,9 @@ class ProductViewSet(ReadProtectedModelViewSet): """ queryset = Product.objects.order_by("invoice_id", "id").all() serializer_class = ProductSerializer - filter_backends = [SearchFilter] - search_fields = ['$designation', ] + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['invoice', 'designation', 'quantity', 'amount', ] + search_fields = ['$designation', '$invoice__object', ] class RemittanceTypeViewSet(ReadProtectedModelViewSet): @@ -42,6 +44,9 @@ class RemittanceTypeViewSet(ReadProtectedModelViewSet): """ queryset = RemittanceType.objects.order_by("id") serializer_class = RemittanceTypeSerializer + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['note', ] + search_fields = ['$note__special_type', ] class RemittanceViewSet(ReadProtectedModelViewSet): @@ -52,6 +57,9 @@ class RemittanceViewSet(ReadProtectedModelViewSet): """ queryset = Remittance.objects.order_by("id") serializer_class = RemittanceSerializer + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['date', 'remittance_type', 'comment', 'closed', 'specialtransactionproxy__transaction', ] + search_fields = ['$remittance_type__note__special_type', '$comment', ] class SogeCreditViewSet(ReadProtectedModelViewSet): @@ -62,3 +70,8 @@ class SogeCreditViewSet(ReadProtectedModelViewSet): """ queryset = SogeCredit.objects.order_by("id") serializer_class = SogeCreditSerializer + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['user', 'user__last_name', 'user__first_name', 'user__email', 'user__note__alias__name', + 'user__note__alias__normalized_name', 'transactions', 'credit_transaction', ] + search_fields = ['$user__last_name', '$user__first_name', '$user__email', '$user__note__alias__name', + '$user__note__alias__normalized_name', ] From 48880e7fd39859594b0ea816d315fbc4deb14db2 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Tue, 22 Dec 2020 13:11:01 +0100 Subject: [PATCH 14/28] More API filters for the wei app --- apps/wei/api/views.py | 55 +++++++++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/apps/wei/api/views.py b/apps/wei/api/views.py index aaa1f141..ccd4615a 100644 --- a/apps/wei/api/views.py +++ b/apps/wei/api/views.py @@ -1,7 +1,8 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later + from django_filters.rest_framework import DjangoFilterBackend -from rest_framework.filters import SearchFilter +from rest_framework.filters import OrderingFilter, SearchFilter from api.viewsets import ReadProtectedModelViewSet from .serializers import WEIClubSerializer, BusSerializer, BusTeamSerializer, WEIRoleSerializer, \ @@ -17,9 +18,12 @@ class WEIClubViewSet(ReadProtectedModelViewSet): """ queryset = WEIClub.objects.all() serializer_class = WEIClubSerializer - filter_backends = [SearchFilter, DjangoFilterBackend] - search_fields = ['$name', ] - filterset_fields = ['name', 'year', ] + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['name', 'year', 'date_start', 'date_end', 'email', 'note__alias__name', + 'note__alias__normalized_name', 'parent_club', 'parent_club__name', 'require_memberships', + 'membership_fee_paid', 'membership_fee_unpaid', 'membership_duration', 'membership_start', + 'membership_end', ] + search_fields = ['$name', '$email', '$note__alias__name', '$note__alias__normalized_name', ] class BusViewSet(ReadProtectedModelViewSet): @@ -30,9 +34,9 @@ class BusViewSet(ReadProtectedModelViewSet): """ queryset = Bus.objects serializer_class = BusSerializer - filter_backends = [SearchFilter, DjangoFilterBackend] - search_fields = ['$name', ] - filterset_fields = ['name', 'wei', ] + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['name', 'wei', 'description', ] + search_fields = ['$name', '$wei__name', '$description', ] class BusTeamViewSet(ReadProtectedModelViewSet): @@ -43,9 +47,9 @@ class BusTeamViewSet(ReadProtectedModelViewSet): """ queryset = BusTeam.objects serializer_class = BusTeamSerializer - filter_backends = [SearchFilter, DjangoFilterBackend] - search_fields = ['$name', ] - filterset_fields = ['name', 'bus', 'bus__wei', ] + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['name', 'bus', 'color', 'description', 'bus__wei', ] + search_fields = ['$name', '$bus__name', '$bus__wei__name', '$description', ] class WEIRoleViewSet(ReadProtectedModelViewSet): @@ -56,8 +60,9 @@ class WEIRoleViewSet(ReadProtectedModelViewSet): """ queryset = WEIRole.objects serializer_class = WEIRoleSerializer - filter_backends = [SearchFilter] - search_fields = ['$name', ] + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['name', 'permissions', 'for_club', 'membership_set__user', ] + SearchFilter = ['$name', '$for_club__name', ] class WEIRegistrationViewSet(ReadProtectedModelViewSet): @@ -68,9 +73,16 @@ class WEIRegistrationViewSet(ReadProtectedModelViewSet): """ queryset = WEIRegistration.objects serializer_class = WEIRegistrationSerializer - filter_backends = [SearchFilter, DjangoFilterBackend] - search_fields = ['$user__username', ] - filterset_fields = ['user', 'wei', ] + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['user', 'user__username', 'user__first_name', 'user__last_name', 'user__email', + 'user__note__alias__name', 'user__note__alias__normalized_name', 'wei', 'wei__name', + 'wei__email', 'wei__note__alias__name', 'wei__note__alias__normalized_name', 'wei__year', + 'soge_credit', 'caution_check', 'birth_date', 'gender', 'clothing_cut', 'clothing_size', + 'first_year', 'emergency_contact_name', 'emergency_contact_phone', ] + search_fields = ['$user__username', '$user__first_name', '$user__last_name', '$user__email', + '$user__note__alias__name', '$user__note__alias__normalized_name', '$wei__name', + '$wei__email', '$wei__note__alias__name', '$wei__note__alias__normalized_name', + '$health_issues', '$emergency_contact_name', '$emergency_contact_phone', ] class WEIMembershipViewSet(ReadProtectedModelViewSet): @@ -81,6 +93,13 @@ class WEIMembershipViewSet(ReadProtectedModelViewSet): """ queryset = WEIMembership.objects serializer_class = WEIMembershipSerializer - filter_backends = [SearchFilter, DjangoFilterBackend] - search_fields = ['$user__username', '$bus__name', '$team__name', ] - filterset_fields = ['user', 'club', 'bus', 'team', ] + filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter] + filterset_fields = ['club__name', 'club__email', 'club__note__alias__name', 'club__note__alias__normalized_name', + 'user__username', 'user__last_name', 'user__first_name', 'user__email', + 'user__note__alias__name', 'user__note__alias__normalized_name', 'date_start', 'date_end', + 'fee', 'roles', 'bus', 'bus__name', 'team', 'team__name', 'registration', ] + ordering_fields = ['id', 'date_start', 'date_end', ] + search_fields = ['$club__name', '$club__email', '$club__note__alias__name', '$club__note__alias__normalized_name', + '$user__username', '$user__last_name', '$user__first_name', '$user__email', + '$user__note__alias__name', '$user__note__alias__normalized_name', '$roles__name', + '$bus__name', '$team__name', ] From 95be0042e9552f202daff20433273b6f1babe3d2 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Tue, 22 Dec 2020 13:28:43 +0100 Subject: [PATCH 15/28] Fix transaction API page --- apps/note/api/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/note/api/views.py b/apps/note/api/views.py index abcf69f2..5c0ec66f 100644 --- a/apps/note/api/views.py +++ b/apps/note/api/views.py @@ -190,7 +190,7 @@ class TransactionViewSet(ReadProtectedModelViewSet): queryset = Transaction.objects.order_by("-created_at").all() serializer_class = TransactionPolymorphicSerializer filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter] - filterset_fields = ['source', 'source_alias', '$source__alias__name', 'source__alias__normalized_name', + filterset_fields = ['source', 'source_alias', 'source__alias__name', 'source__alias__normalized_name', 'destination', 'destination_alias', 'destination__alias__name', 'destination__alias__normalized_name', 'quantity', 'polymorphic_ctype', 'amount', 'created_at', 'valid', 'invalidity_reason', ] From 3a205556633c033506886c71cb772a246476ef50 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 23 Dec 2020 14:54:21 +0100 Subject: [PATCH 16/28] Unit tests for API pages, closes #83 Signed-off-by: Yohann D'ANELLO --- apps/activity/api/views.py | 8 +- apps/activity/tests/test_activities.py | 45 ++++- apps/api/tests.py | 161 ++++++++++++++++++ apps/api/viewsets.py | 15 +- apps/logs/api/views.py | 2 +- apps/member/api/views.py | 8 +- apps/member/models.py | 1 + .../templates/member/includes/club_info.html | 2 +- .../member/includes/profile_info.html | 2 +- apps/member/tests/test_memberships.py | 38 ++++- apps/member/views.py | 4 +- apps/note/api/views.py | 12 +- apps/note/models/notes.py | 1 + apps/note/tests/test_transactions.py | 56 +++++- apps/permission/api/views.py | 9 +- apps/treasury/api/views.py | 12 +- apps/treasury/models.py | 1 + apps/treasury/tests/test_treasury.py | 65 ++++++- apps/wei/api/views.py | 42 ++--- apps/wei/templates/wei/base.html | 4 +- apps/wei/tests/test_wei_registration.py | 74 +++++++- 21 files changed, 495 insertions(+), 67 deletions(-) create mode 100644 apps/api/tests.py diff --git a/apps/activity/api/views.py b/apps/activity/api/views.py index 16ca9054..998c3ce4 100644 --- a/apps/activity/api/views.py +++ b/apps/activity/api/views.py @@ -15,7 +15,7 @@ class ActivityTypeViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `ActivityType` objects, serialize it to JSON with the given serializer, then render it on /api/activity/type/ """ - queryset = ActivityType.objects.all() + queryset = ActivityType.objects.order_by('id') serializer_class = ActivityTypeSerializer filter_backends = [DjangoFilterBackend] filterset_fields = ['name', 'manage_entries', 'can_invite', 'guest_entry_fee', ] @@ -27,7 +27,7 @@ class ActivityViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `Activity` objects, serialize it to JSON with the given serializer, then render it on /api/activity/activity/ """ - queryset = Activity.objects.all() + queryset = Activity.objects.order_by('id') serializer_class = ActivitySerializer filter_backends = [DjangoFilterBackend, SearchFilter] filterset_fields = ['name', 'description', 'activity_type', 'location', 'creater', 'organizer', 'attendees_club', @@ -45,7 +45,7 @@ class GuestViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `Guest` objects, serialize it to JSON with the given serializer, then render it on /api/activity/guest/ """ - queryset = Guest.objects.all() + queryset = Guest.objects.order_by('id') serializer_class = GuestSerializer filter_backends = [DjangoFilterBackend, SearchFilter] filterset_fields = ['activity', 'activity__name', 'last_name', 'first_name', 'inviter', 'inviter__alias__name', @@ -60,7 +60,7 @@ class EntryViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `Entry` objects, serialize it to JSON with the given serializer, then render it on /api/activity/entry/ """ - queryset = Entry.objects.all() + queryset = Entry.objects.order_by('id') serializer_class = EntrySerializer filter_backends = [DjangoFilterBackend, SearchFilter] filterset_fields = ['activity', 'time', 'note', 'guest', ] diff --git a/apps/activity/tests/test_activities.py b/apps/activity/tests/test_activities.py index 99eb2ffb..366f9926 100644 --- a/apps/activity/tests/test_activities.py +++ b/apps/activity/tests/test_activities.py @@ -3,13 +3,16 @@ from datetime import timedelta +from api.tests import TestAPI from django.contrib.auth.models import User from django.test import TestCase from django.urls import reverse from django.utils import timezone -from activity.models import Activity, ActivityType, Guest, Entry from member.models import Club +from ..api.views import ActivityTypeViewSet, ActivityViewSet, EntryViewSet, GuestViewSet +from ..models import Activity, ActivityType, Guest, Entry + class TestActivities(TestCase): """ @@ -173,3 +176,43 @@ class TestActivities(TestCase): """ response = self.client.get(reverse("activity:calendar_ics")) self.assertEqual(response.status_code, 200) + + +class TestActivityAPI(TestAPI): + def setUp(self) -> None: + super().setUp() + + self.activity = Activity.objects.create( + name="Activity", + description="This is a test activity\non two very very long lines\nbecause this is very important.", + location="Earth", + activity_type=ActivityType.objects.get(name="Pot"), + creater=self.user, + organizer=Club.objects.get(name="Kfet"), + attendees_club=Club.objects.get(name="Kfet"), + date_start=timezone.now(), + date_end=timezone.now() + timedelta(days=2), + valid=True, + ) + + self.guest = Guest.objects.create( + activity=self.activity, + inviter=self.user.note, + last_name="GUEST", + first_name="Guest", + ) + + self.entry = Entry.objects.create( + activity=self.activity, + note=self.user.note, + guest=self.guest, + ) + + def test_activity_api(self): + """ + Load API pages for the activity app and test all filters + """ + self.check_viewset(ActivityViewSet, "/api/activity/activity/") + self.check_viewset(ActivityTypeViewSet, "/api/activity/type/") + self.check_viewset(EntryViewSet, "/api/activity/entry/") + self.check_viewset(GuestViewSet, "/api/activity/guest/") diff --git a/apps/api/tests.py b/apps/api/tests.py new file mode 100644 index 00000000..841e9816 --- /dev/null +++ b/apps/api/tests.py @@ -0,0 +1,161 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +import json +from datetime import datetime +from urllib.parse import quote_plus + +from django.contrib.auth.models import User +from django.test import TestCase +from django_filters.rest_framework import DjangoFilterBackend, OrderingFilter +from note.models import NoteClub, NoteUser, Alias, Note +from phonenumbers import PhoneNumber +from rest_framework.filters import SearchFilter + +from .viewsets import ContentTypeViewSet, UserViewSet + + +class TestAPI(TestCase): + """ + Load API pages and check that filters are working. + """ + fixtures = ('initial', ) + + def setUp(self) -> None: + self.user = User.objects.create_superuser( + username="adminapi", + password="adminapi", + email="adminapi@example.com", + last_name="Admin", + first_name="Admin", + ) + self.client.force_login(self.user) + + sess = self.client.session + sess["permission_mask"] = 42 + sess.save() + + def check_viewset(self, viewset, url): + """ + This function should be called inside a unit test. + This loads the viewset and for each filter entry, it checks that the filter is running good. + """ + resp = self.client.get(url + "?format=json") + self.assertEqual(resp.status_code, 200) + + model = viewset.serializer_class.Meta.model + + if not model.objects.exists(): # pragma: no cover + print(f"Warning: unable to test API filters for the model {model._meta.verbose_name} " + "since there is no instance of it.") + return + + if hasattr(viewset, "filter_backends"): + backends = viewset.filter_backends + obj = model.objects.last() + + if DjangoFilterBackend in backends: + # Specific search + for field in viewset.filterset_fields: + obj = self.fix_note_object(obj, field) + + value = self.get_value(obj, field) + if value is None: # pragma: no cover + print(f"Warning: the filter {field} for the model {model._meta.verbose_name} " + "has not been tested.") + continue + resp = self.client.get(url + f"?format=json&{field}={quote_plus(str(value))}") + self.assertEqual(resp.status_code, 200, f"The filter {field} for the model " + f"{model._meta.verbose_name} does not work. " + f"Given parameter: {value}") + content = json.loads(resp.content) + self.assertGreater(content["count"], 0, f"The filter {field} for the model " + f"{model._meta.verbose_name} does not work. " + f"Given parameter: {value}") + + if OrderingFilter in backends: + # Ensure that ordering is working well + for field in viewset.ordering_fields: + resp = self.client.get(url + f"?ordering={field}") + self.assertEqual(resp.status_code, 200) + resp = self.client.get(url + f"?ordering=-{field}") + self.assertEqual(resp.status_code, 200) + + if SearchFilter in backends: + # Basic search + for field in viewset.search_fields: + obj = self.fix_note_object(obj, field) + + if field[0] == '$' or field[0] == '=': + field = field[1:] + value = self.get_value(obj, field) + if value is None: # pragma: no cover + print(f"Warning: the filter {field} for the model {model._meta.verbose_name} " + "has not been tested.") + continue + resp = self.client.get(url + f"?format=json&search={quote_plus(str(value))}") + self.assertEqual(resp.status_code, 200, f"The filter {field} for the model " + f"{model._meta.verbose_name} does not work. " + f"Given parameter: {value}") + content = json.loads(resp.content) + self.assertGreater(content["count"], 0, f"The filter {field} for the model " + f"{model._meta.verbose_name} does not work. " + f"Given parameter: {value}") + + @staticmethod + def get_value(obj, key: str): + """ + Resolve the queryset filter to get the Python value of an object. + """ + if hasattr(obj, "all"): + # obj is a RelatedManager + obj = obj.last() + + if obj is None: # pragma: no cover + return None + + if '__' not in key: + obj = getattr(obj, key) + if hasattr(obj, "pk"): + return obj.pk + elif hasattr(obj, "all"): + if not obj.exists(): # pragma: no cover + return None + return obj.last().pk + elif isinstance(obj, bool): + return int(obj) + elif isinstance(obj, datetime): + return obj.isoformat() + elif isinstance(obj, PhoneNumber): + return obj.raw_input + return obj + + key, remaining = key.split('__', 1) + return TestAPI.get_value(getattr(obj, key), remaining) + + @staticmethod + def fix_note_object(obj, field): + """ + When querying an object that has a noteclub or a noteuser field, + ensure that the object has a good value. + """ + if isinstance(obj, Alias): + if "noteuser" in field: + return NoteUser.objects.last().alias.last() + elif "noteclub" in field: + return NoteClub.objects.last().alias.last() + elif isinstance(obj, Note): + if "noteuser" in field: + return NoteUser.objects.last() + elif "noteclub" in field: + return NoteClub.objects.last() + return obj + + +class TestBasicAPI(TestAPI): + def test_user_api(self): + """ + Load the user page. + """ + self.check_viewset(ContentTypeViewSet, "/api/models/") + self.check_viewset(UserViewSet, "/api/user/") diff --git a/apps/api/viewsets.py b/apps/api/viewsets.py index fa2fc941..88ee7f01 100644 --- a/apps/api/viewsets.py +++ b/apps/api/viewsets.py @@ -6,6 +6,7 @@ from django_filters.rest_framework import DjangoFilterBackend from django.db.models import Q from django.conf import settings from django.contrib.auth.models import User +from rest_framework.filters import SearchFilter from rest_framework.viewsets import ReadOnlyModelViewSet, ModelViewSet from permission.backends import PermissionBackend from note_kfet.middlewares import get_current_session @@ -48,12 +49,13 @@ class UserViewSet(ReadProtectedModelViewSet): """ 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/ + then render it on /api/user/ """ - queryset = User.objects.all() + queryset = User.objects serializer_class = UserSerializer filter_backends = [DjangoFilterBackend] - filterset_fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_superuser', 'is_staff', 'is_active', ] + filterset_fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_superuser', 'is_staff', 'is_active', + 'note__alias__name', 'note__alias__normalized_name', ] def get_queryset(self): queryset = super().get_queryset() @@ -106,7 +108,10 @@ class ContentTypeViewSet(ReadOnlyModelViewSet): """ REST API View set. The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer, - then render it on /api/users/ + then render it on /api/models/ """ - queryset = ContentType.objects.all() + queryset = ContentType.objects.order_by('id') serializer_class = ContentTypeSerializer + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['id', 'app_label', 'model', ] + search_fields = ['$app_label', '$model', ] diff --git a/apps/logs/api/views.py b/apps/logs/api/views.py index 4160d609..5f28b71c 100644 --- a/apps/logs/api/views.py +++ b/apps/logs/api/views.py @@ -15,7 +15,7 @@ class ChangelogViewSet(ReadOnlyProtectedModelViewSet): 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() + queryset = Changelog.objects.order_by('id') serializer_class = ChangelogSerializer filter_backends = [DjangoFilterBackend, OrderingFilter] filterset_fields = ['model', 'action', "instance_pk", 'user', 'ip', ] diff --git a/apps/member/api/views.py b/apps/member/api/views.py index 8c48cfea..69943c7f 100644 --- a/apps/member/api/views.py +++ b/apps/member/api/views.py @@ -15,14 +15,14 @@ class ProfileViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `Profile` objects, serialize it to JSON with the given serializer, then render it on /api/members/profile/ """ - queryset = Profile.objects.all() + queryset = Profile.objects.order_by('id') serializer_class = ProfileSerializer filter_backends = [DjangoFilterBackend, SearchFilter] filterset_fields = ['user', 'user__first_name', 'user__last_name', 'user__username', 'user__email', 'user__note__alias__name', 'user__note__alias__normalized_name', 'phone_number', "section", 'department', 'promotion', 'address', 'paid', 'ml_events_registration', 'ml_sport_registration', 'ml_art_registration', 'report_frequency', 'email_confirmed', 'registration_valid', ] - search_fields = ['$user__first_name' '$user__last_name', '$user__username', '$user__email', + search_fields = ['$user__first_name', '$user__last_name', '$user__username', '$user__email', '$user__note__alias__name', '$user__note__alias__normalized_name', ] @@ -32,7 +32,7 @@ class ClubViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `Club` objects, serialize it to JSON with the given serializer, then render it on /api/members/club/ """ - queryset = Club.objects.all() + queryset = Club.objects.order_by('id') serializer_class = ClubSerializer filter_backends = [DjangoFilterBackend, SearchFilter] filterset_fields = ['name', 'email', 'note__alias__name', 'note__alias__normalized_name', 'parent_club', @@ -47,7 +47,7 @@ class MembershipViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `Membership` objects, serialize it to JSON with the given serializer, then render it on /api/members/membership/ """ - queryset = Membership.objects.all() + queryset = Membership.objects.order_by('id') serializer_class = MembershipSerializer filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter] filterset_fields = ['club__name', 'club__email', 'club__note__alias__name', 'club__note__alias__normalized_name', diff --git a/apps/member/models.py b/apps/member/models.py index ff8f2b88..e5fa23ef 100644 --- a/apps/member/models.py +++ b/apps/member/models.py @@ -313,6 +313,7 @@ class Membership(models.Model): roles = models.ManyToManyField( "permission.Role", + related_name="memberships", verbose_name=_("roles"), ) diff --git a/apps/member/templates/member/includes/club_info.html b/apps/member/templates/member/includes/club_info.html index 51f0ef03..0efc71d0 100644 --- a/apps/member/templates/member/includes/club_info.html +++ b/apps/member/templates/member/includes/club_info.html @@ -48,7 +48,7 @@
- {% trans 'Manage aliases' %} ({{ club.note.alias_set.all|length }}) + {% trans 'Manage aliases' %} ({{ club.note.alias.all|length }})
diff --git a/apps/member/templates/member/includes/profile_info.html b/apps/member/templates/member/includes/profile_info.html index e008ec6a..e1941d23 100644 --- a/apps/member/templates/member/includes/profile_info.html +++ b/apps/member/templates/member/includes/profile_info.html @@ -21,7 +21,7 @@
- {% trans 'Manage aliases' %} ({{ user_object.note.alias_set.all|length }}) + {% trans 'Manage aliases' %} ({{ user_object.note.alias.all|length }})
diff --git a/apps/member/tests/test_memberships.py b/apps/member/tests/test_memberships.py index 80c214f0..39ee98d8 100644 --- a/apps/member/tests/test_memberships.py +++ b/apps/member/tests/test_memberships.py @@ -5,17 +5,20 @@ import hashlib import os from datetime import date, timedelta +from api.tests import TestAPI from django.contrib.auth.models import User from django.core.files.uploadedfile import SimpleUploadedFile from django.db.models import Q from django.test import TestCase from django.urls import reverse from django.utils import timezone -from member.models import Club, Membership, Profile from note.models import Alias, NoteSpecial from permission.models import Role from treasury.models import SogeCredit +from ..api.views import ClubViewSet, MembershipViewSet, ProfileViewSet +from ..models import Club, Membership, Profile + """ Create some users and clubs and test that all pages are rendering properly and that memberships are working. @@ -403,3 +406,36 @@ class TestMemberships(TestCase): self.user.password = "custom_nk15$1$" + salt + "|" + hashed self.user.save() self.assertTrue(self.user.check_password(password)) + + +class TestMemberAPI(TestAPI): + def setUp(self) -> None: + super().setUp() + + self.user.profile.registration_valid = True + self.user.profile.email_confirmed = True + self.user.profile.phone_number = "0600000000" + self.user.profile.section = "1A0" + self.user.profile.department = "A0" + self.user.profile.address = "Earth" + self.user.profile.save() + + self.club = Club.objects.create( + name="totoclub", + parent_club=Club.objects.get(name="BDE"), + membership_start=date(year=1970, month=1, day=1), + membership_end=date(year=2040, month=1, day=1), + membership_duration=365 * 10, + ) + self.bde_membership = Membership.objects.create(user=self.user, club=Club.objects.get(name="BDE")) + self.membership = Membership.objects.create(user=self.user, club=self.club) + self.membership.roles.add(Role.objects.get(name="Bureau de club")) + self.membership.save() + + def test_member_api(self): + """ + Load API pages for the member app and test all filters + """ + self.check_viewset(ClubViewSet, "/api/members/club/") + self.check_viewset(ProfileViewSet, "/api/members/profile/") + self.check_viewset(MembershipViewSet, "/api/members/membership/") diff --git a/apps/member/views.py b/apps/member/views.py index 73569c89..2ddb3591 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -256,7 +256,7 @@ class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): context = super().get_context_data(**kwargs) note = context['object'].note context["aliases"] = AliasTable( - note.alias_set.filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view")).distinct().all()) + note.alias.filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view")).distinct().all()) context["can_create"] = PermissionBackend.check_perm(self.request.user, "note.add_alias", Alias( note=context["object"].note, name="", @@ -458,7 +458,7 @@ class ClubAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) note = context['object'].note - context["aliases"] = AliasTable(note.alias_set.filter( + context["aliases"] = AliasTable(note.alias.filter( PermissionBackend.filter_queryset(self.request.user, Alias, "view")).distinct().all()) context["can_create"] = PermissionBackend.check_perm(self.request.user, "note.add_alias", Alias( note=context["object"].note, diff --git a/apps/note/api/views.py b/apps/note/api/views.py index 5c0ec66f..4ac6ffbf 100644 --- a/apps/note/api/views.py +++ b/apps/note/api/views.py @@ -26,7 +26,7 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet): serialize it to JSON with the given serializer, then render it on /api/note/note/ """ - queryset = Note.objects.all() + queryset = Note.objects.order_by('id') serializer_class = NotePolymorphicSerializer filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filterset_fields = ['alias__name', 'polymorphic_ctype', 'is_active', 'balance', 'last_negative', 'created_at', ] @@ -58,7 +58,7 @@ class AliasViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `Alias` objects, serialize it to JSON with the given serializer, then render it on /api/aliases/ """ - queryset = Alias.objects.all() + queryset = Alias.objects serializer_class = AliasSerializer filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter] search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ] @@ -109,7 +109,7 @@ class AliasViewSet(ReadProtectedModelViewSet): class ConsumerViewSet(ReadOnlyProtectedModelViewSet): - queryset = Alias.objects.all() + queryset = Alias.objects serializer_class = ConsumerSerializer filter_backends = [SearchFilter, OrderingFilter, DjangoFilterBackend] search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ] @@ -160,7 +160,7 @@ class TemplateCategoryViewSet(ReadProtectedModelViewSet): 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.order_by("name").all() + queryset = TemplateCategory.objects.order_by('name') serializer_class = TemplateCategorySerializer filter_backends = [DjangoFilterBackend, SearchFilter] filterset_fields = ['name', 'templates', 'templates__name'] @@ -173,7 +173,7 @@ class TransactionTemplateViewSet(viewsets.ModelViewSet): The djangorestframework plugin will get all `TransactionTemplate` objects, serialize it to JSON with the given serializer, then render it on /api/note/transaction/template/ """ - queryset = TransactionTemplate.objects.order_by("name").all() + queryset = TransactionTemplate.objects.order_by('name') serializer_class = TransactionTemplateSerializer filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter] filterset_fields = ['name', 'amount', 'display', 'category', 'category__name', ] @@ -187,7 +187,7 @@ class TransactionViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `Transaction` objects, serialize it to JSON with the given serializer, then render it on /api/note/transaction/transaction/ """ - queryset = Transaction.objects.order_by("-created_at").all() + queryset = Transaction.objects.order_by('-created_at') serializer_class = TransactionPolymorphicSerializer filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter] filterset_fields = ['source', 'source_alias', 'source__alias__name', 'source__alias__normalized_name', diff --git a/apps/note/models/notes.py b/apps/note/models/notes.py index c649dbc9..7b034d58 100644 --- a/apps/note/models/notes.py +++ b/apps/note/models/notes.py @@ -248,6 +248,7 @@ class Alias(models.Model): note = models.ForeignKey( Note, on_delete=models.PROTECT, + related_name="alias", ) class Meta: diff --git a/apps/note/tests/test_transactions.py b/apps/note/tests/test_transactions.py index 7192d8ed..7e999cca 100644 --- a/apps/note/tests/test_transactions.py +++ b/apps/note/tests/test_transactions.py @@ -1,15 +1,20 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +from api.tests import TestAPI +from member.models import Club, Membership from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.test import TestCase from django.urls import reverse -from member.models import Club, Membership -from note.models import NoteUser, Transaction, TemplateCategory, TransactionTemplate, RecurrentTransaction, \ - MembershipTransaction, SpecialTransaction, NoteSpecial, Alias +from django.utils import timezone from permission.models import Role +from ..api.views import AliasViewSet, ConsumerViewSet, NotePolymorphicViewSet, TemplateCategoryViewSet,\ + TransactionTemplateViewSet, TransactionViewSet +from ..models import NoteUser, Transaction, TemplateCategory, TransactionTemplate, RecurrentTransaction, \ + MembershipTransaction, SpecialTransaction, NoteSpecial, Alias, Note + class TestTransactions(TestCase): fixtures = ('initial', ) @@ -297,8 +302,8 @@ class TestTransactions(TestCase): def test_render_search_transactions(self): response = self.client.get(reverse("note:transactions", args=(self.user.note.pk,)), data=dict( - source=self.second_user.note.alias_set.first().id, - destination=self.user.note.alias_set.first().id, + source=self.second_user.note.alias.first().id, + destination=self.user.note.alias.first().id, type=[ContentType.objects.get_for_model(Transaction).id], reason="test", valid=True, @@ -363,3 +368,44 @@ class TestTransactions(TestCase): self.assertTrue(Alias.objects.filter(name="test_updated_alias").exists()) response = self.client.delete("/api/note/alias/" + str(alias.pk) + "/") self.assertEqual(response.status_code, 204) + + +class TestNoteAPI(TestAPI): + def setUp(self) -> None: + super().setUp() + + membership = Membership.objects.create(club=Club.objects.get(name="BDE"), user=self.user) + membership.roles.add(Role.objects.get(name="Respo info")) + membership.save() + Membership.objects.create(club=Club.objects.get(name="Kfet"), user=self.user) + self.user.note.last_negative = timezone.now() + self.user.note.save() + + self.transaction = Transaction.objects.create( + source=Note.objects.first(), + destination=self.user.note, + amount=4200, + reason="Test transaction", + ) + self.user.note.refresh_from_db() + Alias.objects.create(note=self.user.note, name="I am a ¢omplex alias") + + self.category = TemplateCategory.objects.create(name="Test") + self.template = TransactionTemplate.objects.create( + name="Test", + destination=Club.objects.get(name="BDE").note, + category=self.category, + amount=100, + description="Test template", + ) + + def test_note_api(self): + """ + Load API pages for the note app and test all filters + """ + self.check_viewset(AliasViewSet, "/api/note/alias/") + self.check_viewset(ConsumerViewSet, "/api/note/consumer/") + self.check_viewset(NotePolymorphicViewSet, "/api/note/note/") + self.check_viewset(TemplateCategoryViewSet, "/api/note/transaction/category/") + self.check_viewset(TransactionTemplateViewSet, "/api/note/transaction/template/") + self.check_viewset(TransactionViewSet, "/api/note/transaction/transaction/") diff --git a/apps/permission/api/views.py b/apps/permission/api/views.py index a2ba8b7a..10fd19e3 100644 --- a/apps/permission/api/views.py +++ b/apps/permission/api/views.py @@ -1,11 +1,10 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +from api.viewsets import ReadOnlyProtectedModelViewSet from django_filters.rest_framework import DjangoFilterBackend from rest_framework.filters import SearchFilter -from api.viewsets import ReadOnlyProtectedModelViewSet - from .serializers import PermissionSerializer, RoleSerializer from ..models import Permission, Role @@ -16,7 +15,7 @@ class PermissionViewSet(ReadOnlyProtectedModelViewSet): The djangorestframework plugin will get all `Permission` objects, serialize it to JSON with the given serializer, then render it on /api/permission/permission/ """ - queryset = Permission.objects.all() + queryset = Permission.objects.order_by('id') serializer_class = PermissionSerializer filter_backends = [DjangoFilterBackend, SearchFilter] filterset_fields = ['model', 'type', 'query', 'mask', 'field', 'permanent', ] @@ -29,8 +28,8 @@ class RoleViewSet(ReadOnlyProtectedModelViewSet): The djangorestframework plugin will get all `RolePermission` objects, serialize it to JSON with the given serializer then render it on /api/permission/roles/ """ - queryset = Role.objects.all() + queryset = Role.objects.order_by('id') serializer_class = RoleSerializer filter_backends = [DjangoFilterBackend, SearchFilter] - filterset_fields = ['name', 'permissions', 'for_club', 'membership_set__user', ] + filterset_fields = ['name', 'permissions', 'for_club', 'memberships__user', ] SearchFilter = ['$name', '$for_club__name', ] diff --git a/apps/treasury/api/views.py b/apps/treasury/api/views.py index 0618b924..b0a47e09 100644 --- a/apps/treasury/api/views.py +++ b/apps/treasury/api/views.py @@ -16,7 +16,7 @@ class InvoiceViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `Invoice` objects, serialize it to JSON with the given serializer, then render it on /api/treasury/invoice/ """ - queryset = Invoice.objects.order_by("id").all() + queryset = Invoice.objects.order_by('id') serializer_class = InvoiceSerializer filter_backends = [DjangoFilterBackend, SearchFilter] filterset_fields = ['bde', 'object', 'description', 'name', 'address', 'date', 'acquitted', 'locked', ] @@ -29,7 +29,7 @@ class ProductViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `Product` objects, serialize it to JSON with the given serializer, then render it on /api/treasury/product/ """ - queryset = Product.objects.order_by("invoice_id", "id").all() + queryset = Product.objects.order_by('invoice_id', 'id') serializer_class = ProductSerializer filter_backends = [DjangoFilterBackend, SearchFilter] filterset_fields = ['invoice', 'designation', 'quantity', 'amount', ] @@ -42,7 +42,7 @@ class RemittanceTypeViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `RemittanceType` objects, serialize it to JSON with the given serializer then render it on /api/treasury/remittance_type/ """ - queryset = RemittanceType.objects.order_by("id") + queryset = RemittanceType.objects.order_by('id') serializer_class = RemittanceTypeSerializer filter_backends = [DjangoFilterBackend, SearchFilter] filterset_fields = ['note', ] @@ -55,10 +55,10 @@ class RemittanceViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `Remittance` objects, serialize it to JSON with the given serializer, then render it on /api/treasury/remittance/ """ - queryset = Remittance.objects.order_by("id") + queryset = Remittance.objects.order_by('id') serializer_class = RemittanceSerializer filter_backends = [DjangoFilterBackend, SearchFilter] - filterset_fields = ['date', 'remittance_type', 'comment', 'closed', 'specialtransactionproxy__transaction', ] + filterset_fields = ['date', 'remittance_type', 'comment', 'closed', 'transaction_proxies__transaction', ] search_fields = ['$remittance_type__note__special_type', '$comment', ] @@ -68,7 +68,7 @@ class SogeCreditViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `SogeCredit` objects, serialize it to JSON with the given serializer, then render it on /api/treasury/soge_credit/ """ - queryset = SogeCredit.objects.order_by("id") + queryset = SogeCredit.objects.order_by('id') serializer_class = SogeCreditSerializer filter_backends = [DjangoFilterBackend, SearchFilter] filterset_fields = ['user', 'user__last_name', 'user__first_name', 'user__email', 'user__note__alias__name', diff --git a/apps/treasury/models.py b/apps/treasury/models.py index b1ca407f..7782ebec 100644 --- a/apps/treasury/models.py +++ b/apps/treasury/models.py @@ -257,6 +257,7 @@ class SpecialTransactionProxy(models.Model): Remittance, on_delete=models.PROTECT, null=True, + related_name="transaction_proxies", verbose_name=_("Remittance"), ) diff --git a/apps/treasury/tests/test_treasury.py b/apps/treasury/tests/test_treasury.py index 505453b9..fba9c447 100644 --- a/apps/treasury/tests/test_treasury.py +++ b/apps/treasury/tests/test_treasury.py @@ -1,6 +1,7 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +from api.tests import TestAPI from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.db.models import Q @@ -8,7 +9,10 @@ from django.test import TestCase from django.urls import reverse from member.models import Membership, Club from note.models import SpecialTransaction, NoteSpecial, Transaction -from treasury.models import Invoice, Product, Remittance, RemittanceType, SogeCredit + +from ..api.views import InvoiceViewSet, ProductViewSet, RemittanceViewSet, RemittanceTypeViewSet, \ + SogeCreditViewSet +from ..models import Invoice, Product, Remittance, RemittanceType, SogeCredit class TestInvoices(TestCase): @@ -399,3 +403,62 @@ class TestSogeCredits(TestCase): """ response = self.client.get("/api/treasury/soge_credit/") self.assertEqual(response.status_code, 200) + + +class TestTreasuryAPI(TestAPI): + def setUp(self) -> None: + super().setUp() + + self.invoice = Invoice.objects.create( + id=1, + object="Object", + description="Description", + name="Me", + address="Earth", + acquitted=False, + ) + self.product = Product.objects.create( + invoice=self.invoice, + designation="Product", + quantity=3, + amount=3.14, + ) + + self.credit = SpecialTransaction.objects.create( + source=NoteSpecial.objects.get(special_type="Chèque"), + destination=self.user.note, + amount=4200, + reason="Credit", + last_name="TOTO", + first_name="Toto", + bank="Société générale", + ) + + self.remittance = Remittance.objects.create( + remittance_type=RemittanceType.objects.get(), + comment="Test remittance", + closed=False, + ) + self.credit.specialtransactionproxy.remittance = self.remittance + self.credit.specialtransactionproxy.save() + + self.kfet = Club.objects.get(name="Kfet") + self.bde = self.kfet.parent_club + + self.kfet_membership = Membership( + user=self.user, + club=self.kfet, + ) + self.kfet_membership._force_renew_parent = True + self.kfet_membership._soge = True + self.kfet_membership.save() + + def test_treasury_api(self): + """ + Load API pages for the treasury app and test all filters + """ + self.check_viewset(InvoiceViewSet, "/api/treasury/invoice/") + self.check_viewset(ProductViewSet, "/api/treasury/product/") + self.check_viewset(RemittanceViewSet, "/api/treasury/remittance/") + self.check_viewset(RemittanceTypeViewSet, "/api/treasury/remittance_type/") + self.check_viewset(SogeCreditViewSet, "/api/treasury/soge_credit/") diff --git a/apps/wei/api/views.py b/apps/wei/api/views.py index ccd4615a..f267d093 100644 --- a/apps/wei/api/views.py +++ b/apps/wei/api/views.py @@ -16,7 +16,7 @@ class WEIClubViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `WEIClub` objects, serialize it to JSON with the given serializer, then render it on /api/wei/club/ """ - queryset = WEIClub.objects.all() + queryset = WEIClub.objects.order_by('id') serializer_class = WEIClubSerializer filter_backends = [DjangoFilterBackend, SearchFilter] filterset_fields = ['name', 'year', 'date_start', 'date_end', 'email', 'note__alias__name', @@ -32,7 +32,7 @@ class BusViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `Bus` objects, serialize it to JSON with the given serializer, then render it on /api/wei/bus/ """ - queryset = Bus.objects + queryset = Bus.objects.order_by('id') serializer_class = BusSerializer filter_backends = [DjangoFilterBackend, SearchFilter] filterset_fields = ['name', 'wei', 'description', ] @@ -45,7 +45,7 @@ class BusTeamViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `BusTeam` objects, serialize it to JSON with the given serializer, then render it on /api/wei/team/ """ - queryset = BusTeam.objects + queryset = BusTeam.objects.order_by('id') serializer_class = BusTeamSerializer filter_backends = [DjangoFilterBackend, SearchFilter] filterset_fields = ['name', 'bus', 'color', 'description', 'bus__wei', ] @@ -58,11 +58,11 @@ class WEIRoleViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `WEIRole` objects, serialize it to JSON with the given serializer, then render it on /api/wei/role/ """ - queryset = WEIRole.objects + queryset = WEIRole.objects.order_by('id') serializer_class = WEIRoleSerializer filter_backends = [DjangoFilterBackend, SearchFilter] - filterset_fields = ['name', 'permissions', 'for_club', 'membership_set__user', ] - SearchFilter = ['$name', '$for_club__name', ] + filterset_fields = ['name', 'permissions', 'memberships', ] + search_fields = ['$name', ] class WEIRegistrationViewSet(ReadProtectedModelViewSet): @@ -71,18 +71,17 @@ class WEIRegistrationViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all WEIRegistration objects, serialize it to JSON with the given serializer, then render it on /api/wei/registration/ """ - queryset = WEIRegistration.objects + queryset = WEIRegistration.objects.order_by('id') serializer_class = WEIRegistrationSerializer filter_backends = [DjangoFilterBackend, SearchFilter] filterset_fields = ['user', 'user__username', 'user__first_name', 'user__last_name', 'user__email', 'user__note__alias__name', 'user__note__alias__normalized_name', 'wei', 'wei__name', - 'wei__email', 'wei__note__alias__name', 'wei__note__alias__normalized_name', 'wei__year', - 'soge_credit', 'caution_check', 'birth_date', 'gender', 'clothing_cut', 'clothing_size', - 'first_year', 'emergency_contact_name', 'emergency_contact_phone', ] + 'wei__email', 'wei__year', 'soge_credit', 'caution_check', 'birth_date', 'gender', + 'clothing_cut', 'clothing_size', 'first_year', 'emergency_contact_name', + 'emergency_contact_phone', ] search_fields = ['$user__username', '$user__first_name', '$user__last_name', '$user__email', '$user__note__alias__name', '$user__note__alias__normalized_name', '$wei__name', - '$wei__email', '$wei__note__alias__name', '$wei__note__alias__normalized_name', - '$health_issues', '$emergency_contact_name', '$emergency_contact_phone', ] + '$wei__email', '$health_issues', '$emergency_contact_name', '$emergency_contact_phone', ] class WEIMembershipViewSet(ReadProtectedModelViewSet): @@ -91,15 +90,16 @@ class WEIMembershipViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `BusTeam` objects, serialize it to JSON with the given serializer, then render it on /api/wei/membership/ """ - queryset = WEIMembership.objects + queryset = WEIMembership.objects.order_by('id') serializer_class = WEIMembershipSerializer filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter] - filterset_fields = ['club__name', 'club__email', 'club__note__alias__name', 'club__note__alias__normalized_name', - 'user__username', 'user__last_name', 'user__first_name', 'user__email', - 'user__note__alias__name', 'user__note__alias__normalized_name', 'date_start', 'date_end', - 'fee', 'roles', 'bus', 'bus__name', 'team', 'team__name', 'registration', ] + filterset_fields = ['club__name', 'club__email', 'club__note__alias__name', + 'club__note__alias__normalized_name', 'user__username', 'user__last_name', + 'user__first_name', 'user__email', 'user__note__alias__name', + 'user__note__alias__normalized_name', 'date_start', 'date_end', 'fee', 'roles', 'bus', + 'bus__name', 'team', 'team__name', 'registration', ] ordering_fields = ['id', 'date_start', 'date_end', ] - search_fields = ['$club__name', '$club__email', '$club__note__alias__name', '$club__note__alias__normalized_name', - '$user__username', '$user__last_name', '$user__first_name', '$user__email', - '$user__note__alias__name', '$user__note__alias__normalized_name', '$roles__name', - '$bus__name', '$team__name', ] + search_fields = ['$club__name', '$club__email', '$club__note__alias__name', + '$club__note__alias__normalized_name', '$user__username', '$user__last_name', + '$user__first_name', '$user__email', '$user__note__alias__name', + '$user__note__alias__normalized_name', '$roles__name', '$bus__name', '$team__name', ] diff --git a/apps/wei/templates/wei/base.html b/apps/wei/templates/wei/base.html index a6521bd2..43d61797 100644 --- a/apps/wei/templates/wei/base.html +++ b/apps/wei/templates/wei/base.html @@ -61,10 +61,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
{{ club.note.balance | pretty_money }}
{% endif %} - {% if "note.change_alias"|has_perm:club.note.alias_set.first %} + {% if "note.change_alias"|has_perm:club.note.alias.first %}
{% trans 'aliases'|capfirst %}
-
{{ club.note.alias_set.all|join:", " }}
+
{{ club.note.alias.all|join:", " }}
{% endif %}
{% trans 'email'|capfirst %}
diff --git a/apps/wei/tests/test_wei_registration.py b/apps/wei/tests/test_wei_registration.py index 6d294bdd..07564bf6 100644 --- a/apps/wei/tests/test_wei_registration.py +++ b/apps/wei/tests/test_wei_registration.py @@ -4,16 +4,19 @@ import subprocess from datetime import timedelta, date +from api.tests import TestAPI from django.conf import settings from django.contrib.auth.models import User from django.db.models import Q from django.test import TestCase from django.urls import reverse from django.utils import timezone -from member.models import Membership +from member.models import Membership, Club from note.models import NoteClub, SpecialTransaction from treasury.models import SogeCredit +from ..api.views import BusViewSet, BusTeamViewSet, WEIClubViewSet, WEIMembershipViewSet, WEIRegistrationViewSet, \ + WEIRoleViewSet from ..forms import CurrentSurvey, WEISurveyAlgorithm, WEISurvey from ..models import WEIClub, Bus, BusTeam, WEIRole, WEIRegistration, WEIMembership @@ -807,3 +810,72 @@ class TestWEISurveyAlgorithm(TestCase): def test_survey_algorithm(self): CurrentSurvey.get_algorithm_class()().run_algorithm() + + +class TestWeiAPI(TestAPI): + def setUp(self) -> None: + super().setUp() + + self.year = timezone.now().year + self.wei = WEIClub.objects.create( + name="Test WEI", + email="gc.wei@example.com", + parent_club_id=2, + membership_fee_paid=12500, + membership_fee_unpaid=5500, + membership_start=date(self.year, 1, 1), + membership_end=date(self.year, 12, 31), + membership_duration=396, + year=self.year, + date_start=date.today() + timedelta(days=2), + date_end=date(self.year, 12, 31), + ) + NoteClub.objects.create(club=self.wei) + self.bus = Bus.objects.create( + name="Test Bus", + wei=self.wei, + description="Test Bus", + ) + self.team = BusTeam.objects.create( + name="Test Team", + bus=self.bus, + color=0xFFFFFF, + description="Test Team", + ) + self.registration = WEIRegistration.objects.create( + user_id=self.user.id, + wei_id=self.wei.id, + soge_credit=True, + caution_check=True, + birth_date=date(2000, 1, 1), + gender="nonbinary", + clothing_cut="male", + clothing_size="XL", + health_issues="I am a bot", + emergency_contact_name="Pikachu", + emergency_contact_phone="+33123456789", + first_year=False, + ) + Membership.objects.create(user=self.user, club=Club.objects.get(name="BDE")) + Membership.objects.create(user=self.user, club=Club.objects.get(name="Kfet")) + self.membership = WEIMembership.objects.create( + user=self.user, + club=self.wei, + fee=125, + bus=self.bus, + team=self.team, + registration=self.registration, + ) + self.membership.roles.add(WEIRole.objects.last()) + self.membership.save() + + def test_wei_api(self): + """ + Load API pages for the treasury app and test all filters + """ + self.check_viewset(WEIClubViewSet, "/api/wei/club/") + self.check_viewset(BusViewSet, "/api/wei/bus/") + self.check_viewset(BusTeamViewSet, "/api/wei/team/") + self.check_viewset(WEIRoleViewSet, "/api/wei/role/") + self.check_viewset(WEIRegistrationViewSet, "/api/wei/registration/") + self.check_viewset(WEIMembershipViewSet, "/api/wei/membership/") From 5cb4183e9f9ff44ccb131b39d482172a496024e9 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 23 Dec 2020 15:11:33 +0100 Subject: [PATCH 17/28] Use python Warnings instead of printing messages during tests Signed-off-by: Yohann D'ANELLO --- apps/api/tests.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/api/tests.py b/apps/api/tests.py index 841e9816..88290a4f 100644 --- a/apps/api/tests.py +++ b/apps/api/tests.py @@ -4,6 +4,7 @@ import json from datetime import datetime from urllib.parse import quote_plus +from warnings import warn from django.contrib.auth.models import User from django.test import TestCase @@ -46,8 +47,8 @@ class TestAPI(TestCase): model = viewset.serializer_class.Meta.model if not model.objects.exists(): # pragma: no cover - print(f"Warning: unable to test API filters for the model {model._meta.verbose_name} " - "since there is no instance of it.") + warn(f"Warning: unable to test API filters for the model {model._meta.verbose_name} " + "since there is no instance of it.") return if hasattr(viewset, "filter_backends"): @@ -61,8 +62,8 @@ class TestAPI(TestCase): value = self.get_value(obj, field) if value is None: # pragma: no cover - print(f"Warning: the filter {field} for the model {model._meta.verbose_name} " - "has not been tested.") + warn(f"Warning: the filter {field} for the model {model._meta.verbose_name} " + "has not been tested.") continue resp = self.client.get(url + f"?format=json&{field}={quote_plus(str(value))}") self.assertEqual(resp.status_code, 200, f"The filter {field} for the model " @@ -90,8 +91,8 @@ class TestAPI(TestCase): field = field[1:] value = self.get_value(obj, field) if value is None: # pragma: no cover - print(f"Warning: the filter {field} for the model {model._meta.verbose_name} " - "has not been tested.") + warn(f"Warning: the filter {field} for the model {model._meta.verbose_name} " + "has not been tested.") continue resp = self.client.get(url + f"?format=json&search={quote_plus(str(value))}") self.assertEqual(resp.status_code, 200, f"The filter {field} for the model " From f570ff3cd5d26313c9d4da41da36207053930a29 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 23 Dec 2020 18:21:59 +0100 Subject: [PATCH 18/28] Check that permissions are working when accessing to API pages Signed-off-by: Yohann D'ANELLO --- apps/activity/tests/test_activities.py | 17 +++++- apps/api/tests.py | 77 ++++++++++++++++++++++++- apps/member/tests/test_memberships.py | 14 ++++- apps/note/api/views.py | 9 ++- apps/note/tests/test_transactions.py | 29 +++++++++- apps/permission/decorators.py | 6 +- apps/treasury/tests/test_treasury.py | 24 +++++++- apps/wei/tests/test_wei_registration.py | 31 +++++++++- 8 files changed, 193 insertions(+), 14 deletions(-) diff --git a/apps/activity/tests/test_activities.py b/apps/activity/tests/test_activities.py index 366f9926..15635a6b 100644 --- a/apps/activity/tests/test_activities.py +++ b/apps/activity/tests/test_activities.py @@ -210,9 +210,24 @@ class TestActivityAPI(TestAPI): def test_activity_api(self): """ - Load API pages for the activity app and test all filters + Load Activity API page and test all filters and permissions """ self.check_viewset(ActivityViewSet, "/api/activity/activity/") + + def test_activity_type_api(self): + """ + Load ActivityType API page and test all filters and permissions + """ self.check_viewset(ActivityTypeViewSet, "/api/activity/type/") + + def test_entry_api(self): + """ + Load Entry API page and test all filters and permissions + """ self.check_viewset(EntryViewSet, "/api/activity/entry/") + + def test_guest_api(self): + """ + Load Guest API page and test all filters and permissions + """ self.check_viewset(GuestViewSet, "/api/activity/guest/") diff --git a/apps/api/tests.py b/apps/api/tests.py index 88290a4f..6d2b09d1 100644 --- a/apps/api/tests.py +++ b/apps/api/tests.py @@ -2,14 +2,18 @@ # SPDX-License-Identifier: GPL-3.0-or-later import json -from datetime import datetime +from datetime import datetime, date from urllib.parse import quote_plus from warnings import warn from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType +from django.db.models.fields.files import ImageFieldFile from django.test import TestCase from django_filters.rest_framework import DjangoFilterBackend, OrderingFilter +from member.models import Membership, Club from note.models import NoteClub, NoteUser, Alias, Note +from permission.models import PermissionMask, Permission, Role from phonenumbers import PhoneNumber from rest_framework.filters import SearchFilter @@ -103,6 +107,77 @@ class TestAPI(TestCase): f"{model._meta.verbose_name} does not work. " f"Given parameter: {value}") + self.check_permissions(url, obj) + + def check_permissions(self, url, obj): + """ + Check that permissions are working + """ + # Drop rights + self.user.is_superuser = False + self.user.save() + sess = self.client.session + sess["permission_mask"] = 0 + sess.save() + + # Delete user permissions + for m in Membership.objects.filter(user=self.user).all(): + m.roles.clear() + m.save() + + # Create a new role, which will have the checking permission + role = Role.objects.get_or_create(name="β-tester")[0] + role.permissions.clear() + role.save() + membership = Membership.objects.get_or_create(user=self.user, club=Club.objects.get(name="BDE"))[0] + membership.roles.set([role]) + membership.save() + + # Ensure that the access to the object is forbidden without permission + resp = self.client.get(url + f"{obj.pk}/") + self.assertEqual(resp.status_code, 404, f"Mysterious access to {url}{obj.pk}/ for {obj}") + + obj.refresh_from_db() + + # There are problems with polymorphism + if isinstance(obj, Note) and hasattr(obj, "note_ptr"): + obj = obj.note_ptr + + mask = PermissionMask.objects.get(rank=0) + + for field in obj._meta.fields: + # Build permission query + value = self.get_value(obj, field.name) + if isinstance(value, date) or isinstance(value, datetime): + value = value.isoformat() + elif isinstance(value, ImageFieldFile): + value = value.name + query = json.dumps({field.name: value}) + + # Create sample permission + permission = Permission.objects.get_or_create( + model=ContentType.objects.get_for_model(obj._meta.model), + query=query, + mask=mask, + type="view", + permanent=False, + description=f"Can view {obj._meta.verbose_name}", + )[0] + role.permissions.set([permission]) + role.save() + + # Check that the access is possible + resp = self.client.get(url + f"{obj.pk}/") + self.assertEqual(resp.status_code, 200, f"Permission {permission.query} is not working " + f"for the model {obj._meta.verbose_name}") + + # Restore rights + self.user.is_superuser = True + self.user.save() + sess = self.client.session + sess["permission_mask"] = 42 + sess.save() + @staticmethod def get_value(obj, key: str): """ diff --git a/apps/member/tests/test_memberships.py b/apps/member/tests/test_memberships.py index 39ee98d8..1bbae1c7 100644 --- a/apps/member/tests/test_memberships.py +++ b/apps/member/tests/test_memberships.py @@ -432,10 +432,20 @@ class TestMemberAPI(TestAPI): self.membership.roles.add(Role.objects.get(name="Bureau de club")) self.membership.save() - def test_member_api(self): + def test_club_api(self): """ - Load API pages for the member app and test all filters + Load Club API page and test all filters and permissions """ self.check_viewset(ClubViewSet, "/api/members/club/") + + def test_profile_api(self): + """ + Load Profile API page and test all filters and permissions + """ self.check_viewset(ProfileViewSet, "/api/members/profile/") + + def test_membership_api(self): + """ + Load Membership API page and test all filters and permissions + """ self.check_viewset(MembershipViewSet, "/api/members/membership/") diff --git a/apps/note/api/views.py b/apps/note/api/views.py index 4ac6ffbf..517450c7 100644 --- a/apps/note/api/views.py +++ b/apps/note/api/views.py @@ -15,7 +15,7 @@ from permission.backends import PermissionBackend from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer,\ TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer -from ..models.notes import Note, Alias +from ..models.notes import Note, Alias, NoteUser, NoteClub, NoteSpecial from ..models.transactions import TransactionTemplate, Transaction, TemplateCategory @@ -40,7 +40,12 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet): Parse query and apply filters. :return: The filtered set of requested notes """ - queryset = super().get_queryset().distinct() + user = self.request.user + get_current_session().setdefault("permission_mask", 42) + queryset = self.queryset.filter(PermissionBackend.filter_queryset(user, Note, "view") + | PermissionBackend.filter_queryset(user, NoteUser, "view") + | PermissionBackend.filter_queryset(user, NoteClub, "view") + | PermissionBackend.filter_queryset(user, NoteSpecial, "view")).distinct() alias = self.request.query_params.get("alias", ".*") queryset = queryset.filter( diff --git a/apps/note/tests/test_transactions.py b/apps/note/tests/test_transactions.py index 7e999cca..0626c453 100644 --- a/apps/note/tests/test_transactions.py +++ b/apps/note/tests/test_transactions.py @@ -399,13 +399,38 @@ class TestNoteAPI(TestAPI): description="Test template", ) - def test_note_api(self): + def test_alias_api(self): """ - Load API pages for the note app and test all filters + Load Alias API page and test all filters and permissions """ self.check_viewset(AliasViewSet, "/api/note/alias/") + + def test_consumer_api(self): + """ + Load Consumer API page and test all filters and permissions + """ self.check_viewset(ConsumerViewSet, "/api/note/consumer/") + + def test_note_api(self): + """ + Load Note API page and test all filters and permissions + """ self.check_viewset(NotePolymorphicViewSet, "/api/note/note/") + + def test_template_category_api(self): + """ + Load TemplateCategory API page and test all filters and permissions + """ self.check_viewset(TemplateCategoryViewSet, "/api/note/transaction/category/") + + def test_transaction_template_api(self): + """ + Load TemplateTemplate API page and test all filters and permissions + """ self.check_viewset(TransactionTemplateViewSet, "/api/note/transaction/template/") + + def test_transaction_api(self): + """ + Load Transaction API page and test all filters and permissions + """ self.check_viewset(TransactionViewSet, "/api/note/transaction/transaction/") diff --git a/apps/permission/decorators.py b/apps/permission/decorators.py index 8ab35697..11edac43 100644 --- a/apps/permission/decorators.py +++ b/apps/permission/decorators.py @@ -1,6 +1,6 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later - +import sys from functools import lru_cache from time import time @@ -38,6 +38,10 @@ def memoize(f): nonlocal last_collect + if "test" in sys.argv: + # In a test environment, don't memoize permissions + return f(*args, **kwargs) + if time() - last_collect > 60: # Clear cache collect() diff --git a/apps/treasury/tests/test_treasury.py b/apps/treasury/tests/test_treasury.py index fba9c447..e51054b6 100644 --- a/apps/treasury/tests/test_treasury.py +++ b/apps/treasury/tests/test_treasury.py @@ -453,12 +453,32 @@ class TestTreasuryAPI(TestAPI): self.kfet_membership._soge = True self.kfet_membership.save() - def test_treasury_api(self): + def test_invoice_api(self): """ - Load API pages for the treasury app and test all filters + Load Invoice API page and test all filters and permissions """ self.check_viewset(InvoiceViewSet, "/api/treasury/invoice/") + + def test_product_api(self): + """ + Load Product API page and test all filters and permissions + """ self.check_viewset(ProductViewSet, "/api/treasury/product/") + + def test_remittance_api(self): + """ + Load Remittance API page and test all filters and permissions + """ self.check_viewset(RemittanceViewSet, "/api/treasury/remittance/") + + def test_remittance_type_api(self): + """ + Load RemittanceType API page and test all filters and permissions + """ self.check_viewset(RemittanceTypeViewSet, "/api/treasury/remittance_type/") + + def test_sogecredit_api(self): + """ + Load SogeCredit API page and test all filters and permissions + """ self.check_viewset(SogeCreditViewSet, "/api/treasury/soge_credit/") diff --git a/apps/wei/tests/test_wei_registration.py b/apps/wei/tests/test_wei_registration.py index 07564bf6..92ceb289 100644 --- a/apps/wei/tests/test_wei_registration.py +++ b/apps/wei/tests/test_wei_registration.py @@ -527,7 +527,7 @@ class TestWEIRegistration(TestCase): sess["permission_mask"] = 0 sess.save() response = self.client.get(reverse("wei:wei_update_registration", kwargs=dict(pk=self.registration.pk))) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 403) sess["permission_mask"] = 42 sess.save() @@ -869,13 +869,38 @@ class TestWeiAPI(TestAPI): self.membership.roles.add(WEIRole.objects.last()) self.membership.save() - def test_wei_api(self): + def test_weiclub_api(self): """ - Load API pages for the treasury app and test all filters + Load WEI API page and test all filters and permissions """ self.check_viewset(WEIClubViewSet, "/api/wei/club/") + + def test_wei_bus_api(self): + """ + Load Bus API page and test all filters and permissions + """ self.check_viewset(BusViewSet, "/api/wei/bus/") + + def test_wei_team_api(self): + """ + Load BusTeam API page and test all filters and permissions + """ self.check_viewset(BusTeamViewSet, "/api/wei/team/") + + def test_weirole_api(self): + """ + Load WEIRole API page and test all filters and permissions + """ self.check_viewset(WEIRoleViewSet, "/api/wei/role/") + + def test_weiregistration_api(self): + """ + Load WEIRegistration API page and test all filters and permissions + """ self.check_viewset(WEIRegistrationViewSet, "/api/wei/registration/") + + def test_weimembership_api(self): + """ + Load WEIMembership API page and test all filters and permissions + """ self.check_viewset(WEIMembershipViewSet, "/api/wei/membership/") From 7866ab7ec0566dade6d09f2c410cd07d870486ce Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 23 Dec 2020 18:25:54 +0100 Subject: [PATCH 19/28] Ordering filters are now properly tested Signed-off-by: Yohann D'ANELLO --- apps/api/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/tests.py b/apps/api/tests.py index 6d2b09d1..203e592a 100644 --- a/apps/api/tests.py +++ b/apps/api/tests.py @@ -10,12 +10,12 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.db.models.fields.files import ImageFieldFile from django.test import TestCase -from django_filters.rest_framework import DjangoFilterBackend, OrderingFilter +from django_filters.rest_framework import DjangoFilterBackend from member.models import Membership, Club from note.models import NoteClub, NoteUser, Alias, Note from permission.models import PermissionMask, Permission, Role from phonenumbers import PhoneNumber -from rest_framework.filters import SearchFilter +from rest_framework.filters import SearchFilter, OrderingFilter from .viewsets import ContentTypeViewSet, UserViewSet From 016ab5a9c999ed0adfa1fefd799ba0963cfba0ca Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 23 Dec 2020 18:45:05 +0100 Subject: [PATCH 20/28] Remove dead code, don't try to cover unnecessary things Signed-off-by: Yohann D'ANELLO --- apps/permission/templatetags/perms.py | 41 ------------------- .../tests/test_permission_queries.py | 2 +- apps/permission/views.py | 2 +- apps/treasury/tables.py | 3 -- apps/treasury/tests/test_treasury.py | 7 +--- note_kfet/settings/secrets_example.py | 7 ++-- tox.ini | 2 +- 7 files changed, 9 insertions(+), 55 deletions(-) diff --git a/apps/permission/templatetags/perms.py b/apps/permission/templatetags/perms.py index 335721a1..7841f400 100644 --- a/apps/permission/templatetags/perms.py +++ b/apps/permission/templatetags/perms.py @@ -5,7 +5,6 @@ from django.contrib.auth.models import AnonymousUser from django.contrib.contenttypes.models import ContentType from django.template.defaultfilters import stringfilter from django import template -from note.models import Transaction from note_kfet.middlewares import get_current_authenticated_user, get_current_session from permission.backends import PermissionBackend @@ -25,21 +24,6 @@ def not_empty_model_list(model_name): return qs.exists() -@stringfilter -def not_empty_model_change_list(model_name): - """ - Return True if and only if the current user has right to change any object of the given model. - """ - user = get_current_authenticated_user() - session = get_current_session() - if user is None or isinstance(user, AnonymousUser): - return False - elif user.is_superuser and session.get("permission_mask", -1) >= 42: - return True - qs = model_list(model_name, "change") - return qs.exists() - - @stringfilter def model_list(model_name, t="view", fetch=True): """ @@ -68,33 +52,8 @@ def has_perm(perm, obj): return PermissionBackend.check_perm(get_current_authenticated_user(), perm, obj) -def can_create_transaction(): - """ - :return: True iff the authenticated user can create a transaction. - """ - user = get_current_authenticated_user() - session = get_current_session() - if user is None or isinstance(user, AnonymousUser): - return False - elif user.is_superuser and session.get("permission_mask", -1) >= 42: - return True - if session.get("can_create_transaction", None): - return session.get("can_create_transaction", None) == 1 - - empty_transaction = Transaction( - source=user.note, - destination=user.note, - quantity=1, - amount=0, - reason="Check permissions", - ) - session["can_create_transaction"] = PermissionBackend.check_perm(user, "note.add_transaction", empty_transaction) - return session.get("can_create_transaction") == 1 - - register = template.Library() register.filter('not_empty_model_list', not_empty_model_list) -register.filter('not_empty_model_change_list', not_empty_model_change_list) register.filter('model_list', model_list) register.filter('model_list_length', model_list_length) register.filter('has_perm', has_perm) diff --git a/apps/permission/tests/test_permission_queries.py b/apps/permission/tests/test_permission_queries.py index fdd530a5..95b8b7b1 100644 --- a/apps/permission/tests/test_permission_queries.py +++ b/apps/permission/tests/test_permission_queries.py @@ -78,7 +78,7 @@ class PermissionQueryTestCase(TestCase): query = instanced.query model = perm.model.model_class() model.objects.filter(query).all() - except (FieldError, AttributeError, ValueError, TypeError, JSONDecodeError): + except (FieldError, AttributeError, ValueError, TypeError, JSONDecodeError): # pragma: no cover print("Query error for permission", perm) print("Query:", perm.query) if instanced.query: diff --git a/apps/permission/views.py b/apps/permission/views.py index d77133d6..9ff9b50d 100644 --- a/apps/permission/views.py +++ b/apps/permission/views.py @@ -85,7 +85,7 @@ class ProtectedCreateView(LoginRequiredMixin, CreateView): If not, a 403 error is displayed. """ - def get_sample_object(self): + def get_sample_object(self): # pragma: no cover """ return a sample instance of the Model. It should be valid (can be stored properly in database), but must not collide with existing data. diff --git a/apps/treasury/tables.py b/apps/treasury/tables.py index 44415061..77d39a0e 100644 --- a/apps/treasury/tables.py +++ b/apps/treasury/tables.py @@ -109,9 +109,6 @@ class SpecialTransactionTable(tables.Table): 'a': {'class': 'btn btn-primary btn-danger'} }, ) - def render_id(self, record): - return record.specialtransactionproxy.pk - def render_amount(self, value): return pretty_money(value) diff --git a/apps/treasury/tests/test_treasury.py b/apps/treasury/tests/test_treasury.py index e51054b6..98fb2249 100644 --- a/apps/treasury/tests/test_treasury.py +++ b/apps/treasury/tests/test_treasury.py @@ -370,11 +370,8 @@ class TestSogeCredits(TestCase): response = self.client.get(reverse("treasury:manage_soge_credit", args=(soge_credit.pk,))) self.assertEqual(response.status_code, 200) - try: - self.client.post(reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)), data=dict(delete=True)) - raise AssertionError("It is not possible to delete the soge credit until the note is not credited.") - except ValidationError: - pass + self.assertRaises(ValidationError, self.client.post, + reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)), data=dict(delete=True)) SpecialTransaction.objects.create( source=NoteSpecial.objects.get(special_type="Carte bancaire"), diff --git a/note_kfet/settings/secrets_example.py b/note_kfet/settings/secrets_example.py index 656e558b..61d92359 100644 --- a/note_kfet/settings/secrets_example.py +++ b/note_kfet/settings/secrets_example.py @@ -1,12 +1,13 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -# CAS OPTIONAL_APPS = [ -# 'debug_toolbar' + # 'cas_server', + # 'debug_toolbar', + # 'django_extensions', ] -# When a server error occured, send an email to these addresses +# When a server error occurred, send an email to these addresses ADMINS = ( ('Note Kfet', 'notekfet@example.com'), ) diff --git a/tox.ini b/tox.ini index 1c32a412..d972e34b 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,7 @@ deps = -r{toxinidir}/requirements.txt coverage commands = - coverage run --omit='*migrations*,apps/scripts*' --source=apps,note_kfet ./manage.py test apps/ + coverage run --omit='apps/scripts*,*_example.py,note_kfet/wsgi.py' --source=apps,note_kfet ./manage.py test apps/ coverage report -m [testenv:linters] From d3a9c442a52374602ff37e0e0ab70ed6ebb6f595 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 23 Dec 2020 18:48:09 +0100 Subject: [PATCH 21/28] Test the note kfet with Debian Bullseye, Python 3.9 and Django 2.2 Signed-off-by: Yohann D'ANELLO --- .gitlab-ci.yml | 15 +++++++++++++++ tox.ini | 3 +++ 2 files changed, 18 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2181b4b5..3fb22574 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -38,6 +38,21 @@ py38-django22: python3-bs4 python3-setuptools tox texlive-xetex script: tox -e py38-django22 +# Debian Bullseye +py39-django22: + stage: test + image: debian:bullseye + before_script: + - > + apt-get update && + apt-get install --no-install-recommends -y + python3-django python3-django-crispy-forms + python3-django-extensions python3-django-filters python3-django-polymorphic + python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil + python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache + python3-bs4 python3-setuptools tox texlive-xetex + script: tox -e py39-django22 + linters: stage: quality-assurance image: debian:buster-backports diff --git a/tox.ini b/tox.ini index d972e34b..c900d524 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,9 @@ envlist = # Ubuntu 20.04 Python py38-django22 + # Debian Bullseye Python + py39-django22 + linters skipsdist = True From b4a1b513ccb52cb81ba12aa92ddeea3b8b409813 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Tue, 29 Dec 2020 20:05:15 +0100 Subject: [PATCH 22/28] Good bye bde3-virt, welcome bde-note-dev! Signed-off-by: Yohann D'ANELLO --- ...e3-virt.adh.crans.org.yml => bde-note-dev.adh.crans.org.yml} | 0 ansible/hosts_example | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename ansible/host_vars/{bde3-virt.adh.crans.org.yml => bde-note-dev.adh.crans.org.yml} (100%) diff --git a/ansible/host_vars/bde3-virt.adh.crans.org.yml b/ansible/host_vars/bde-note-dev.adh.crans.org.yml similarity index 100% rename from ansible/host_vars/bde3-virt.adh.crans.org.yml rename to ansible/host_vars/bde-note-dev.adh.crans.org.yml diff --git a/ansible/hosts_example b/ansible/hosts_example index 10d86488..79526d3d 100644 --- a/ansible/hosts_example +++ b/ansible/hosts_example @@ -1,5 +1,5 @@ [dev] -bde3-virt.adh.crans.org +bde-note-dev.adh.crans.org bde-nk20-beta.adh.crans.org [prod] From 3aad4e7398c39c26ce32e1007c83d04607daa639 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Tue, 29 Dec 2020 21:41:29 +0100 Subject: [PATCH 23/28] Agree Let's Encrypt ToS Signed-off-by: Yohann D'ANELLO --- ansible/roles/4-certbot/tasks/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible/roles/4-certbot/tasks/main.yml b/ansible/roles/4-certbot/tasks/main.yml index dbd6e477..0056480f 100644 --- a/ansible/roles/4-certbot/tasks/main.yml +++ b/ansible/roles/4-certbot/tasks/main.yml @@ -31,7 +31,7 @@ state: stopped - name: Generate new certificate if one doesn't exist. - shell: "certbot certonly --non-interactive --config /etc/letsencrypt/conf.d/nk20.ini -d {{note.server_name}}" + shell: "certbot certonly --non-interactive --agree-tos --config /etc/letsencrypt/conf.d/nk20.ini -d {{note.server_name}}" when: letsencrypt_cert.stat.exists == False - name: Restart services to allow certbot to generate a cert. From b5f3b3ffc1fd5e9398573fc521ee78d9df376453 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Tue, 29 Dec 2020 21:42:16 +0100 Subject: [PATCH 24/28] Use Nginx certbot challenge Signed-off-by: Yohann D'ANELLO --- .../roles/4-certbot/templates/letsencrypt/conf.d/nk20.ini.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible/roles/4-certbot/templates/letsencrypt/conf.d/nk20.ini.j2 b/ansible/roles/4-certbot/templates/letsencrypt/conf.d/nk20.ini.j2 index 272e160d..fc3b0aac 100644 --- a/ansible/roles/4-certbot/templates/letsencrypt/conf.d/nk20.ini.j2 +++ b/ansible/roles/4-certbot/templates/letsencrypt/conf.d/nk20.ini.j2 @@ -16,5 +16,5 @@ email = {{ note.email }} text = True # Use DNS-01 challenge -authenticator = standalone +authenticator = nginx From dfbf9972c2d37a54a41b062268af2e2468324544 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Tue, 29 Dec 2020 23:27:51 +0100 Subject: [PATCH 25/28] By default, automatically change directory to /var/www/note_kfet and source the Python virtual environment in the .bashrc file Signed-off-by: Yohann D'ANELLO --- ansible/roles/2-nk20/tasks/main.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ansible/roles/2-nk20/tasks/main.yml b/ansible/roles/2-nk20/tasks/main.yml index 3852894d..e39863b5 100644 --- a/ansible/roles/2-nk20/tasks/main.yml +++ b/ansible/roles/2-nk20/tasks/main.yml @@ -36,3 +36,13 @@ dest: /etc/cron.d/note owner: root group: root + +- name: Set default directory to /var/www/note_kfet + lineinfile: + path: /etc/skel/.bashrc + line: 'cd /var/www/note_kfet' + +- name: Automatically source Python virtual environment + lineinfile: + path: /etc/skel/.bashrc + line: 'source /var/www/note_kfet/env/bin/activate' From 893534955db8ab16ba1faa181ba883cdb72a60c5 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 30 Dec 2020 00:04:08 +0100 Subject: [PATCH 26/28] Use the Debian mirror of Crans Signed-off-by: Yohann D'ANELLO --- ansible/base.yml | 2 +- ansible/host_vars/bde-note.adh.crans.org.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/ansible/base.yml b/ansible/base.yml index 950aafa5..a2fca768 100755 --- a/ansible/base.yml +++ b/ansible/base.yml @@ -7,7 +7,7 @@ prompt: "Password of the database (leave it blank to skip database init)" private: yes vars: - mirror: deb.debian.org + mirror: mirror.crans.org roles: - 1-apt-basic - 2-nk20 diff --git a/ansible/host_vars/bde-note.adh.crans.org.yml b/ansible/host_vars/bde-note.adh.crans.org.yml index ba085433..8405dc0f 100644 --- a/ansible/host_vars/bde-note.adh.crans.org.yml +++ b/ansible/host_vars/bde-note.adh.crans.org.yml @@ -3,3 +3,4 @@ note: server_name: note.crans.org git_branch: master cron_enabled: true + email: notekfet2020@lists.crans.org From d9c97628e259d3041d3be6156c6c60b94e84841b Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Thu, 31 Dec 2020 15:40:18 +0100 Subject: [PATCH 27/28] Add Clacks Overhead header on each response. Closes #84 Signed-off-by: Yohann D'ANELLO --- note_kfet/middlewares.py | 14 ++++++++++++++ note_kfet/settings/base.py | 1 + 2 files changed, 15 insertions(+) diff --git a/note_kfet/middlewares.py b/note_kfet/middlewares.py index f545d839..cf99c99f 100644 --- a/note_kfet/middlewares.py +++ b/note_kfet/middlewares.py @@ -142,3 +142,17 @@ class TurbolinksMiddleware(object): location = request.session.pop('_turbolinks_redirect_to') response['Turbolinks-Location'] = location return response + + +class ClacksMiddleware(object): + """ + Add Clacks Overhead header on each response. + See https://www.gnuterrypratchett.com/ + """ + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + response['X-Clacks-Overhead'] = 'GNU Terry Pratchett' + return response diff --git a/note_kfet/settings/base.py b/note_kfet/settings/base.py index 1cbf6ed7..bd6677d3 100644 --- a/note_kfet/settings/base.py +++ b/note_kfet/settings/base.py @@ -82,6 +82,7 @@ MIDDLEWARE = [ 'note_kfet.middlewares.SessionMiddleware', 'note_kfet.middlewares.LoginByIPMiddleware', 'note_kfet.middlewares.TurbolinksMiddleware', + 'note_kfet.middlewares.ClacksMiddleware', ] ROOT_URLCONF = 'note_kfet.urls' From a6f23df7d5d758d96408c64d20f3d731bbc65f9d Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Tue, 19 Jan 2021 11:58:19 +0100 Subject: [PATCH 28/28] Load the good translation file, fixes #85 Signed-off-by: Yohann D'ANELLO --- note_kfet/templates/base.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/note_kfet/templates/base.html b/note_kfet/templates/base.html index 77b3b19d..cd902d32 100644 --- a/note_kfet/templates/base.html +++ b/note_kfet/templates/base.html @@ -39,7 +39,7 @@ SPDX-License-Identifier: GPL-3.0-or-later {# Translation in javascript files #} - + {# If extra ressources are needed for a form, load here #} {% if form.media %}