From 06679b2e6a718ac936e2e08c1fc87a61dbe07e18 Mon Sep 17 00:00:00 2001 From: Alexandre Iooss Date: Wed, 2 Sep 2020 22:27:04 +0200 Subject: [PATCH 01/95] Remove deprecation warning and enable some linting rules --- tox.ini | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index 240c9523..51429fd0 100644 --- a/tox.ini +++ b/tox.ini @@ -11,8 +11,6 @@ skipsdist = True [testenv] sitepackages = True -setenv = - PYTHONWARNINGS = all deps = -r{toxinidir}/requirements.txt coverage @@ -34,8 +32,7 @@ commands = flake8 apps/activity apps/api apps/logs apps/member apps/note apps/permission apps/treasury apps/wei [flake8] -# Ignore too many errors, should be reduced in the future -ignore = D203, W503, E203, I100, I101, C901 +ignore = I100, I101 exclude = .tox, .git, From 3d20987b18877aefa4a0fb2489a516dd9893beff Mon Sep 17 00:00:00 2001 From: Alexandre Iooss Date: Wed, 2 Sep 2020 22:33:31 +0200 Subject: [PATCH 02/95] Ignore W503 in linting --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 51429fd0..ad5cdc6f 100644 --- a/tox.ini +++ b/tox.ini @@ -32,7 +32,7 @@ commands = flake8 apps/activity apps/api apps/logs apps/member apps/note apps/permission apps/treasury apps/wei [flake8] -ignore = I100, I101 +ignore = W503, I100, I101 exclude = .tox, .git, From 1b8cb7abb031cd3ec6949567848db723b470aff8 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 2 Sep 2020 22:53:43 +0200 Subject: [PATCH 03/95] Send user id and group id in Docker console --- apps/scripts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/scripts b/apps/scripts index 1145f75a..4e1bcd18 160000 --- a/apps/scripts +++ b/apps/scripts @@ -1 +1 @@ -Subproject commit 1145f75a968999f24f9feb3fc82946aba14fb45d +Subproject commit 4e1bcd1808a24b532aa27bf2a119f6f8155af534 From bf7f5b9cd6f52099060f43c4a1abc577c08d1845 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 2 Sep 2020 22:54:01 +0200 Subject: [PATCH 04/95] Test and cover fully member app --- apps/member/admin.py | 4 +- apps/member/models.py | 54 +-- apps/member/signals.py | 6 +- .../templates/member/manage_auth_tokens.html | 2 +- apps/member/tests/test_login.py | 37 +- apps/member/tests/test_memberships.py | 407 ++++++++++++++++++ apps/member/views.py | 90 ++-- apps/note/api/views.py | 5 +- 8 files changed, 490 insertions(+), 115 deletions(-) create mode 100644 apps/member/tests/test_memberships.py diff --git a/apps/member/admin.py b/apps/member/admin.py index 4cc2d0bf..7936f564 100644 --- a/apps/member/admin.py +++ b/apps/member/admin.py @@ -31,9 +31,7 @@ class CustomUserAdmin(UserAdmin): """ When creating a new user don't show profile one the first step """ - if not obj: - return list() - return super().get_inline_instances(request, obj) + return super().get_inline_instances(request, obj) if obj else [] @admin.register(Club, site=admin_site) diff --git a/apps/member/models.py b/apps/member/models.py index b17f1f09..a1628fae 100644 --- a/apps/member/models.py +++ b/apps/member/models.py @@ -339,43 +339,40 @@ class Membership(models.Model): return self.date_start.toordinal() <= datetime.datetime.now().toordinal() def renew(self): - if Membership.objects.filter( + if not Membership.objects.filter( user=self.user, club=self.club, date_start__gte=self.club.membership_start, ).exists(): - # Membership is already renewed - return - new_membership = Membership( - user=self.user, - club=self.club, - date_start=max(self.date_end + datetime.timedelta(days=1), self.club.membership_start), - ) - if hasattr(self, '_force_renew_parent') and self._force_renew_parent: - new_membership._force_renew_parent = True - if hasattr(self, '_soge') and self._soge: - new_membership._soge = True - if hasattr(self, '_force_save') and self._force_save: - new_membership._force_save = True - new_membership.save() - new_membership.roles.set(self.roles.all()) - new_membership.save() + # Membership is not renewed yet + new_membership = Membership( + user=self.user, + club=self.club, + date_start=max(self.date_end + datetime.timedelta(days=1), self.club.membership_start), + ) + if hasattr(self, '_force_renew_parent') and self._force_renew_parent: + new_membership._force_renew_parent = True + if hasattr(self, '_soge') and self._soge: + new_membership._soge = True + if hasattr(self, '_force_save') and self._force_save: + new_membership._force_save = True + new_membership.save() + new_membership.roles.set(self.roles.all()) + new_membership.save() def save(self, *args, **kwargs): """ Calculate fee and end date before saving the membership and creating the transaction if needed. """ - - if self.pk: + created = not self.pk + if not created: for role in self.roles.all(): club = role.for_club if club is not None: if club.pk != self.club_id: raise ValidationError(_('The role {role} does not apply to the club {club}.') .format(role=role.name, club=club.name)) - - created = not self.pk - if created: + else: if Membership.objects.filter( user=self.user, club=self.club, @@ -384,7 +381,7 @@ class Membership(models.Model): ).exists(): raise ValidationError(_('User is already a member of the club')) - if self.club.parent_club is not None and not self.pk: + if self.club.parent_club is not None: # Check that the user is already a member of the parent club if the membership is created if not Membership.objects.filter( user=self.user, @@ -433,15 +430,10 @@ class Membership(models.Model): raise ValidationError(_('User is not a member of the parent club') + ' ' + self.club.parent_club.name) - if self.user.profile.paid: - self.fee = self.club.membership_fee_paid - else: - self.fee = self.club.membership_fee_unpaid + self.fee = self.club.membership_fee_paid if self.user.profile.paid else self.club.membership_fee_unpaid - if self.club.membership_duration is not None: - self.date_end = self.date_start + datetime.timedelta(days=self.club.membership_duration) - else: - self.date_end = self.date_start + datetime.timedelta(days=424242) + self.date_end = self.date_start + datetime.timedelta(days=self.club.membership_duration) \ + if self.club.membership_duration is not None else self.date_start + datetime.timedelta(days=424242) if self.club.membership_end is not None and self.date_end > self.club.membership_end: self.date_end = self.club.membership_end diff --git a/apps/member/signals.py b/apps/member/signals.py index 70162b37..43659b01 100644 --- a/apps/member/signals.py +++ b/apps/member/signals.py @@ -6,11 +6,7 @@ def save_user_profile(instance, created, raw, **_kwargs): """ Hook to create and save a profile when an user is updated if it is not registered with the signup form """ - if raw: - # When provisionning data, do not try to autocreate - return - - if created and instance.is_active: + if not raw and created and instance.is_active: from .models import Profile Profile.objects.get_or_create(user=instance) if instance.is_superuser: diff --git a/apps/member/templates/member/manage_auth_tokens.html b/apps/member/templates/member/manage_auth_tokens.html index 473286c1..014686f1 100644 --- a/apps/member/templates/member/manage_auth_tokens.html +++ b/apps/member/templates/member/manage_auth_tokens.html @@ -1,4 +1,4 @@ -{% extends "member/base.html" %} +{% extends "base.html" %} {% comment %} SPDX-License-Identifier: GPL-3.0-or-later {% endcomment %} diff --git a/apps/member/tests/test_login.py b/apps/member/tests/test_login.py index 51a4ab94..c4467f81 100644 --- a/apps/member/tests/test_login.py +++ b/apps/member/tests/test_login.py @@ -1,8 +1,10 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later - +from django.conf import settings from django.contrib.auth.models import User from django.test import TestCase +from django.urls import reverse + from note.models import TransactionTemplate, TemplateCategory """ @@ -31,7 +33,20 @@ class TemplateLoggedInTests(TestCase): sess.save() def test_login_page(self): - response = self.client.get('/accounts/login/') + response = self.client.get(reverse("login")) + self.assertEqual(response.status_code, 200) + + self.client.logout() + + response = self.client.post('/accounts/login/', data=dict( + username="admin", + password="adminadmin", + permission_mask=3, + )) + self.assertRedirects(response, settings.LOGIN_REDIRECT_URL, 302, 200) + + def test_logout(self): + response = self.client.get(reverse("logout")) self.assertEqual(response.status_code, 200) def test_admin_index(self): @@ -42,21 +57,3 @@ class TemplateLoggedInTests(TestCase): response = self.client.get('/accounts/password_reset/') self.assertEqual(response.status_code, 200) - def test_logout_page(self): - response = self.client.get('/accounts/logout/') - self.assertEqual(response.status_code, 200) - - def test_transfer_page(self): - response = self.client.get('/note/transfer/') - self.assertEqual(response.status_code, 200) - - def test_consos_page(self): - # Create one button and ensure that it is visible - cat = TemplateCategory.objects.create() - TransactionTemplate.objects.create( - destination_id=5, - category=cat, - amount=0, - ) - response = self.client.get('/note/consos/') - self.assertEqual(response.status_code, 200) diff --git a/apps/member/tests/test_memberships.py b/apps/member/tests/test_memberships.py new file mode 100644 index 00000000..8ad7b7cb --- /dev/null +++ b/apps/member/tests/test_memberships.py @@ -0,0 +1,407 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +import hashlib +import os +from datetime import date, timedelta + +from django.conf import settings +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 + +""" +Create some users and clubs and test that all pages are rendering properly +and that memberships are working. +""" + + +class TestMemberships(TestCase): + fixtures = ('initial', ) + + def setUp(self) -> None: + """ + Create a sample superuser, a club and a membership for all tests. + """ + self.user = User.objects.create_superuser( + username="toto", + email="toto@example.com", + password="toto", + ) + self.user.profile.registration_valid = True + self.user.profile.email_confirmed = True + self.user.profile.save() + self.client.force_login(self.user) + + sess = self.client.session + sess["permission_mask"] = 42 + sess.save() + + self.club = Club.objects.create(name="totoclub", parent_club=Club.objects.get(name="BDE")) + 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_admin_pages(self): + """ + Check that Django Admin pages for the member app are loading successfully. + """ + response = self.client.get(reverse("admin:index") + "member/membership/") + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("admin:index") + "member/club/") + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("admin:index") + "auth/user/") + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("admin:index") + "auth/user/" + str(self.user.pk) + "/change/") + self.assertEqual(response.status_code, 200) + + def test_render_club_list(self): + """ + Render the list of all clubs, with a search. + """ + response = self.client.get(reverse("member:club_list")) + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("member:club_list") + "?search=toto") + self.assertEqual(response.status_code, 200) + + def test_render_club_create(self): + """ + Try to create a new club. + """ + response = self.client.get(reverse("member:club_create")) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("member:club_create"), data=dict( + name="Club toto", + email="clubtoto@example.com", + parent_club=self.club.pk, + require_memberships=False, + membership_fee_paid=0, + membership_fee_unpaid=0, + )) + self.assertTrue(Club.objects.filter(name="Club toto").exists()) + club = Club.objects.get(name="Club toto") + self.assertRedirects(response, club.get_absolute_url(), 302, 200) + + def test_render_club_detail(self): + """ + Display the detail of a club. + """ + response = self.client.get(reverse("member:club_detail", args=(self.club.pk,))) + self.assertEqual(response.status_code, 200) + + def test_render_club_update(self): + """ + Try to update the information about a club. + """ + response = self.client.get(reverse("member:club_update", args=(self.club.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("member:club_update", args=(self.club.pk, )), data=dict( + name="Toto club updated", + email="clubtoto@example.com", + require_memberships=True, + membership_fee_paid=0, + membership_fee_unpaid=0, + )) + self.assertRedirects(response, self.club.get_absolute_url(), 302, 200) + self.assertTrue(Club.objects.exclude(name="Toto club updated")) + + def test_render_club_update_picture(self): + """ + Try to update the picture of the note of a club. + """ + response = self.client.get(reverse("member:club_update_pic", args=(self.club.pk,))) + self.assertEqual(response.status_code, 200) + + old_pic = self.club.note.display_image + + with open("media/pic/default.png", "rb") as f: + image = SimpleUploadedFile("image.png", f.read(), "image/png") + response = self.client.post(reverse("member:club_update_pic", args=(self.club.pk,)), dict( + image=image, + x=0, + y=0, + width=200, + height=200, + )) + self.assertRedirects(response, self.club.get_absolute_url(), 302, 200) + + self.club.note.refresh_from_db() + self.assertTrue(os.path.exists(self.club.note.display_image.path)) + os.remove(self.club.note.display_image.path) + + self.club.note.display_image = old_pic + self.club.note.save() + + def test_render_club_aliases(self): + """ + Display the list of the aliases of a club. + """ + # Alias creation and deletion is already tested in the note app + response = self.client.get(reverse("member:club_alias", args=(self.club.pk,))) + self.assertEqual(response.status_code, 200) + + def test_render_club_members(self): + """ + Display the list of the members of a club. + """ + response = self.client.get(reverse("member:club_members", args=(self.club.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.get(reverse("member:club_members", args=(self.club.pk,)) + "?search=toto&roles=" + + ",".join([str(role.pk) for role in + Role.objects.filter(weirole__isnull=True).all()])) + self.assertEqual(response.status_code, 200) + + def test_render_club_add_member(self): + """ + Try to add memberships and renew them. + """ + response = self.client.get(reverse("member:club_add_member", args=(Club.objects.get(name="BDE").pk,))) + self.assertEqual(response.status_code, 200) + + user = User.objects.create(username="totototo") + user.profile.registration_valid = True + user.profile.email_confirmed = True + user.profile.save() + user.save() + + # We create a club without any parent and one club with parent BDE (that is the club Kfet) + for bde_parent in False, True: + if bde_parent: + club = Club.objects.get(name="Kfet") + else: + club = Club.objects.create( + name="Second club " + ("with BDE" if bde_parent else "without BDE"), + parent_club=None, + email="newclub@example.com", + require_memberships=True, + membership_fee_paid=1000, + membership_fee_unpaid=500, + membership_start=date.today(), + membership_end=date.today() + timedelta(days=366), + membership_duration=366, + ) + + response = self.client.get(reverse("member:club_add_member", args=(club.pk,))) + self.assertEqual(response.status_code, 200) + + # Create a new membership + response = self.client.post(reverse("member:club_add_member", args=(club.pk,)), data=dict( + user=user.pk, + date_start="{:%Y-%m-%d}".format(timezone.now().date()), + soge=False, + credit_type=NoteSpecial.objects.get(special_type="Espèces").id, + credit_amount=4200, + last_name="TOTO", + first_name="Toto", + bank="Le matelas", + )) + self.assertRedirects(response, club.get_absolute_url(), 302, 200) + + self.assertTrue(Membership.objects.filter(user=user, club=club).exists()) + + # Membership is sent to the past to check renewals + membership = Membership.objects.get(user=user, club=club) + self.assertTrue(membership.valid) + membership.date_start = date(year=2000, month=1, day=1) + membership.date_end = date(year=2000, month=12, day=31) + membership.save() + self.assertFalse(membership.valid) + + response = self.client.get(reverse("member:club_members", args=(club.pk,)) + "?only_active=0") + self.assertEqual(response.status_code, 200) + + bde_membership = self.bde_membership + if bde_parent: + bde_membership = Membership.objects.get(club__name="BDE", user=user) + bde_membership.date_start = date(year=2000, month=1, day=1) + bde_membership.date_end = date(year=2000, month=12, day=31) + bde_membership.save() + + response = self.client.get(reverse("member:club_renew_membership", args=(bde_membership.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.get(reverse("member:club_renew_membership", args=(membership.pk,))) + self.assertEqual(response.status_code, 200) + + # Renew membership + response = self.client.post(reverse("member:club_renew_membership", args=(membership.pk,)), data=dict( + user=user.pk, + date_start="{:%Y-%m-%d}".format(timezone.now().date()), + soge=bde_parent, + credit_type=NoteSpecial.objects.get(special_type="Chèque").id, + credit_amount=14242, + last_name="TOTO", + first_name="Toto", + bank="Bank", + )) + self.assertRedirects(response, club.get_absolute_url(), 302, 200) + + response = self.client.get(user.profile.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + def test_auto_join_kfet_when_join_bde_with_soge(self): + """ + When we join the BDE club with a Soge registration, a Kfet membership is automatically created. + We check that it is the case. + """ + user = User.objects.create(username="new1A") + user.profile.registration_valid = True + user.profile.email_confirmed = True + user.profile.save() + user.save() + + bde = Club.objects.get(name="BDE") + kfet = Club.objects.get(name="Kfet") + + response = self.client.post(reverse("member:club_add_member", args=(bde.pk,)), data=dict( + user=user.pk, + date_start="{:%Y-%m-%d}".format(timezone.now().date()), + soge=True, + credit_type=NoteSpecial.objects.get(special_type="Virement bancaire").id, + credit_amount=(bde.membership_fee_paid + kfet.membership_fee_paid) / 100, + last_name="TOTO", + first_name="Toto", + bank="Société générale", + )) + self.assertRedirects(response, bde.get_absolute_url(), 302, 200) + + self.assertTrue(Membership.objects.filter(user=user, club=bde).exists()) + self.assertTrue(Membership.objects.filter(user=user, club=kfet).exists()) + self.assertTrue(SogeCredit.objects.filter(user=user).exists()) + + def test_change_roles(self): + """ + Check to change the roles of a membership. + """ + response = self.client.get(reverse("member:club_manage_roles", args=(self.membership.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("member:club_manage_roles", args=(self.membership.pk,)), data=dict( + roles=[role.id for role in Role.objects.filter( + Q(name="Membre de club") | Q(name="Trésorier·ère de club") | Q(name="Bureau de club")).all()], + )) + self.assertRedirects(response, self.user.profile.get_absolute_url(), 302, 200) + self.membership.refresh_from_db() + self.assertEqual(self.membership.roles.count(), 3) + + def test_render_user_list(self): + """ + Display the user search page. + """ + response = self.client.get(reverse("member:user_list")) + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("member:user_list") + "?search=toto") + self.assertEqual(response.status_code, 200) + + def test_render_user_detail(self): + """ + Display the user detail page. + """ + response = self.client.get(self.user.profile.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + def test_render_user_update(self): + """ + Update some data about the user. + """ + response = self.client.get(reverse("member:user_update_profile", args=(self.user.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("member:user_update_profile", args=(self.user.pk,)), data=dict( + first_name="Toto", + last_name="Toto", + username="toto changed", + email="updated@example.com", + phone_number="+33600000000", + section="", + department="A0", + promotion=timezone.now().year, + address="Earth", + paid=True, + ml_events_registration="en", + ml_sports_registration=True, + ml_art_registration=True, + report_frequency=7, + )) + self.assertRedirects(response, self.user.profile.get_absolute_url(), 302, 200) + self.assertTrue(User.objects.filter(username="toto changed").exists()) + self.assertTrue(Profile.objects.filter(address="Earth").exists()) + self.assertTrue(Alias.objects.filter(normalized_name="totochanged").exists()) + + def test_render_user_update_picture(self): + """ + Update the note picture of the user. + """ + response = self.client.get(reverse("member:user_update_pic", args=(self.user.pk,))) + self.assertEqual(response.status_code, 200) + + old_pic = self.user.note.display_image + + with open("media/pic/default.png", "rb") as f: + image = SimpleUploadedFile("image.png", f.read(), "image/png") + response = self.client.post(reverse("member:user_update_pic", args=(self.user.pk,)), dict( + image=image, + x=0, + y=0, + width=200, + height=200, + )) + self.assertRedirects(response, self.user.profile.get_absolute_url(), 302, 200) + + self.user.note.refresh_from_db() + self.assertTrue(os.path.exists(self.user.note.display_image.path)) + os.remove(self.user.note.display_image.path) + + self.user.note.display_image = old_pic + self.user.note.save() + + def test_render_user_aliases(self): + """ + Display the list of aliases of the user. + """ + # Alias creation and deletion is already tested in the note app + response = self.client.get(reverse("member:user_alias", args=(self.user.pk,))) + self.assertEqual(response.status_code, 200) + + def test_manage_auth_token(self): + """ + Display the page to see the API authentication token, see it and regenerate it. + :return: + """ + response = self.client.get(reverse("member:auth_token")) + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("member:auth_token") + "?view") + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("member:auth_token") + "?regenerate") + self.assertRedirects(response, reverse("member:auth_token") + "?view", 302, 200) + + def test_random_coverage(self): + # Useless, only for coverage + self.assertEqual(str(self.user), str(self.user.profile)) + self.user.profile.promotion = None + self.assertEqual(self.user.profile.ens_year, 0) + self.membership.date_end = None + self.assertTrue(self.membership.valid) + + def test_nk15_hasher(self): + """ + Test that NK15 passwords are successfully imported. + """ + salt = "42" + password = "strongpassword42" + hashed = hashlib.sha256((salt + password).encode("utf-8")).hexdigest() + self.user.password = "custom_nk15$1$" + salt + "|" + hashed + self.user.save() + self.assertTrue(self.user.check_password(password)) diff --git a/apps/member/views.py b/apps/member/views.py index cccffc4a..c2f9f136 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -97,8 +97,7 @@ class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): note = NoteUser.objects.filter( alias__normalized_name=Alias.normalize(new_username)) if note.exists() and note.get().user != self.object: - form.add_error('username', - _("An alias with a similar name already exists.")) + form.add_error('username', _("An alias with a similar name already exists.")) return super().form_invalid(form) # Check if the username is one of user's aliases. alias = Alias.objects.filter(name=new_username) @@ -142,9 +141,8 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): We can't display information of a not registered user. """ qs = super().get_queryset() - if self.request.user.is_superuser and self.request.session.get("permission_mask", -1) >= 42: - return qs - return qs.filter(profile__registration_valid=True) + return qs if self.request.user.is_superuser and self.request.session.get("permission_mask", -1) >= 42\ + else qs.filter(profile__registration_valid=True) def get_context_data(self, **kwargs): """ @@ -204,14 +202,16 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): """ Filter the user list with the given pattern. """ - qs = super().get_queryset().distinct("username").annotate(alias=F("note__alias__name"))\ + qs = super().get_queryset().annotate(alias=F("note__alias__name"))\ .annotate(normalized_alias=F("note__alias__normalized_name"))\ - .filter(profile__registration_valid=True).order_by("username") - if "search" in self.request.GET: - pattern = self.request.GET["search"] + .filter(profile__registration_valid=True) - if not pattern: - return qs.none() + # Sqlite doesn't support order by in subqueries + qs = qs.order_by("username").distinct("username")\ + if settings.DATABASES[qs.db]["ENGINE"] == 'django.db.backends.postgresql' else qs.distinct() + + if "search" in self.request.GET and self.request.GET["search"]: + pattern = self.request.GET["search"] qs = qs.filter( username__iregex="^" + pattern @@ -270,12 +270,7 @@ class PictureUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin, Det def post(self, request, *args, **kwargs): form = self.get_form() self.object = self.get_object() - if form.is_valid(): - return self.form_valid(form) - else: - print('is_invalid') - print(form) - return self.form_invalid(form) + return self.form_valid(form) if form.is_valid() else self.form_invalid(form) def form_valid(self, form): image_field = form.cleaned_data['image'] @@ -320,8 +315,7 @@ class ManageAuthTokens(LoginRequiredMixin, TemplateView): def get(self, request, *args, **kwargs): if 'regenerate' in request.GET and Token.objects.filter(user=request.user).exists(): Token.objects.get(user=self.request.user).delete() - return redirect(reverse_lazy('member:auth_token') + "?show", - permanent=True) + return redirect(reverse_lazy('member:auth_token') + "?show") return super().get(request, *args, **kwargs) @@ -351,8 +345,9 @@ class ClubCreateView(ProtectQuerysetMixin, ProtectedCreateView): email="", ) - def form_valid(self, form): - return super().form_valid(form) + def get_success_url(self): + self.object.refresh_from_db() + return reverse_lazy("member:club_detail", kwargs={"pk": self.object.pk}) class ClubListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): @@ -655,7 +650,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView): fee += c.membership_fee_paid if user.profile.paid else c.membership_fee_unpaid c = c.parent_club - if user.note.balance + credit_amount < fee and not Membership.objects.filter( + if not soge and user.note.balance + credit_amount < fee and not Membership.objects.filter( club__name="Kfet", user=user, date_start__lte=date.today(), @@ -683,7 +678,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView): if club.membership_end and form.instance.date_start > club.membership_end: form.add_error('user', _("The membership must begin before {:%m-%d-%Y}.") - .format(form.instance.club.membership_start)) + .format(form.instance.club.membership_end)) return super().form_invalid(form) # Now, all is fine, the membership can be created. @@ -719,46 +714,38 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView): transaction._force_save = True transaction.save() + # Parent club memberships are automatically renewed / created. + # For example, a Kfet membership creates a BDE membership if it does not exist. form.instance._force_renew_parent = True ret = super().form_valid(form) - if club.name == "BDE": - member_role = Role.objects.filter(Q(name="Adhérent BDE") | Q(name="Membre de club")).all() - elif club.name == "Kfet": - member_role = Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all() - else: - member_role = Role.objects.filter(name="Membre de club").all() + member_role = Role.objects.filter(Q(name="Adhérent BDE") | Q(name="Membre de club")).all() \ + if club.name == "BDE" else Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all() \ + if club.name == "Kfet"else Role.objects.filter(name="Membre de club").all() form.instance.roles.set(member_role) form.instance._force_save = True form.instance.save() # If Société générale pays, then we assume that this is the BDE membership, and we auto-renew the # Kfet membership. - if soge: - # If not already done, create BDE and Kfet memberships - bde = Club.objects.get(name="BDE") + if soge and club.name == "BDE": kfet = Club.objects.get(name="Kfet") + fee = kfet.membership_fee_paid if user.profile.paid else kfet.membership_fee_unpaid - soge_clubs = [bde, kfet] - for club in soge_clubs: - fee = club.membership_fee_paid if user.profile.paid else club.membership_fee_unpaid - - # Get current membership, to get the end date - old_membership = Membership.objects.filter( - club=club, - user=user, - ).order_by("-date_start") - - if old_membership.filter(date_start__gte=club.membership_start).exists(): - # Membership is already renewed - continue + # Get current membership, to get the end date + old_membership = Membership.objects.filter( + club=kfet, + user=user, + ).order_by("-date_start") + if not old_membership.filter(date_start__gte=kfet.membership_start).exists(): + # If the membership is not already renewed membership = Membership( - club=club, + club=kfet, user=user, fee=fee, - date_start=max(old_membership.first().date_end + timedelta(days=1), club.membership_start) + date_start=max(old_membership.first().date_end + timedelta(days=1), kfet.membership_start) if old_membership.exists() else form.instance.date_start, ) membership._force_save = True @@ -767,10 +754,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView): membership.refresh_from_db() if old_membership.exists(): membership.roles.set(old_membership.get().roles.all()) - elif c.name == "BDE": - membership.roles.set(Role.objects.filter(Q(name="Adhérent BDE") | Q(name="Membre de club")).all()) - elif c.name == "Kfet": - membership.roles.set(Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all()) + membership.roles.set(Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all()) membership.save() return ret @@ -830,9 +814,7 @@ class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV qs = qs.filter(date_start__lte=timezone.now().today(), date_end__gte=timezone.now().today()) if "roles" in self.request.GET: - if not self.request.GET["roles"]: - return qs.none() - roles_str = self.request.GET["roles"].replace(' ', '').split(',') + roles_str = self.request.GET["roles"].replace(' ', '').split(',') if self.request.GET["roles"] else ['0'] roles_int = map(int, roles_str) qs = qs.filter(roles__in=roles_int) diff --git a/apps/note/api/views.py b/apps/note/api/views.py index 1ab954c9..a478e7ff 100644 --- a/apps/note/api/views.py +++ b/apps/note/api/views.py @@ -118,7 +118,7 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet): queryset = super().get_queryset() # Sqlite doesn't support ORDER BY in subqueries - queryset = queryset.order_by("username") \ + queryset = queryset.order_by("name") \ if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' else queryset alias = self.request.query_params.get("alias", ".*") @@ -140,6 +140,9 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet): ), all=True) + queryset = queryset if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' \ + else queryset.order_by("name") + return queryset.distinct() From be6cf93cdb003624b8e2aa763a59d9aac2e9c8b1 Mon Sep 17 00:00:00 2001 From: Alexandre Iooss Date: Wed, 2 Sep 2020 23:08:40 +0200 Subject: [PATCH 05/95] Move default profile picture in member app --- .../member/static/member/img/default_picture.png | Bin apps/note/templates/note/conso_form.html | 2 +- apps/note/templates/note/transaction_form.html | 2 +- note_kfet/static/js/base.js | 2 +- note_kfet/static/js/consos.js | 2 +- note_kfet/static/js/transfer.js | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) rename media/pic/default.png => apps/member/static/member/img/default_picture.png (100%) diff --git a/media/pic/default.png b/apps/member/static/member/img/default_picture.png similarity index 100% rename from media/pic/default.png rename to apps/member/static/member/img/default_picture.png diff --git a/apps/note/templates/note/conso_form.html b/apps/note/templates/note/conso_form.html index 07c63488..d5914055 100644 --- a/apps/note/templates/note/conso_form.html +++ b/apps/note/templates/note/conso_form.html @@ -15,7 +15,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
-
diff --git a/apps/note/templates/note/transaction_form.html b/apps/note/templates/note/transaction_form.html index acb09beb..15478219 100644 --- a/apps/note/templates/note/transaction_form.html +++ b/apps/note/templates/note/transaction_form.html @@ -38,7 +38,7 @@ SPDX-License-Identifier: GPL-2.0-or-later {# Preview note profile (picture, username and balance) #}
-
diff --git a/note_kfet/static/js/base.js b/note_kfet/static/js/base.js index a20c72bc..8315f01a 100644 --- a/note_kfet/static/js/base.js +++ b/note_kfet/static/js/base.js @@ -122,7 +122,7 @@ function displayStyle (note) { */ function displayNote (note, alias, user_note_field = null, profile_pic_field = null) { if (!note.display_image) { - note.display_image = '/media/pic/default.png'; + note.display_image = '/static/member/img/default_picture.png'; } let img = note.display_image; if (alias !== note.name && note.name) diff --git a/note_kfet/static/js/consos.js b/note_kfet/static/js/consos.js index fc04b2b2..ec5a0940 100644 --- a/note_kfet/static/js/consos.js +++ b/note_kfet/static/js/consos.js @@ -158,7 +158,7 @@ function reset() { $("#consos_list").html(""); $("#note").val(""); $("#note").attr("data-original-title", "").tooltip("hide"); - $("#profile_pic").attr("src", "/media/pic/default.png"); + $("#profile_pic").attr("src", "/static/member/img/default_picture.png"); $("#profile_pic_link").attr("href", "#"); refreshHistory(); refreshBalance(); diff --git a/note_kfet/static/js/transfer.js b/note_kfet/static/js/transfer.js index 28b28aef..cbae7456 100644 --- a/note_kfet/static/js/transfer.js +++ b/note_kfet/static/js/transfer.js @@ -40,7 +40,7 @@ function reset(refresh=true) { $("#first_name").val(""); $("#bank").val(""); $("#user_note").val(""); - $("#profile_pic").attr("src", "/media/pic/default.png"); + $("#profile_pic").attr("src", "/static/member/img/default_picture.png"); $("#profile_pic_link").attr("href", "#"); if (refresh) { refreshBalance(); From 7bdf5a4366fe5206420c7a5036215947aa566f14 Mon Sep 17 00:00:00 2001 From: Alexandre Iooss Date: Wed, 2 Sep 2020 23:25:32 +0200 Subject: [PATCH 06/95] Update picture path in member test --- apps/member/tests/test_memberships.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/member/tests/test_memberships.py b/apps/member/tests/test_memberships.py index 8ad7b7cb..4852de77 100644 --- a/apps/member/tests/test_memberships.py +++ b/apps/member/tests/test_memberships.py @@ -125,7 +125,7 @@ class TestMemberships(TestCase): old_pic = self.club.note.display_image - with open("media/pic/default.png", "rb") as f: + with open("apps/member/static/member/img/default_picture.png", "rb") as f: image = SimpleUploadedFile("image.png", f.read(), "image/png") response = self.client.post(reverse("member:club_update_pic", args=(self.club.pk,)), dict( image=image, @@ -349,7 +349,7 @@ class TestMemberships(TestCase): old_pic = self.user.note.display_image - with open("media/pic/default.png", "rb") as f: + with open("apps/member/static/member/img/default_picture.png", "rb") as f: image = SimpleUploadedFile("image.png", f.read(), "image/png") response = self.client.post(reverse("member:user_update_pic", args=(self.user.pk,)), dict( image=image, From fed95675222e6e5a9e26a4ac86d153fa324f4a4e Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 2 Sep 2020 23:49:10 +0200 Subject: [PATCH 07/95] Force line breaks on transactions reason in history, but don't wrap dates or amounts --- apps/note/models/transactions.py | 2 +- apps/note/tables.py | 34 +++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/apps/note/models/transactions.py b/apps/note/models/transactions.py index d88be5a6..a4f220bd 100644 --- a/apps/note/models/transactions.py +++ b/apps/note/models/transactions.py @@ -356,4 +356,4 @@ class MembershipTransaction(Transaction): @property def type(self): - return _('membership transaction') + return _('membership').capitalize() diff --git a/apps/note/tables.py b/apps/note/tables.py index b1d434ae..12ec58a9 100644 --- a/apps/note/tables.py +++ b/apps/note/tables.py @@ -29,6 +29,7 @@ class HistoryTable(tables.Table): source = tables.Column( attrs={ "td": { + "class": "text-nowrap", "data-toggle": "tooltip", "title": lambda record: _("used alias").capitalize() + " : " + record.source_alias, } @@ -38,15 +39,46 @@ class HistoryTable(tables.Table): destination = tables.Column( attrs={ "td": { + "class": "text-nowrap", "data-toggle": "tooltip", "title": lambda record: _("used alias").capitalize() + " : " + record.destination_alias, } } ) + created_at = tables.DateColumn( + attrs={ + "td": { + "class": "text-nowrap", + }, + } + ) + + amount = tables.Column( + attrs={ + "td": { + "class": "text-nowrap", + }, + } + ) + + reason = tables.Column( + attrs={ + "td": { + "class": "text-break", + }, + } + ) + type = tables.Column() - total = tables.Column() # will use Transaction.total() !! + total = tables.Column( # will use Transaction.total() !! + attrs={ + "td": { + "class": "text-nowrap", + }, + } + ) valid = tables.Column( attrs={ From cc7ebd2d8a67c57983af31495d14236845f3602d Mon Sep 17 00:00:00 2001 From: Alexandre Iooss Date: Thu, 3 Sep 2020 00:35:25 +0200 Subject: [PATCH 08/95] Sometimes the nk20 is too laggy --- note_kfet/static/js/konami.js | 38 +++++++++++++++++++++++++++++++++++ note_kfet/templates/base.html | 1 + 2 files changed, 39 insertions(+) create mode 100644 note_kfet/static/js/konami.js diff --git a/note_kfet/static/js/konami.js b/note_kfet/static/js/konami.js new file mode 100644 index 00000000..e9f6c17d --- /dev/null +++ b/note_kfet/static/js/konami.js @@ -0,0 +1,38 @@ +/* + * Konami code support + */ + +// Cursor denote the position in konami code +let cursor = 0 +const KONAMI_CODE = [38, 38, 40, 40, 37, 39, 37, 39, 66, 65] + +function afterKonami() { + // Load Rythm.js + var rythmScript = document.createElement('script') + rythmScript.setAttribute('src','https://unpkg.com/rythm.js@2.2.5/rythm.min.js') + document.head.appendChild(rythmScript) + + rythmScript.addEventListener('load', function() { + // This media source need to be accessible with a cross-origin header + const audioElement = new Audio('https://okazari.github.io/Rythm.js/samples/rythmC.mp3') + audioElement.crossOrigin = 'anonymous' + audioElement.play(); + + const rythm = new Rythm() + rythm.connectExternalAudioElement(audioElement) + rythm.addRythm('card', 'pulse', 0, 10) + rythm.addRythm('nav-link', 'color', 0, 10, { + from: [0,0,255], + to:[255,0,255] + }) + rythm.start() + }); +} + +// Register custom event +document.addEventListener('keydown', (e) => { + cursor = (e.keyCode == KONAMI_CODE[cursor]) ? cursor + 1 : 0; + if (cursor == KONAMI_CODE.length) { + afterKonami() + } +}); diff --git a/note_kfet/templates/base.html b/note_kfet/templates/base.html index 3381c78e..fcee608a 100644 --- a/note_kfet/templates/base.html +++ b/note_kfet/templates/base.html @@ -35,6 +35,7 @@ SPDX-License-Identifier: GPL-3.0-or-later + {# Si un formulaire requiert des données supplémentaires (notamment JS), les données sont chargées #} {% if form.media %} From 76aacaf048f3d12c8d34587405ae594c89e96c80 Mon Sep 17 00:00:00 2001 From: Alexandre Iooss Date: Thu, 3 Sep 2020 11:47:17 +0200 Subject: [PATCH 09/95] Definitively more usable --- note_kfet/static/js/konami.js | 25 ++++++++++++++++--------- note_kfet/static/song/konami.ogg | Bin 0 -> 162347 bytes 2 files changed, 16 insertions(+), 9 deletions(-) create mode 100644 note_kfet/static/song/konami.ogg diff --git a/note_kfet/static/js/konami.js b/note_kfet/static/js/konami.js index e9f6c17d..a430a4b6 100644 --- a/note_kfet/static/js/konami.js +++ b/note_kfet/static/js/konami.js @@ -9,21 +9,28 @@ const KONAMI_CODE = [38, 38, 40, 40, 37, 39, 37, 39, 66, 65] function afterKonami() { // Load Rythm.js var rythmScript = document.createElement('script') - rythmScript.setAttribute('src','https://unpkg.com/rythm.js@2.2.5/rythm.min.js') + rythmScript.setAttribute('src','//unpkg.com/rythm.js@2.2.5/rythm.min.js') document.head.appendChild(rythmScript) rythmScript.addEventListener('load', function() { - // This media source need to be accessible with a cross-origin header - const audioElement = new Audio('https://okazari.github.io/Rythm.js/samples/rythmC.mp3') - audioElement.crossOrigin = 'anonymous' - audioElement.play(); + // Ker-Lyon audio courtesy of @adalan, ker-lyon.fr + const audioElement = new Audio('/static/song/konami.ogg') + audioElement.loop = true + audioElement.play() const rythm = new Rythm() rythm.connectExternalAudioElement(audioElement) - rythm.addRythm('card', 'pulse', 0, 10) - rythm.addRythm('nav-link', 'color', 0, 10, { - from: [0,0,255], - to:[255,0,255] + rythm.addRythm('card', 'pulse', 50, 50, { + min: 1, + max: 1.1 + }) + rythm.addRythm('d-flex', 'color', 50, 50, { + from: [64,64,64], + to:[128,64,128] + }) + rythm.addRythm('nav-link', 'jump', 150, 50, { + min: 0, + max: 10 }) rythm.start() }); diff --git a/note_kfet/static/song/konami.ogg b/note_kfet/static/song/konami.ogg new file mode 100644 index 0000000000000000000000000000000000000000..e84f32ec9957ceb38188dff81327bcd170b45f5b GIT binary patch literal 162347 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 Date: Thu, 3 Sep 2020 14:33:26 +0200 Subject: [PATCH 10/95] Format date to ISO standard --- apps/note/tables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/note/tables.py b/apps/note/tables.py index 12ec58a9..0ca50306 100644 --- a/apps/note/tables.py +++ b/apps/note/tables.py @@ -46,7 +46,7 @@ class HistoryTable(tables.Table): } ) - created_at = tables.DateColumn( + created_at = tables.DateTimeColumn(format='Y-m-d H:i:s', attrs={ "td": { "class": "text-nowrap", From 4b85a35a9d4429f8ee35c6328a728a08775f042d Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Thu, 3 Sep 2020 16:10:29 +0200 Subject: [PATCH 11/95] Fix double consumptions --- note_kfet/static/js/consos.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/note_kfet/static/js/consos.js b/note_kfet/static/js/consos.js index ec5a0940..25e8113e 100644 --- a/note_kfet/static/js/consos.js +++ b/note_kfet/static/js/consos.js @@ -27,7 +27,7 @@ $(document).ready(function() { }); // Switching in double consumptions mode should update the layout - $("#double_conso").click(function() { + $("#double_conso").change(function() { $("#consos_list_div").removeClass('d-none'); $("#user_select_div").attr('class', 'col-xl-4'); $("#infos_div").attr('class', 'col-sm-5 col-xl-6'); @@ -47,7 +47,7 @@ $(document).ready(function() { } }); - $("#single_conso").click(function() { + $("#single_conso").change(function() { $("#consos_list_div").addClass('d-none'); $("#user_select_div").attr('class', 'col-xl-7'); $("#infos_div").attr('class', 'col-sm-5 col-md-4'); From f02efd3b39f5478d09a77bb176873c3835c5c92c Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Thu, 3 Sep 2020 20:03:40 +0200 Subject: [PATCH 12/95] 100% coverage on registration app --- apps/member/models.py | 10 +- apps/member/views.py | 4 +- apps/registration/tests/__init__.py | 0 apps/registration/tests/test_registration.py | 386 +++++++++++++++++++ apps/registration/views.py | 21 +- 5 files changed, 401 insertions(+), 20 deletions(-) create mode 100644 apps/registration/tests/__init__.py create mode 100644 apps/registration/tests/test_registration.py diff --git a/apps/member/models.py b/apps/member/models.py index a1628fae..d1218e94 100644 --- a/apps/member/models.py +++ b/apps/member/models.py @@ -172,19 +172,21 @@ class Profile(models.Model): def send_email_validation_link(self): subject = "[Note Kfet] " + str(_("Activate your Note Kfet account")) + token = email_validation_token.make_token(self.user) + uid = urlsafe_base64_encode(force_bytes(self.user_id)) message = loader.render_to_string('registration/mails/email_validation_email.txt', { 'user': self.user, 'domain': os.getenv("NOTE_URL", "note.example.com"), - 'token': email_validation_token.make_token(self.user), - 'uid': urlsafe_base64_encode(force_bytes(self.user.pk)), + 'token': token, + 'uid': uid, }) html = loader.render_to_string('registration/mails/email_validation_email.html', { 'user': self.user, 'domain': os.getenv("NOTE_URL", "note.example.com"), - 'token': email_validation_token.make_token(self.user), - 'uid': urlsafe_base64_encode(force_bytes(self.user.pk)), + 'token': token, + 'uid': uid, }) self.user.email_user(subject, message, html_message=html) diff --git a/apps/member/views.py b/apps/member/views.py index c2f9f136..4534c9e8 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -140,9 +140,7 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): """ We can't display information of a not registered user. """ - qs = super().get_queryset() - return qs if self.request.user.is_superuser and self.request.session.get("permission_mask", -1) >= 42\ - else qs.filter(profile__registration_valid=True) + return super().get_queryset().filter(profile__registration_valid=True) def get_context_data(self, **kwargs): """ diff --git a/apps/registration/tests/__init__.py b/apps/registration/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/registration/tests/test_registration.py b/apps/registration/tests/test_registration.py new file mode 100644 index 00000000..e2191445 --- /dev/null +++ b/apps/registration/tests/test_registration.py @@ -0,0 +1,386 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +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.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode +from member.models import Club, Membership +from note.models import NoteUser, NoteSpecial, Transaction +from registration.tokens import email_validation_token +from treasury.models import SogeCredit + +""" +Check that pre-registrations and validations are working as well. +""" + + +class TestSignup(TestCase): + """ + Assume we are a new user. + Check that it can pre-register without any problem. + """ + + fixtures = ("initial", ) + + def test_signup(self): + """ + A first year member signs up and validates its email address. + """ + response = self.client.get(reverse("registration:signup")) + self.assertEqual(response.status_code, 200) + + # Signup + response = self.client.post(reverse("registration:signup"), dict( + first_name="Toto", + last_name="TOTO", + username="toto", + email="toto@example.com", + password1="toto1234", + password2="toto1234", + phone_number="+33123456789", + department="EXT", + promotion=Club.objects.get(name="BDE").membership_start.year, + address="Earth", + paid=False, + ml_events_registration="en", + ml_sport_registration=True, + ml_art_registration=True, + )) + self.assertRedirects(response, reverse("registration:email_validation_sent"), 302, 200) + self.assertTrue(User.objects.filter(username="toto").exists()) + user = User.objects.get(username="toto") + # A preregistred user has no note + self.assertFalse(NoteUser.objects.filter(user=user).exists()) + self.assertFalse(user.profile.registration_valid) + self.assertFalse(user.profile.email_confirmed) + self.assertFalse(user.is_active) + + response = self.client.get(reverse("registration:email_validation_sent")) + self.assertEqual(response.status_code, 200) + + # Check that the email validation link is valid + token = email_validation_token.make_token(user) + uid = urlsafe_base64_encode(force_bytes(user.pk)) + response = self.client.get(reverse("registration:email_validation", kwargs=dict(uidb64=uid, token=token))) + self.assertEqual(response.status_code, 200) + user.profile.refresh_from_db() + self.assertTrue(user.profile.email_confirmed) + + # Token has expired + response = self.client.get(reverse("registration:email_validation", kwargs=dict(uidb64=uid, token=token))) + self.assertEqual(response.status_code, 400) + + # Uid does not exist + response = self.client.get(reverse("registration:email_validation", kwargs=dict(uidb64=0, token="toto"))) + self.assertEqual(response.status_code, 400) + + def test_invalid_signup(self): + """ + Send wrong data and check that it is not valid + """ + User.objects.create_superuser( + first_name="Toto", + last_name="TOTO", + username="toto", + email="toto@example.com", + password="toto1234", + ) + + # The email is already used + response = self.client.post(reverse("registration:signup"), dict( + first_name="Toto", + last_name="TOTO", + username="tôtö", + email="toto@example.com", + password1="toto1234", + password2="toto1234", + phone_number="+33123456789", + department="EXT", + promotion=Club.objects.get(name="BDE").membership_start.year, + address="Earth", + paid=False, + ml_events_registration="en", + ml_sport_registration=True, + ml_art_registration=True, + )) + self.assertTrue(response.status_code, 200) + + # The username is similar to a known alias + response = self.client.post(reverse("registration:signup"), dict( + first_name="Toto", + last_name="TOTO", + username="tôtö", + email="othertoto@example.com", + password1="toto1234", + password2="toto1234", + phone_number="+33123456789", + department="EXT", + promotion=Club.objects.get(name="BDE").membership_start.year, + address="Earth", + paid=False, + ml_events_registration="en", + ml_sport_registration=True, + ml_art_registration=True, + )) + self.assertTrue(response.status_code, 200) + + # The phone number is invalid + response = self.client.post(reverse("registration:signup"), dict( + first_name="Toto", + last_name="TOTO", + username="Ihaveanotherusername", + email="othertoto@example.com", + password1="toto1234", + password2="toto1234", + phone_number="invalid phone number", + department="EXT", + promotion=Club.objects.get(name="BDE").membership_start.year, + address="Earth", + paid=False, + ml_events_registration="en", + ml_sport_registration=True, + ml_art_registration=True, + )) + self.assertTrue(response.status_code, 200) + + +class TestValidateRegistration(TestCase): + """ + Test the admin interface to validate users + """ + + fixtures = ('initial',) + + def setUp(self) -> None: + self.superuser = User.objects.create_superuser( + username="admintoto", + password="toto1234", + email="admin.toto@example.com", + ) + self.client.force_login(self.superuser) + + self.user = User.objects.create( + username="toto", + first_name="Toto", + last_name="TOTO", + email="toto@example.com", + ) + + sess = self.client.session + sess["permission_mask"] = 42 + sess.save() + + def test_future_user_list(self): + """ + Display the list of pre-registered users + """ + response = self.client.get(reverse("registration:future_user_list")) + self.assertEqual(response.status_code, 200) + + response = self.client.get(reverse("registration:future_user_list") + "?search=toto") + self.assertEqual(response.status_code, 200) + + def test_invalid_registrations(self): + """ + Send wrong data and check that errors are detected + """ + + # BDE Membership is mandatory + response = self.client.post(reverse("registration:future_user_detail", args=(self.user.pk,)), data=dict( + soge=False, + credit_type=NoteSpecial.objects.get(special_type="Chèque").id, + credit_amount=4200, + last_name="TOTO", + first_name="Toto", + bank="Société générale", + join_BDE=False, + join_Kfet=False, + )) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.context["form"].errors) + + # Same + response = self.client.post(reverse("registration:future_user_detail", args=(self.user.pk,)), data=dict( + soge=False, + credit_type="", + credit_amount=0, + last_name="TOTO", + first_name="Toto", + bank="Société générale", + join_BDE=False, + join_Kfet=True, + )) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.context["form"].errors) + + # The BDE membership is not free + response = self.client.post(reverse("registration:future_user_detail", args=(self.user.pk,)), data=dict( + soge=False, + credit_type=NoteSpecial.objects.get(special_type="Espèces").id, + credit_amount=0, + last_name="TOTO", + first_name="Toto", + bank="J'ai pas d'argent", + join_BDE=True, + join_Kfet=True, + )) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.context["form"].errors) + + # Last and first names are required for a credit + response = self.client.post(reverse("registration:future_user_detail", args=(self.user.pk,)), data=dict( + soge=False, + credit_type=NoteSpecial.objects.get(special_type="Chèque").id, + credit_amount=4000, + last_name="", + first_name="", + bank="", + join_BDE=True, + join_Kfet=True, + )) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.context["form"].errors) + + # The username admïntoto is too similar with the alias admintoto. + # Since the form is valid, the user must update its username. + self.user.username = "admïntoto" + self.user.save() + response = self.client.post(reverse("registration:future_user_detail", args=(self.user.pk,)), data=dict( + soge=False, + credit_type=NoteSpecial.objects.get(special_type="Chèque").id, + credit_amount=500, + last_name="TOTO", + first_name="Toto", + bank="Société générale", + join_BDE=True, + join_Kfet=False, + )) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.context["form"].errors) + + def test_validate_bde_registration(self): + """ + The user wants only to join the BDE. We validate the registration. + """ + response = self.client.get(reverse("registration:future_user_detail", args=(self.user.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.get(self.user.profile.get_absolute_url()) + self.assertEqual(response.status_code, 404) + + self.user.profile.email_confirmed = True + self.user.profile.save() + + response = self.client.post(reverse("registration:future_user_detail", args=(self.user.pk,)), data=dict( + soge=False, + credit_type=NoteSpecial.objects.get(special_type="Chèque").id, + credit_amount=500, + last_name="TOTO", + first_name="Toto", + bank="Société générale", + join_BDE=True, + join_Kfet=False, + )) + self.assertRedirects(response, self.user.profile.get_absolute_url(), 302, 200) + self.user.profile.refresh_from_db() + self.assertTrue(self.user.profile.registration_valid) + self.assertTrue(NoteUser.objects.filter(user=self.user).exists()) + self.assertTrue(Membership.objects.filter(club__name="BDE", user=self.user).exists()) + self.assertFalse(Membership.objects.filter(club__name="Kfet", user=self.user).exists()) + self.assertFalse(SogeCredit.objects.filter(user=self.user).exists()) + self.assertEqual(Transaction.objects.filter( + Q(source=self.user.note) | Q(destination=self.user.note)).count(), 2) + + response = self.client.get(self.user.profile.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + def test_validate_kfet_registration(self): + """ + The user joins the BDE and the Kfet. + """ + response = self.client.get(reverse("registration:future_user_detail", args=(self.user.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.get(self.user.profile.get_absolute_url()) + self.assertEqual(response.status_code, 404) + + self.user.profile.email_confirmed = True + self.user.profile.save() + + response = self.client.post(reverse("registration:future_user_detail", args=(self.user.pk,)), data=dict( + soge=False, + credit_type=NoteSpecial.objects.get(special_type="Espèces").id, + credit_amount=4000, + last_name="TOTO", + first_name="Toto", + bank="Société générale", + join_BDE=True, + join_Kfet=True, + )) + self.assertRedirects(response, self.user.profile.get_absolute_url(), 302, 200) + self.user.profile.refresh_from_db() + self.assertTrue(self.user.profile.registration_valid) + self.assertTrue(NoteUser.objects.filter(user=self.user).exists()) + self.assertTrue(Membership.objects.filter(club__name="BDE", user=self.user).exists()) + self.assertTrue(Membership.objects.filter(club__name="Kfet", user=self.user).exists()) + self.assertFalse(SogeCredit.objects.filter(user=self.user).exists()) + self.assertEqual(Transaction.objects.filter( + Q(source=self.user.note) | Q(destination=self.user.note)).count(), 3) + + response = self.client.get(self.user.profile.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + def test_validate_kfet_registration_with_soge(self): + """ + The user joins the BDE and the Kfet, but the membership is paid by the Société générale. + """ + response = self.client.get(reverse("registration:future_user_detail", args=(self.user.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.get(self.user.profile.get_absolute_url()) + self.assertEqual(response.status_code, 404) + + self.user.profile.email_confirmed = True + self.user.profile.save() + + response = self.client.post(reverse("registration:future_user_detail", args=(self.user.pk,)), data=dict( + soge=True, + credit_type=NoteSpecial.objects.get(special_type="Espèces").id, + credit_amount=4000, + last_name="TOTO", + first_name="Toto", + bank="Société générale", + join_BDE=True, + join_Kfet=True, + )) + self.assertRedirects(response, self.user.profile.get_absolute_url(), 302, 200) + self.user.profile.refresh_from_db() + self.assertTrue(self.user.profile.registration_valid) + self.assertTrue(NoteUser.objects.filter(user=self.user).exists()) + self.assertTrue(Membership.objects.filter(club__name="BDE", user=self.user).exists()) + self.assertTrue(Membership.objects.filter(club__name="Kfet", user=self.user).exists()) + self.assertTrue(SogeCredit.objects.filter(user=self.user).exists()) + self.assertEqual(Transaction.objects.filter( + Q(source=self.user.note) | Q(destination=self.user.note)).count(), 2) + self.assertFalse(Transaction.objects.filter(valid=True).exists()) + + response = self.client.get(self.user.profile.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + def test_invalidate_registration(self): + """ + Try to invalidate (= delete) pre-registration. + """ + response = self.client.get(reverse("registration:future_user_invalidate", args=(self.user.pk,))) + self.assertRedirects(response, reverse("registration:future_user_list"), 302, 200) + self.assertFalse(User.objects.filter(pk=self.user.pk).exists()) + + def test_resend_email_validation_link(self): + """ + Resend email validation linK. + """ + response = self.client.get(reverse("registration:email_validation_resend", args=(self.user.pk,))) + self.assertRedirects(response, reverse("registration:future_user_detail", args=(self.user.pk,)), 302, 200) diff --git a/apps/registration/views.py b/apps/registration/views.py index bf68a8ed..7a924591 100644 --- a/apps/registration/views.py +++ b/apps/registration/views.py @@ -16,7 +16,7 @@ from django.views.generic.edit import FormMixin from django_tables2 import SingleTableView from member.forms import ProfileForm from member.models import Membership, Club -from note.models import SpecialTransaction +from note.models import SpecialTransaction, Alias from note.templatetags.pretty_money import pretty_money from permission.backends import PermissionBackend from permission.models import Role @@ -101,7 +101,7 @@ class UserValidateView(TemplateView): user.profile.email_confirmed = True user.save() user.profile.save() - return self.render_to_response(self.get_context_data()) + return self.render_to_response(self.get_context_data(), status=200 if self.validlink else 400) def get_user(self, uidb64): """ @@ -169,12 +169,9 @@ class FutureUserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableVi :return: """ qs = super().get_queryset().distinct().filter(profile__registration_valid=False) - if "search" in self.request.GET: + if "search" in self.request.GET and self.request.GET["search"]: pattern = self.request.GET["search"] - if not pattern: - return qs.none() - qs = qs.filter( Q(first_name__iregex=pattern) | Q(last_name__iregex=pattern) @@ -205,10 +202,7 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin, def post(self, request, *args, **kwargs): form = self.get_form() self.object = self.get_object() - if form.is_valid(): - return self.form_valid(form) - else: - return self.form_invalid(form) + return self.form_valid(form) if form.is_valid() else self.form_invalid(form) def get_queryset(self, **kwargs): """ @@ -239,6 +233,10 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin, def form_valid(self, form): user = self.get_object() + if Alias.objects.filter(normalized_name=Alias.normalize(user.username)).exists(): + form.add_error(None, _("An alias with a similar name already exists.")) + return self.form_invalid(form) + # Get form data soge = form.cleaned_data["soge"] credit_type = form.cleaned_data["credit_type"] @@ -276,9 +274,6 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin, if credit_type is None: credit_amount = 0 - if join_Kfet and not join_BDE: - form.add_error('join_Kfet', _("You must join BDE club before joining Kfet club.")) - if fee > credit_amount and not soge: # Check if the user credits enough money form.add_error('credit_type', From ff187581c9d0aaa6c1aae3697d5adfc9ac76d360 Mon Sep 17 00:00:00 2001 From: Alexandre Iooss Date: Thu, 3 Sep 2020 21:21:09 +0200 Subject: [PATCH 13/95] Remove useless blank lines and spaces in api app --- apps/api/serializers.py | 1 + apps/api/viewsets.py | 6 +----- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/api/serializers.py b/apps/api/serializers.py index 1f658217..d59bdc43 100644 --- a/apps/api/serializers.py +++ b/apps/api/serializers.py @@ -6,6 +6,7 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.auth.models import User from rest_framework.serializers import ModelSerializer + class UserSerializer(ModelSerializer): """ REST API Serializer for Users. diff --git a/apps/api/viewsets.py b/apps/api/viewsets.py index 825082c0..f7d1e481 100644 --- a/apps/api/viewsets.py +++ b/apps/api/viewsets.py @@ -6,11 +6,8 @@ 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.viewsets import ReadOnlyModelViewSet, ModelViewSet - from permission.backends import PermissionBackend - from note_kfet.middlewares import get_current_session from note.models import Alias @@ -47,7 +44,6 @@ class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet): return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct() - class UserViewSet(ReadProtectedModelViewSet): """ REST API View set. @@ -67,7 +63,7 @@ class UserViewSet(ReadProtectedModelViewSet): if "search" in self.request.GET: pattern = self.request.GET["search"] - + # We match first a user by its username, then if an alias is matched without normalization # And finally if the normalized pattern matches a normalized alias. queryset = queryset.filter( From d29e1d69d18e323c8b35e4639d253622d1014851 Mon Sep 17 00:00:00 2001 From: Alexandre Iooss Date: Thu, 3 Sep 2020 21:47:08 +0200 Subject: [PATCH 14/95] Format api viewsets --- apps/api/urls.py | 1 + apps/api/viewsets.py | 50 +++++++++++++++++++++++++------------------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/apps/api/urls.py b/apps/api/urls.py index 4addbdf1..7131c657 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -4,6 +4,7 @@ from django.conf import settings from django.conf.urls import url, include from rest_framework import routers + from .viewsets import ContentTypeViewSet, UserViewSet # Routers provide an easy way of automatically determining the URL conf. diff --git a/apps/api/viewsets.py b/apps/api/viewsets.py index f7d1e481..333ae5e3 100644 --- a/apps/api/viewsets.py +++ b/apps/api/viewsets.py @@ -64,28 +64,36 @@ class UserViewSet(ReadProtectedModelViewSet): if "search" in self.request.GET: pattern = self.request.GET["search"] - # We match first a user by its username, then if an alias is matched without normalization - # And finally if the normalized pattern matches a normalized alias. + # Filter with different rules + # We use union-all to keep each filter rule sorted in result queryset = queryset.filter( - username__iregex="^" + pattern).union( - queryset.filter( - Q(note__alias__name__iregex="^" + pattern) - & ~Q(username__iregex="^" + pattern)), all=True).union( - queryset.filter( - Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern)) - & ~Q(note__alias__name__iregex="^" + pattern) - & ~Q(username__iregex="^" + pattern)), all=True).union( - queryset.filter( - Q(note__alias__normalized_name__iregex="^" + pattern.lower()) - & ~Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern)) - & ~Q(note__alias__name__iregex="^" + pattern) - & ~Q(username__iregex="^" + pattern)), all=True).union( - queryset.filter( - (Q(last_name__iregex="^" + pattern) | Q(first_name__iregex="^" + pattern)) - & ~Q(note__alias__normalized_name__iregex="^" + pattern.lower()) - & ~Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern)) - & ~Q(note__alias__name__iregex="^" + pattern) - & ~Q(username__iregex="^" + pattern)), all=True) + # Match without normalization + note__alias__name__iregex="^" + pattern + ).union( + queryset.filter( + # Match with normalization + Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern)) + & ~Q(note__alias__name__iregex="^" + pattern) + ), + all=True, + ).union( + queryset.filter( + # Match on lower pattern + Q(note__alias__normalized_name__iregex="^" + pattern.lower()) + & ~Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern)) + & ~Q(note__alias__name__iregex="^" + pattern) + ), + all=True, + ).union( + queryset.filter( + # Match on firstname or lastname + (Q(last_name__iregex="^" + pattern) | Q(first_name__iregex="^" + pattern)) + & ~Q(note__alias__normalized_name__iregex="^" + pattern.lower()) + & ~Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern)) + & ~Q(note__alias__name__iregex="^" + pattern) + ), + all=True, + ) queryset = queryset if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' \ else queryset.order_by("username") From f8a0e207728af6535097f537bc43631a2e464a25 Mon Sep 17 00:00:00 2001 From: Alexandre Iooss Date: Fri, 4 Sep 2020 07:44:59 +0200 Subject: [PATCH 15/95] Regenerate locales --- locale/de/LC_MESSAGES/django.po | 149 +++-- locale/fr/LC_MESSAGES/django.po | 1059 +++---------------------------- 2 files changed, 144 insertions(+), 1064 deletions(-) diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index 1f5c0403..e27135fa 100644 --- a/locale/de/LC_MESSAGES/django.po +++ b/locale/de/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-09-02 23:17+0200\n" +"POT-Creation-Date: 2020-09-04 07:44+0200\n" "PO-Revision-Date: 2020-09-03 23:47+0200\n" "Last-Translator: \n" "Language-Team: \n" @@ -49,7 +49,7 @@ msgid "You can't invite more than 3 people to this activity." msgstr "Sie dürfen höchstens 3 Leute zu dieser Veranstaltung einladen." #: apps/activity/models.py:27 apps/activity/models.py:62 -#: apps/member/models.py:198 +#: apps/member/models.py:200 #: apps/member/templates/member/includes/club_info.html:4 #: apps/member/templates/member/includes/profile_info.html:4 #: apps/note/models/notes.py:247 apps/note/models/transactions.py:26 @@ -110,7 +110,7 @@ msgstr "Wo findet die Veranstaltung statt ? (z.B Kfet)" msgid "type" msgstr "Type" -#: apps/activity/models.py:88 apps/logs/models.py:22 apps/member/models.py:303 +#: apps/activity/models.py:88 apps/logs/models.py:22 apps/member/models.py:305 #: apps/note/models/notes.py:138 apps/treasury/models.py:267 #: apps/treasury/templates/treasury/sogecredit_detail.html:14 #: apps/wei/models.py:160 apps/wei/templates/wei/survey.html:15 @@ -273,7 +273,7 @@ msgstr "Gastliste" #: apps/note/models/transactions.py:259 #: apps/note/templates/note/transaction_form.html:16 #: apps/note/templates/note/transaction_form.html:148 -#: note_kfet/templates/base.html:68 +#: note_kfet/templates/base.html:69 msgid "Transfer" msgstr "Überweisen" @@ -356,7 +356,7 @@ msgid "validate" msgstr "validate" #: apps/activity/templates/activity/includes/activity_info.html:71 -#: apps/logs/models.py:62 apps/note/tables.py:162 +#: apps/logs/models.py:62 apps/note/tables.py:194 msgid "edit" msgstr "bearbeiten" @@ -368,7 +368,7 @@ msgstr "Einladen" msgid "Create new activity" msgstr "Neue Veranstaltung schaffen" -#: apps/activity/views.py:59 note_kfet/templates/base.html:86 +#: apps/activity/views.py:59 note_kfet/templates/base.html:87 msgid "Activities" msgstr "Veranstaltungen" @@ -432,7 +432,7 @@ msgstr "neue Daten" msgid "create" msgstr "schaffen" -#: apps/logs/models.py:63 apps/note/tables.py:132 apps/note/tables.py:168 +#: apps/logs/models.py:63 apps/note/tables.py:164 apps/note/tables.py:200 #: apps/permission/models.py:127 apps/treasury/tables.py:38 #: apps/wei/tables.py:75 msgid "delete" @@ -458,21 +458,21 @@ msgstr "Changelog" msgid "changelogs" msgstr "Changelogs" -#: apps/member/admin.py:52 apps/member/models.py:225 +#: apps/member/admin.py:50 apps/member/models.py:227 #: apps/member/templates/member/includes/club_info.html:34 msgid "membership fee (paid students)" msgstr "Mitgliedschaftpreis (bezahlte Studenten)" -#: apps/member/admin.py:53 apps/member/models.py:230 +#: apps/member/admin.py:51 apps/member/models.py:232 #: apps/member/templates/member/includes/club_info.html:37 msgid "membership fee (unpaid students)" msgstr "Mitgliedschaftpreis (unbezahlte Studenten)" -#: apps/member/admin.py:67 apps/member/models.py:314 +#: apps/member/admin.py:65 apps/member/models.py:316 msgid "roles" msgstr "Rollen" -#: apps/member/admin.py:68 apps/member/models.py:328 +#: apps/member/admin.py:66 apps/member/models.py:330 msgid "fee" msgstr "Preis" @@ -500,8 +500,8 @@ msgstr "Wählen sie ein Bild aus" msgid "Maximal size: 2MB" msgstr "Maximal Größe: 2MB" -#: apps/member/forms.py:87 apps/member/views.py:101 -#: apps/registration/forms.py:33 +#: apps/member/forms.py:87 apps/member/views.py:100 +#: apps/registration/forms.py:33 apps/registration/views.py:237 msgid "An alias with a similar name already exists." msgstr "Ein ähnliches Alias ist schon benutzt." @@ -718,7 +718,7 @@ msgstr "Userprofile" msgid "Activate your Note Kfet account" msgstr "Ihre Note Kfet Konto bestätigen" -#: apps/member/models.py:203 +#: apps/member/models.py:205 #: apps/member/templates/member/includes/club_info.html:55 #: apps/member/templates/member/includes/profile_info.html:31 #: apps/registration/templates/registration/future_profile_detail.html:22 @@ -727,88 +727,88 @@ msgstr "Ihre Note Kfet Konto bestätigen" msgid "email" msgstr "Email" -#: apps/member/models.py:210 +#: apps/member/models.py:212 msgid "parent club" msgstr "Urclub" -#: apps/member/models.py:219 +#: apps/member/models.py:221 msgid "require memberships" msgstr "erfordern Mitgliedschaft" -#: apps/member/models.py:220 +#: apps/member/models.py:222 msgid "Uncheck if this club don't require memberships." msgstr "" "Deaktivieren Sie diese Option, wenn für diesen Club keine Mitgliedschaft " "erforderlich ist." -#: apps/member/models.py:236 +#: apps/member/models.py:238 #: apps/member/templates/member/includes/club_info.html:26 msgid "membership duration" msgstr "Mitgliedscahftzeit" -#: apps/member/models.py:237 +#: apps/member/models.py:239 msgid "The longest time (in days) a membership can last (NULL = infinite)." msgstr "Wie lang am höchsten eine Mitgliedschaft dauern kann." -#: apps/member/models.py:244 +#: apps/member/models.py:246 #: apps/member/templates/member/includes/club_info.html:16 msgid "membership start" msgstr "Mitgliedschaftanfangsdatum" -#: apps/member/models.py:245 +#: apps/member/models.py:247 msgid "Date from which the members can renew their membership." msgstr "Ab wann kann man sein Mitgliedschaft erneuern." -#: apps/member/models.py:251 +#: apps/member/models.py:253 #: apps/member/templates/member/includes/club_info.html:21 msgid "membership end" msgstr "Mitgliedschaftenddatum" -#: apps/member/models.py:252 +#: apps/member/models.py:254 msgid "Maximal date of a membership, after which members must renew it." msgstr "" "Maximales Datum einer Mitgliedschaft, nach dem Mitglieder es erneuern müssen." -#: apps/member/models.py:284 apps/member/models.py:309 +#: apps/member/models.py:286 apps/member/models.py:311 #: apps/note/models/notes.py:179 msgid "club" msgstr "Club" -#: apps/member/models.py:285 +#: apps/member/models.py:287 msgid "clubs" msgstr "Clubs" -#: apps/member/models.py:319 +#: apps/member/models.py:321 msgid "membership starts on" msgstr "Mitgliedschaft fängt an" -#: apps/member/models.py:323 +#: apps/member/models.py:325 msgid "membership ends on" msgstr "Mitgliedschaft endet am" -#: apps/member/models.py:374 +#: apps/member/models.py:375 #, python-brace-format msgid "The role {role} does not apply to the club {club}." msgstr "Die Rolle {role} ist nicht erlaubt für das Club {club}." -#: apps/member/models.py:385 apps/member/views.py:676 +#: apps/member/models.py:384 apps/member/views.py:669 msgid "User is already a member of the club" msgstr "User ist schon ein Mitglied dieser club" -#: apps/member/models.py:433 +#: apps/member/models.py:432 msgid "User is not a member of the parent club" msgstr "User ist noch nicht Mitglied des Urclubs" -#: apps/member/models.py:486 +#: apps/member/models.py:480 #, python-brace-format msgid "Membership of {user} for the club {club}" msgstr "Mitgliedschaft von {user} für das Club {club}" -#: apps/member/models.py:489 +#: apps/member/models.py:483 apps/note/models/transactions.py:359 msgid "membership" msgstr "Mitgliedschaft" -#: apps/member/models.py:490 +#: apps/member/models.py:484 msgid "memberships" msgstr "Mitgliedschaften" @@ -902,8 +902,8 @@ msgstr "" "erlaubt." #: apps/member/templates/member/club_alias.html:10 -#: apps/member/templates/member/profile_alias.html:10 apps/member/views.py:240 -#: apps/member/views.py:450 +#: apps/member/templates/member/profile_alias.html:10 apps/member/views.py:238 +#: apps/member/views.py:443 msgid "Note aliases" msgstr "Note Aliases" @@ -1034,43 +1034,43 @@ msgstr "Anmeldung" msgid "This address must be valid." msgstr "Diese Adresse muss gültig sein." -#: apps/member/views.py:138 +#: apps/member/views.py:137 msgid "Profile detail" msgstr "Profile detail" -#: apps/member/views.py:201 +#: apps/member/views.py:197 msgid "Search user" msgstr "User finden" -#: apps/member/views.py:260 +#: apps/member/views.py:258 msgid "Update note picture" msgstr "Notebild ändern" -#: apps/member/views.py:318 +#: apps/member/views.py:311 msgid "Manage auth token" msgstr "Auth token bearbeiten" -#: apps/member/views.py:346 +#: apps/member/views.py:338 msgid "Create new club" msgstr "Neue Club" -#: apps/member/views.py:364 +#: apps/member/views.py:357 msgid "Search club" msgstr "Club finden" -#: apps/member/views.py:397 +#: apps/member/views.py:390 msgid "Club detail" msgstr "Club Details" -#: apps/member/views.py:473 +#: apps/member/views.py:466 msgid "Update club" msgstr "Club bearbeiten" -#: apps/member/views.py:507 +#: apps/member/views.py:500 msgid "Add new member to the club" msgstr "Neue Mitglieder" -#: apps/member/views.py:667 apps/wei/views.py:922 +#: apps/member/views.py:660 apps/wei/views.py:922 msgid "" "This user don't have enough money to join this club, and can't have a " "negative balance." @@ -1078,25 +1078,25 @@ msgstr "" "Diese User hat nicht genug Geld um Mitglied zu werden, und darf nich im Rot " "sein." -#: apps/member/views.py:680 +#: apps/member/views.py:673 msgid "The membership must start after {:%m-%d-%Y}." msgstr "Die Mitgliedschaft muss nach {:%m-%d-Y} anfängen." -#: apps/member/views.py:685 +#: apps/member/views.py:678 msgid "The membership must begin before {:%m-%d-%Y}." msgstr "Die Mitgliedschaft muss vor {:%m-%d-Y} anfängen." -#: apps/member/views.py:701 apps/member/views.py:703 apps/member/views.py:705 -#: apps/registration/views.py:292 apps/registration/views.py:294 -#: apps/registration/views.py:296 apps/wei/views.py:927 apps/wei/views.py:931 +#: apps/member/views.py:694 apps/member/views.py:696 apps/member/views.py:698 +#: apps/registration/views.py:287 apps/registration/views.py:289 +#: apps/registration/views.py:291 apps/wei/views.py:927 apps/wei/views.py:931 msgid "This field is required." msgstr "Dies ist ein Pflichtfeld." -#: apps/member/views.py:789 +#: apps/member/views.py:771 msgid "Manage roles of an user in the club" msgstr "Rollen in diesen Club bearbeiten" -#: apps/member/views.py:814 +#: apps/member/views.py:796 msgid "Members of the club" msgstr "Mitlglieder dieses Club" @@ -1301,7 +1301,7 @@ msgid "transaction templates" msgstr "Transaktionsvorlagen" #: apps/note/models/transactions.py:112 apps/note/models/transactions.py:125 -#: apps/note/tables.py:33 apps/note/tables.py:42 +#: apps/note/tables.py:34 apps/note/tables.py:44 msgid "used alias" msgstr "benutzte Aliasen" @@ -1313,7 +1313,7 @@ msgstr "Anzahl" msgid "reason" msgstr "Grund" -#: apps/note/models/transactions.py:151 apps/note/tables.py:107 +#: apps/note/models/transactions.py:151 apps/note/tables.py:139 msgid "invalidity reason" msgstr "Ungültigkeit Grund" @@ -1386,7 +1386,7 @@ msgstr "Sondertransaktion" msgid "Special transactions" msgstr "Sondertranskationen" -#: apps/note/models/transactions.py:354 apps/note/models/transactions.py:359 +#: apps/note/models/transactions.py:354 msgid "membership transaction" msgstr "Mitgliedschafttransaktion" @@ -1394,19 +1394,19 @@ msgstr "Mitgliedschafttransaktion" msgid "membership transactions" msgstr "Mitgliedschaftttransaktionen" -#: apps/note/tables.py:61 +#: apps/note/tables.py:93 msgid "Click to invalidate" msgstr "Klicken Sie zum Ungültigmachen" -#: apps/note/tables.py:61 +#: apps/note/tables.py:93 msgid "Click to validate" msgstr "Klicken Sie zum gültigmachen" -#: apps/note/tables.py:105 +#: apps/note/tables.py:137 msgid "No reason specified" msgstr "Kein Grund gegeben" -#: apps/note/tables.py:136 apps/note/tables.py:170 apps/treasury/tables.py:39 +#: apps/note/tables.py:168 apps/note/tables.py:202 apps/treasury/tables.py:39 #: apps/treasury/templates/treasury/invoice_confirm_delete.html:30 #: apps/treasury/templates/treasury/sogecredit_detail.html:59 #: apps/wei/tables.py:76 apps/wei/tables.py:103 @@ -1414,7 +1414,7 @@ msgstr "Kein Grund gegeben" msgid "Delete" msgstr "Löschen" -#: apps/note/tables.py:164 apps/note/templates/note/conso_form.html:132 +#: apps/note/tables.py:196 apps/note/templates/note/conso_form.html:132 #: apps/wei/tables.py:47 apps/wei/tables.py:48 #: apps/wei/templates/wei/base.html:89 #: apps/wei/templates/wei/bus_detail.html:20 @@ -1562,7 +1562,7 @@ msgstr "Tatsen finden" msgid "Update button" msgstr "Tatse bearbeiten" -#: apps/note/views.py:151 note_kfet/templates/base.html:62 +#: apps/note/views.py:151 note_kfet/templates/base.html:63 msgid "Consumptions" msgstr "Verbräuche" @@ -1740,7 +1740,7 @@ msgstr "" "diesen Parametern zu erstellen. Bitte korrigieren Sie Ihre Daten und " "versuchen Sie es erneut." -#: apps/permission/views.py:96 note_kfet/templates/base.html:104 +#: apps/permission/views.py:96 note_kfet/templates/base.html:105 msgid "Rights" msgstr "Rechten" @@ -1911,35 +1911,30 @@ msgstr "E-Mail-Validierungslink erneut senden" msgid "Pre-registered users list" msgstr "Vorregistrierte Userliste" -#: apps/registration/views.py:190 +#: apps/registration/views.py:187 msgid "Unregistered users" msgstr "Unregistrierte Users" -#: apps/registration/views.py:203 +#: apps/registration/views.py:200 msgid "Registration detail" msgstr "Registrierung Detailen" -#: apps/registration/views.py:258 +#: apps/registration/views.py:256 msgid "You must join the BDE." msgstr "Sie müssen die BDE beitreten." #: apps/registration/views.py:280 -msgid "You must join BDE club before joining Kfet club." -msgstr "" -"Sie müssen dem BDE-Club beitreten, bevor Sie dem Kfet-Club beitreten können." - -#: apps/registration/views.py:285 msgid "" "The entered amount is not enough for the memberships, should be at least {}" msgstr "" "Der eingegebene Betrag reicht für die Mitgliedschaft nicht aus, sollte " "mindestens {} betragen" -#: apps/registration/views.py:360 +#: apps/registration/views.py:355 msgid "Invalidate pre-registration" msgstr "Ungültige Vorregistrierung" -#: apps/treasury/apps.py:12 note_kfet/templates/base.html:92 +#: apps/treasury/apps.py:12 note_kfet/templates/base.html:93 msgid "Treasury" msgstr "Quaestor" @@ -2330,7 +2325,7 @@ msgstr "Krediten von der Société générale handeln" #: apps/wei/apps.py:10 apps/wei/models.py:49 apps/wei/models.py:50 #: apps/wei/models.py:61 apps/wei/models.py:167 -#: note_kfet/templates/base.html:98 +#: note_kfet/templates/base.html:99 msgid "WEI" msgstr "WEI" @@ -2938,19 +2933,19 @@ msgstr "Reset" msgid "The ENS Paris-Saclay BDE note." msgstr "Die BDE ENS-Paris-Saclay Note." -#: note_kfet/templates/base.html:74 +#: note_kfet/templates/base.html:75 msgid "Users" msgstr "Users" -#: note_kfet/templates/base.html:80 +#: note_kfet/templates/base.html:81 msgid "Clubs" msgstr "CLubs" -#: note_kfet/templates/base.html:109 +#: note_kfet/templates/base.html:110 msgid "Admin" msgstr "Admin" -#: note_kfet/templates/base.html:153 +#: note_kfet/templates/base.html:154 msgid "" "Your e-mail address is not validated. Please check your mail inbox and click " "on the validation link." diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 96feaac7..53b84ef2 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-09-02 23:17+0200\n" +"POT-Creation-Date: 2020-09-04 07:44+0200\n" "PO-Revision-Date: 2020-09-02 23:18+0200\n" "Last-Translator: \n" "Language-Team: \n" @@ -49,7 +49,7 @@ msgid "You can't invite more than 3 people to this activity." msgstr "Vous ne pouvez pas inviter plus de 3 personnes à cette activité." #: apps/activity/models.py:27 apps/activity/models.py:62 -#: apps/member/models.py:198 +#: apps/member/models.py:200 #: apps/member/templates/member/includes/club_info.html:4 #: apps/member/templates/member/includes/profile_info.html:4 #: apps/note/models/notes.py:247 apps/note/models/transactions.py:26 @@ -110,7 +110,7 @@ msgstr "Lieu où l'activité est organisée, par exemple la Kfet." msgid "type" msgstr "type" -#: apps/activity/models.py:88 apps/logs/models.py:22 apps/member/models.py:303 +#: apps/activity/models.py:88 apps/logs/models.py:22 apps/member/models.py:305 #: apps/note/models/notes.py:138 apps/treasury/models.py:267 #: apps/treasury/templates/treasury/sogecredit_detail.html:14 #: apps/wei/models.py:160 apps/wei/templates/wei/survey.html:15 @@ -273,7 +273,7 @@ msgstr "Liste des invités" #: apps/note/models/transactions.py:259 #: apps/note/templates/note/transaction_form.html:16 #: apps/note/templates/note/transaction_form.html:148 -#: note_kfet/templates/base.html:68 +#: note_kfet/templates/base.html:69 msgid "Transfer" msgstr "Virement" @@ -356,7 +356,7 @@ msgid "validate" msgstr "valider" #: apps/activity/templates/activity/includes/activity_info.html:71 -#: apps/logs/models.py:62 apps/note/tables.py:162 +#: apps/logs/models.py:62 apps/note/tables.py:194 msgid "edit" msgstr "modifier" @@ -368,7 +368,7 @@ msgstr "Inviter" msgid "Create new activity" msgstr "Créer une nouvelle activité" -#: apps/activity/views.py:59 note_kfet/templates/base.html:86 +#: apps/activity/views.py:59 note_kfet/templates/base.html:87 msgid "Activities" msgstr "Activités" @@ -434,7 +434,7 @@ msgstr "ouvelles données" msgid "create" msgstr "créer" -#: apps/logs/models.py:63 apps/note/tables.py:132 apps/note/tables.py:168 +#: apps/logs/models.py:63 apps/note/tables.py:164 apps/note/tables.py:200 #: apps/permission/models.py:127 apps/treasury/tables.py:38 #: apps/wei/tables.py:75 msgid "delete" @@ -460,21 +460,21 @@ msgstr "journal de modification" msgid "changelogs" msgstr "journaux de modifications" -#: apps/member/admin.py:52 apps/member/models.py:225 +#: apps/member/admin.py:50 apps/member/models.py:227 #: apps/member/templates/member/includes/club_info.html:34 msgid "membership fee (paid students)" msgstr "cotisation pour adhérer (normalien élève)" -#: apps/member/admin.py:53 apps/member/models.py:230 +#: apps/member/admin.py:51 apps/member/models.py:232 #: apps/member/templates/member/includes/club_info.html:37 msgid "membership fee (unpaid students)" msgstr "cotisation pour adhérer (normalien étudiant)" -#: apps/member/admin.py:67 apps/member/models.py:314 +#: apps/member/admin.py:65 apps/member/models.py:316 msgid "roles" msgstr "rôles" -#: apps/member/admin.py:68 apps/member/models.py:328 +#: apps/member/admin.py:66 apps/member/models.py:330 msgid "fee" msgstr "cotisation" @@ -502,8 +502,8 @@ msgstr "choisissez une image" msgid "Maximal size: 2MB" msgstr "Taille maximale : 2 Mo" -#: apps/member/forms.py:87 apps/member/views.py:101 -#: apps/registration/forms.py:33 +#: apps/member/forms.py:87 apps/member/views.py:100 +#: apps/registration/forms.py:33 apps/registration/views.py:237 msgid "An alias with a similar name already exists." msgstr "Un alias avec un nom similaire existe déjà." @@ -720,7 +720,7 @@ msgstr "profil utilisateur" msgid "Activate your Note Kfet account" msgstr "Activez votre compte Note Kfet" -#: apps/member/models.py:203 +#: apps/member/models.py:205 #: apps/member/templates/member/includes/club_info.html:55 #: apps/member/templates/member/includes/profile_info.html:31 #: apps/registration/templates/registration/future_profile_detail.html:22 @@ -729,88 +729,88 @@ msgstr "Activez votre compte Note Kfet" msgid "email" msgstr "courriel" -#: apps/member/models.py:210 +#: apps/member/models.py:212 msgid "parent club" msgstr "club parent" -#: apps/member/models.py:219 +#: apps/member/models.py:221 msgid "require memberships" msgstr "nécessite des adhésions" -#: apps/member/models.py:220 +#: apps/member/models.py:222 msgid "Uncheck if this club don't require memberships." msgstr "Décochez si ce club n'utilise pas d'adhésions." -#: apps/member/models.py:236 +#: apps/member/models.py:238 #: apps/member/templates/member/includes/club_info.html:26 msgid "membership duration" msgstr "durée de l'adhésion" -#: apps/member/models.py:237 +#: apps/member/models.py:239 msgid "The longest time (in days) a membership can last (NULL = infinite)." msgstr "La durée maximale (en jours) d'une adhésion (NULL = infinie)." -#: apps/member/models.py:244 +#: apps/member/models.py:246 #: apps/member/templates/member/includes/club_info.html:16 msgid "membership start" msgstr "début de l'adhésion" -#: apps/member/models.py:245 +#: apps/member/models.py:247 msgid "Date from which the members can renew their membership." msgstr "" "Date à partir de laquelle les adhérents peuvent renouveler leur adhésion." -#: apps/member/models.py:251 +#: apps/member/models.py:253 #: apps/member/templates/member/includes/club_info.html:21 msgid "membership end" msgstr "fin de l'adhésion" -#: apps/member/models.py:252 +#: apps/member/models.py:254 msgid "Maximal date of a membership, after which members must renew it." msgstr "" "Date maximale d'une fin d'adhésion, après laquelle les adhérents doivent la " "renouveler." -#: apps/member/models.py:284 apps/member/models.py:309 +#: apps/member/models.py:286 apps/member/models.py:311 #: apps/note/models/notes.py:179 msgid "club" msgstr "club" -#: apps/member/models.py:285 +#: apps/member/models.py:287 msgid "clubs" msgstr "clubs" -#: apps/member/models.py:319 +#: apps/member/models.py:321 msgid "membership starts on" msgstr "l'adhésion commence le" -#: apps/member/models.py:323 +#: apps/member/models.py:325 msgid "membership ends on" msgstr "l'adhésion finit le" -#: apps/member/models.py:374 +#: apps/member/models.py:375 #, python-brace-format msgid "The role {role} does not apply to the club {club}." msgstr "Le rôle {role} ne s'applique pas au club {club}." -#: apps/member/models.py:385 apps/member/views.py:676 +#: apps/member/models.py:384 apps/member/views.py:669 msgid "User is already a member of the club" msgstr "L'utilisateur est déjà membre du club" -#: apps/member/models.py:433 +#: apps/member/models.py:432 msgid "User is not a member of the parent club" msgstr "L'utilisateur n'est pas membre du club parent" -#: apps/member/models.py:486 +#: apps/member/models.py:480 #, python-brace-format msgid "Membership of {user} for the club {club}" msgstr "Adhésion de {user} pour le club {club}" -#: apps/member/models.py:489 +#: apps/member/models.py:483 apps/note/models/transactions.py:359 msgid "membership" msgstr "adhésion" -#: apps/member/models.py:490 +#: apps/member/models.py:484 msgid "memberships" msgstr "adhésions" @@ -904,8 +904,8 @@ msgstr "" "à nouveau possible." #: apps/member/templates/member/club_alias.html:10 -#: apps/member/templates/member/profile_alias.html:10 apps/member/views.py:240 -#: apps/member/views.py:450 +#: apps/member/templates/member/profile_alias.html:10 apps/member/views.py:238 +#: apps/member/views.py:443 msgid "Note aliases" msgstr "Alias de la note" @@ -1036,43 +1036,43 @@ msgstr "Inscriptions" msgid "This address must be valid." msgstr "Cette adresse doit être valide." -#: apps/member/views.py:138 +#: apps/member/views.py:137 msgid "Profile detail" msgstr "Détails de l'utilisateur" -#: apps/member/views.py:201 +#: apps/member/views.py:197 msgid "Search user" msgstr "Chercher un utilisateur" -#: apps/member/views.py:260 +#: apps/member/views.py:258 msgid "Update note picture" msgstr "Modifier la photo de la note" -#: apps/member/views.py:318 +#: apps/member/views.py:311 msgid "Manage auth token" msgstr "Gérer les jetons d'authentification" -#: apps/member/views.py:346 +#: apps/member/views.py:338 msgid "Create new club" msgstr "Créer un nouveau club" -#: apps/member/views.py:364 +#: apps/member/views.py:357 msgid "Search club" msgstr "Chercher un club" -#: apps/member/views.py:397 +#: apps/member/views.py:390 msgid "Club detail" msgstr "Détails du club" -#: apps/member/views.py:473 +#: apps/member/views.py:466 msgid "Update club" msgstr "Modifier le club" -#: apps/member/views.py:507 +#: apps/member/views.py:500 msgid "Add new member to the club" msgstr "Ajouter un nouveau membre au club" -#: apps/member/views.py:667 apps/wei/views.py:922 +#: apps/member/views.py:660 apps/wei/views.py:922 msgid "" "This user don't have enough money to join this club, and can't have a " "negative balance." @@ -1080,25 +1080,25 @@ msgstr "" "Cet utilisateur n'a pas assez d'argent pour rejoindre ce club et ne peut pas " "avoir un solde négatif." -#: apps/member/views.py:680 +#: apps/member/views.py:673 msgid "The membership must start after {:%m-%d-%Y}." msgstr "L'adhésion doit commencer après le {:%d/%m/%Y}." -#: apps/member/views.py:685 +#: apps/member/views.py:678 msgid "The membership must begin before {:%m-%d-%Y}." msgstr "L'adhésion doit commencer avant le {:%d/%m/%Y}." -#: apps/member/views.py:701 apps/member/views.py:703 apps/member/views.py:705 -#: apps/registration/views.py:292 apps/registration/views.py:294 -#: apps/registration/views.py:296 apps/wei/views.py:927 apps/wei/views.py:931 +#: apps/member/views.py:694 apps/member/views.py:696 apps/member/views.py:698 +#: apps/registration/views.py:287 apps/registration/views.py:289 +#: apps/registration/views.py:291 apps/wei/views.py:927 apps/wei/views.py:931 msgid "This field is required." msgstr "Ce champ est requis." -#: apps/member/views.py:789 +#: apps/member/views.py:771 msgid "Manage roles of an user in the club" msgstr "Gérer les rôles d'un utilisateur dans le club" -#: apps/member/views.py:814 +#: apps/member/views.py:796 msgid "Members of the club" msgstr "Membres du club" @@ -1304,7 +1304,7 @@ msgid "transaction templates" msgstr "modèles de transaction" #: apps/note/models/transactions.py:112 apps/note/models/transactions.py:125 -#: apps/note/tables.py:33 apps/note/tables.py:42 +#: apps/note/tables.py:34 apps/note/tables.py:44 msgid "used alias" msgstr "alias utilisé" @@ -1316,7 +1316,7 @@ msgstr "quantité" msgid "reason" msgstr "raison" -#: apps/note/models/transactions.py:151 apps/note/tables.py:107 +#: apps/note/models/transactions.py:151 apps/note/tables.py:139 msgid "invalidity reason" msgstr "motif d'invalidité" @@ -1391,7 +1391,7 @@ msgstr "Transaction de crédit/retrait" msgid "Special transactions" msgstr "Transactions de crédit/retrait" -#: apps/note/models/transactions.py:354 apps/note/models/transactions.py:359 +#: apps/note/models/transactions.py:354 msgid "membership transaction" msgstr "transaction d'adhésion" @@ -1399,19 +1399,19 @@ msgstr "transaction d'adhésion" msgid "membership transactions" msgstr "transactions d'adhésion" -#: apps/note/tables.py:61 +#: apps/note/tables.py:93 msgid "Click to invalidate" msgstr "Cliquez pour dévalider" -#: apps/note/tables.py:61 +#: apps/note/tables.py:93 msgid "Click to validate" msgstr "Cliquez pour valider" -#: apps/note/tables.py:105 +#: apps/note/tables.py:137 msgid "No reason specified" msgstr "Pas de motif spécifié" -#: apps/note/tables.py:136 apps/note/tables.py:170 apps/treasury/tables.py:39 +#: apps/note/tables.py:168 apps/note/tables.py:202 apps/treasury/tables.py:39 #: apps/treasury/templates/treasury/invoice_confirm_delete.html:30 #: apps/treasury/templates/treasury/sogecredit_detail.html:59 #: apps/wei/tables.py:76 apps/wei/tables.py:103 @@ -1419,7 +1419,7 @@ msgstr "Pas de motif spécifié" msgid "Delete" msgstr "Supprimer" -#: apps/note/tables.py:164 apps/note/templates/note/conso_form.html:132 +#: apps/note/tables.py:196 apps/note/templates/note/conso_form.html:132 #: apps/wei/tables.py:47 apps/wei/tables.py:48 #: apps/wei/templates/wei/base.html:89 #: apps/wei/templates/wei/bus_detail.html:20 @@ -1567,7 +1567,7 @@ msgstr "Chercher un bouton" msgid "Update button" msgstr "Modifier le bouton" -#: apps/note/views.py:151 note_kfet/templates/base.html:62 +#: apps/note/views.py:151 note_kfet/templates/base.html:63 msgid "Consumptions" msgstr "Consommations" @@ -1746,7 +1746,7 @@ msgstr "" "Vous n'avez pas la permission d'ajouter une instance du modèle « {model} » " "avec ces paramètres. Merci de les corriger et de réessayer." -#: apps/permission/views.py:96 note_kfet/templates/base.html:104 +#: apps/permission/views.py:96 note_kfet/templates/base.html:105 msgid "Rights" msgstr "Droits" @@ -1915,34 +1915,30 @@ msgstr "Renvoyer le lien de validation" msgid "Pre-registered users list" msgstr "Liste des utilisateurs en attente d'inscription" -#: apps/registration/views.py:190 +#: apps/registration/views.py:187 msgid "Unregistered users" msgstr "Utilisateurs en attente d'inscription" -#: apps/registration/views.py:203 +#: apps/registration/views.py:200 msgid "Registration detail" msgstr "Détails de l'inscription" -#: apps/registration/views.py:258 +#: apps/registration/views.py:256 msgid "You must join the BDE." msgstr "Vous devez adhérer au BDE." #: apps/registration/views.py:280 -msgid "You must join BDE club before joining Kfet club." -msgstr "Vous devez adhérer au club BDE avant d'adhérer au club Kfet." - -#: apps/registration/views.py:285 msgid "" "The entered amount is not enough for the memberships, should be at least {}" msgstr "" "Le montant crédité est trop faible pour adhérer, il doit être au minimum de " "{}" -#: apps/registration/views.py:360 +#: apps/registration/views.py:355 msgid "Invalidate pre-registration" msgstr "Invalider l'inscription" -#: apps/treasury/apps.py:12 note_kfet/templates/base.html:92 +#: apps/treasury/apps.py:12 note_kfet/templates/base.html:93 msgid "Treasury" msgstr "Trésorerie" @@ -2331,7 +2327,7 @@ msgstr "Gérer les crédits de la Société générale" #: apps/wei/apps.py:10 apps/wei/models.py:49 apps/wei/models.py:50 #: apps/wei/models.py:61 apps/wei/models.py:167 -#: note_kfet/templates/base.html:98 +#: note_kfet/templates/base.html:99 msgid "WEI" msgstr "WEI" @@ -2939,19 +2935,19 @@ msgstr "Réinitialiser" msgid "The ENS Paris-Saclay BDE note." msgstr "La note du BDE de l'ENS Paris-Saclay." -#: note_kfet/templates/base.html:74 +#: note_kfet/templates/base.html:75 msgid "Users" msgstr "Utilisateurs" -#: note_kfet/templates/base.html:80 +#: note_kfet/templates/base.html:81 msgid "Clubs" msgstr "Clubs" -#: note_kfet/templates/base.html:109 +#: note_kfet/templates/base.html:110 msgid "Admin" msgstr "Admin" -#: note_kfet/templates/base.html:153 +#: note_kfet/templates/base.html:154 msgid "" "Your e-mail address is not validated. Please check your mail inbox and click " "on the validation link." @@ -3106,914 +3102,3 @@ msgstr "" "vous connecter. Vous devez vous rendre à la Kfet et payer les frais " "d'adhésion. Vous devez également valider votre adresse email en suivant le " "lien que vous avez reçu." - -#~ msgid "Enter a valid color." -#~ msgstr "Entrer une couleur valide." - -#~ msgid "i18n text" -#~ msgstr "i18n text" - -#~ msgid "i18n legend" -#~ msgstr "i18n legend" - -#~ msgid "Enter a valid value." -#~ msgstr "Entrer une valeur correcte." - -#~ msgid "Messages" -#~ msgstr "Messages" - -#~ msgid "Site Maps" -#~ msgstr "Plan du site" - -#~ msgid "Static Files" -#~ msgstr "Fichiers statiques" - -#~ msgid "Syndication" -#~ msgstr "Invitation" - -#~ msgid "That page number is not an integer" -#~ msgstr "Ce numéro de page n'est pas entier" - -#~ msgid "That page number is less than 1" -#~ msgstr "Ce numéro de page est inférieur à 1" - -#~ msgid "That page contains no results" -#~ msgstr "Il n'y a pas de résultat" - -#~ msgid "Enter a valid URL." -#~ msgstr "Entrer une URL valide." - -#~ msgid "Enter a valid integer." -#~ msgstr "Entrer un entier valid." - -#~ msgid "Enter a valid email address." -#~ msgstr "Entrer une adresse mail valide." - -#~ msgid "" -#~ "Enter a valid 'slug' consisting of letters, numbers, underscores or " -#~ "hyphens." -#~ msgstr "" -#~ "Entrer un 'slug' valide, constitué de lettres, chiffres, tirets ou tirets " -#~ "bas." - -#~ msgid "" -#~ "Enter a valid 'slug' consisting of Unicode letters, numbers, underscores, " -#~ "or hyphens." -#~ msgstr "" -#~ "Entrer un 'slug' valide, constitué de caractère unicode, chiffres, tirets " -#~ "ou underscores." - -#~ msgid "Enter a valid IPv4 address." -#~ msgstr "Entrer une adresse IPv4 valide." - -#~ msgid "Enter a valid IPv6 address." -#~ msgstr "Entrer une adresse IPv6 valide." - -#~ msgid "Enter a valid IPv4 or IPv6 address." -#~ msgstr "Entrer une adresse IPv4 ou IPv6 valide." - -#~ msgid "Enter only digits separated by commas." -#~ msgstr "Entrer seulement des chiffres séparés par des virgules." - -#~ msgid "Ensure this value is %(limit_value)s (it is %(show_value)s)." -#~ msgstr "" -#~ "Vérifier que cette valeur est %(limit_value)s (actuellement " -#~ "%(show_value)s)." - -#~ msgid "Ensure this value is less than or equal to %(limit_value)s." -#~ msgstr "Vérifier que cette valeur est plus petite que %(limit_value)s." - -#~ msgid "Ensure this value is greater than or equal to %(limit_value)s." -#~ msgstr "Vérifier que cette valeur est plus grande que %(limit_value)s." - -#~ msgid "" -#~ "Ensure this value has at least %(limit_value)d character (it has " -#~ "%(show_value)d)." -#~ msgid_plural "" -#~ "Ensure this value has at least %(limit_value)d characters (it has " -#~ "%(show_value)d)." -#~ msgstr[0] "" -#~ "Vérifier que cette valeur a au moins %(limit_value)d caractère " -#~ "(actuellement %(show_value)d)." -#~ msgstr[1] "" -#~ "Assurer vous que cette valeur a au moins %(limit_value)d caractères " -#~ "(actuellement %(show_value)d)." - -#~ msgid "" -#~ "Ensure this value has at most %(limit_value)d character (it has " -#~ "%(show_value)d)." -#~ msgid_plural "" -#~ "Ensure this value has at most %(limit_value)d characters (it has " -#~ "%(show_value)d)." -#~ msgstr[0] "" -#~ "Vérifier que cette valeur a au plus %(limit_value)d caractère " -#~ "(actuellement %(show_value)d)." -#~ msgstr[1] "" -#~ "Assurer vous que cette valeur a au plus %(limit_value)d caractères " -#~ "(actuellement %(show_value)d)." - -#~ msgid "Enter a number." -#~ msgstr "Numéro de téléphone." - -#~ msgid "Ensure that there are no more than %(max)s digit in total." -#~ msgid_plural "Ensure that there are no more than %(max)s digits in total." -#~ msgstr[0] "Vérifier qu'il n'y a pas plus de %(max)s chiffre au total." -#~ msgstr[1] "Vérifier qu'il n'y a pas plus de %(max)s chiffres au total." - -#~ msgid "Ensure that there are no more than %(max)s decimal place." -#~ msgid_plural "Ensure that there are no more than %(max)s decimal places." -#~ msgstr[0] "Vérifier qu'il y n'as pas plus de %(max)s chiffre décimal." -#~ msgstr[1] "Vérifier qu'il y n'as pas plus de %(max)s chiffres décimaux." - -#~ msgid "" -#~ "Ensure that there are no more than %(max)s digit before the decimal point." -#~ msgid_plural "" -#~ "Ensure that there are no more than %(max)s digits before the decimal " -#~ "point." -#~ msgstr[0] "" -#~ "Vérifier qu'il y n'as pas plus d'%(max)s chiffre avant la virgule." -#~ msgstr[1] "" -#~ "Vérifier qu'il y n'as pas plus de %(max)s chiffres avant la virgule." - -#~ msgid "" -#~ "File extension '%(extension)s' is not allowed. Allowed extensions are: " -#~ "'%(allowed_extensions)s'." -#~ msgstr "" -#~ "Les fichiers d'extension '%(extension)s' ne sont pas autorisé. Les " -#~ "extension autorisées sont: \"%(allowed_extensions)s'." - -#~ msgid "Null characters are not allowed." -#~ msgstr "Les caractères nuls ne sont pas autorisés." - -#~ msgid "and" -#~ msgstr "et" - -#~ msgid "%(model_name)s with this %(field_labels)s already exists." -#~ msgstr "Un %(model_name)s avec ce %(field_labels)s existe déjà." - -#~ msgid "Value %(value)r is not a valid choice." -#~ msgstr "Le choix %(value)r n'est pas possible." - -#~ msgid "This field cannot be null." -#~ msgstr "Ce champ est requis." - -#~ msgid "This field cannot be blank." -#~ msgstr "Ce champ est requis." - -#~ msgid "%(model_name)s with this %(field_label)s already exists." -#~ msgstr "Un %(model_name)s avec ce %(field_label)s existe déjà." - -#~ msgid "" -#~ "%(field_label)s must be unique for %(date_field_label)s %(lookup_type)s." -#~ msgstr "" -#~ "%(field_label)s doit être unique pour %(date_field_label)s " -#~ "%(lookup_type)s." - -#~ msgid "Field of type: %(field_type)s" -#~ msgstr "Champ du type %(field_type)s" - -#~ msgid "Integer" -#~ msgstr "Nombre entier" - -#~ msgid "'%(value)s' value must be an integer." -#~ msgstr "'%(value)s' doit être un nombre entier." - -#~ msgid "Big (8 byte) integer" -#~ msgstr "Gros nombre entier (8 octets)" - -#~ msgid "'%(value)s' value must be either True or False." -#~ msgstr "'%(value)s' doit être Vrai ou Faux." - -#~ msgid "'%(value)s' value must be either True, False, or None." -#~ msgstr "'%(value)s' doit être Vrai, Faux, ou None." - -#~ msgid "Boolean (Either True or False)" -#~ msgstr "Booléen (Vrai ou Faux)" - -#~ msgid "String (up to %(max_length)s)" -#~ msgstr "Chaîne de caractère (maximum %(max_length)s caractères)" - -#~ msgid "Comma-separated integers" -#~ msgstr "Liste d'entier séparer par des virgules" - -#~ msgid "" -#~ "'%(value)s' value has an invalid date format. It must be in YYYY-MM-DD " -#~ "format." -#~ msgstr "'%(value)s n'est pas formaté correctement (AAAA-MM-JJ)." - -#~ msgid "" -#~ "'%(value)s' value has the correct format (YYYY-MM-DD) but it is an " -#~ "invalid date." -#~ msgstr "" -#~ "'%(value)s possède le format date requis (AAAA-MM-JJ) mais n'est pas une " -#~ "date valide." - -#~ msgid "Date (without time)" -#~ msgstr "Date (sans horaire)" - -#~ msgid "" -#~ "'%(value)s' value has an invalid format. It must be in YYYY-MM-DD HH:MM[:" -#~ "ss[.uuuuuu]][TZ] format." -#~ msgstr "" -#~ "'%(value)s' n'est pas formatée correctement (AAAA-MM-JJ HH:MM[:ss[." -#~ "uuuuuu]][TZ])." - -#~ msgid "" -#~ "'%(value)s' value has the correct format (YYYY-MM-DD HH:MM[:ss[.uuuuuu]]" -#~ "[TZ]) but it is an invalid date/time." -#~ msgstr "" -#~ "'%(value)s' est formaté correctement, mais n'est pas une date/horaire " -#~ "valide." - -#~ msgid "Date (with time)" -#~ msgstr "Date (avec horaire)" - -#~ msgid "'%(value)s' value must be a decimal number." -#~ msgstr "'%(value)s doit être un nombre décimal." - -#~ msgid "Decimal number" -#~ msgstr "Nombre décimal" - -#~ msgid "" -#~ "'%(value)s' value has an invalid format. It must be in [DD] [HH:[MM:]]ss[." -#~ "uuuuuu] format." -#~ msgstr "" -#~ "'%(value)s' n'est pas formatée correctement (AAAA-MM-JJ HH:MM[:ss[." -#~ "uuuuuu]][TZ])." - -#~ msgid "Duration" -#~ msgstr "Durée" - -#~ msgid "Email address" -#~ msgstr "Courriel" - -#~ msgid "File path" -#~ msgstr "Chemin du fichier" - -#~ msgid "'%(value)s' value must be a float." -#~ msgstr "'%(value)s doit être un nombre décimal." - -#~ msgid "Floating point number" -#~ msgstr "Nombre décimal" - -#~ msgid "IPv4 address" -#~ msgstr "Adresse IPv4" - -#~ msgid "IP address" -#~ msgstr "Adresse IP" - -#~ msgid "'%(value)s' value must be either None, True or False." -#~ msgstr "'%(value)s' doit être Vrai, Faux, ou None." - -#~ msgid "Boolean (Either True, False or None)" -#~ msgstr "Booléen (Vrai ou Faux)" - -#~ msgid "Positive integer" -#~ msgstr "Nombre entier positif" - -#~ msgid "Positive small integer" -#~ msgstr "Nombre entier positif petit" - -#~ msgid "Slug (up to %(max_length)s)" -#~ msgstr "Slug (maximum %(max_length)s caractères)" - -#~ msgid "Small integer" -#~ msgstr "Petit entier" - -#~ msgid "Text" -#~ msgstr "Texte" - -#~ msgid "" -#~ "'%(value)s' value has an invalid format. It must be in HH:MM[:ss[." -#~ "uuuuuu]] format." -#~ msgstr "'%(value)s' n'est pas formatée correctement (HH:MM[:ss[.uuuuuu]])." - -#~ msgid "" -#~ "'%(value)s' value has the correct format (HH:MM[:ss[.uuuuuu]]) but it is " -#~ "an invalid time." -#~ msgstr "" -#~ "'%(value)s' est formaté correctement, mais n'est pas un horaire valide." - -#~ msgid "Time" -#~ msgstr "Temps" - -#~ msgid "URL" -#~ msgstr "URL" - -#~ msgid "Raw binary data" -#~ msgstr "Donnée binaire brute" - -#~ msgid "'%(value)s' is not a valid UUID." -#~ msgstr "'%(value)s' n'est pas un UUID valide." - -#~ msgid "Universally unique identifier" -#~ msgstr "Identifiant unique universel" - -#~ msgid "File" -#~ msgstr "Fichier" - -#~ msgid "Image" -#~ msgstr "Image" - -#~ msgid "%(model)s instance with %(field)s %(value)r does not exist." -#~ msgstr "" -#~ "l'objet %(model)s avec les champs %(field)s %(value)r n'existe pas." - -#~ msgid "Foreign Key (type determined by related field)" -#~ msgstr "Relations plusieurs-à-un (type déterminé par le champ relié)" - -#~ msgid "One-to-one relationship" -#~ msgstr "Relation un-à-un" - -#~ msgid "%(from)s-%(to)s relationship" -#~ msgstr "Relation %(from)s-%(to)s" - -#~ msgid "%(from)s-%(to)s relationships" -#~ msgstr "Relations %(from)s-%(to)s" - -#~ msgid "Many-to-many relationship" -#~ msgstr "Relation plusieurs-à-plusieurs" - -#~ msgid ":?.!" -#~ msgstr ":?.!" - -#~ msgid "Enter a whole number." -#~ msgstr "Entrer un numéro complet." - -#~ msgid "Enter a valid date." -#~ msgstr "Entrer une date valide." - -#~ msgid "Enter a valid time." -#~ msgstr "Entrer un horaire valide." - -#~ msgid "Enter a valid date/time." -#~ msgstr "Entrer une date et un horaire valide." - -#~ msgid "Enter a valid duration." -#~ msgstr "Entrer une durée valide." - -#~ msgid "The number of days must be between {min_days} and {max_days}." -#~ msgstr "" -#~ "Le nombre de jours doit être compris entre {min_days} et {max_days}." - -#~ msgid "No file was submitted. Check the encoding type on the form." -#~ msgstr "Aucun fichier n'as été ajouté. Vérifier l'encodage du formulaire." - -#~ msgid "No file was submitted." -#~ msgstr "Aucun fichier n'as été ajouté." - -#~ msgid "The submitted file is empty." -#~ msgstr "Le fichier ajouté est vide." - -#~ msgid "" -#~ "Ensure this filename has at most %(max)d character (it has %(length)d)." -#~ msgid_plural "" -#~ "Ensure this filename has at most %(max)d characters (it has %(length)d)." -#~ msgstr[0] "" -#~ "Vérifier que le nom du fichier a au plus %(max)d caractère (actuellement " -#~ "%(length)d)." -#~ msgstr[1] "" -#~ "Vérifier que le nom du fichier a au plus %(max)d caractères (actuellement " -#~ "%(length)d)." - -#~ msgid "Please either submit a file or check the clear checkbox, not both." -#~ msgstr "Veuillez ajouter un fichier ou cocher la case envoi vide." - -#~ msgid "" -#~ "Upload a valid image. The file you uploaded was either not an image or a " -#~ "corrupted image." -#~ msgstr "Veuillez choisir une image valide." - -#~ msgid "" -#~ "Select a valid choice. %(value)s is not one of the available choices." -#~ msgstr "Faites un choix valide. %(value)s n'est pas autorisé." - -#~ msgid "Enter a list of values." -#~ msgstr "Entrer une liste de valeurs." - -#~ msgid "Enter a complete value." -#~ msgstr "Entrer une valeur complète." - -#~ msgid "Enter a valid UUID." -#~ msgstr "Entrer un UUID valid." - -#~ msgid ":" -#~ msgstr ":" - -#~ msgid "(Hidden field %(name)s) %(error)s" -#~ msgstr "(Champ caché %(name)s) %(error)s" - -#~ msgid "ManagementForm data is missing or has been tampered with" -#~ msgstr "Les données ManagementForm sont manquantes ou falsifiées" - -#~ msgid "Please submit %d or fewer forms." -#~ msgid_plural "Please submit %d or fewer forms." -#~ msgstr[0] "Veuillez remplir %d formulaire ou moins." -#~ msgstr[1] "Veuillez remplir %d formulaires ou moins." - -#~ msgid "Please submit %d or more forms." -#~ msgid_plural "Please submit %d or more forms." -#~ msgstr[0] "Veuillez remplir %d formulaire ou plus." -#~ msgstr[1] "Veuillez remplir %d formulaires ou plus." - -#~ msgid "Order" -#~ msgstr "Ordre" - -#~ msgid "Please correct the duplicate data for %(field)s." -#~ msgstr "Veuillez corriger les données dupliquées pour %(field)s." - -#~ msgid "" -#~ "Please correct the duplicate data for %(field)s, which must be unique." -#~ msgstr "Veuillez rendre uniques les valeurs des champs %(field)s." - -#~ msgid "" -#~ "Please correct the duplicate data for %(field_name)s which must be unique " -#~ "for the %(lookup)s in %(date_field)s." -#~ msgstr "" -#~ "Veuillez rendre unique les valeurs des champs %(field_name)s pour les " -#~ "%(lookup)s dans %(date_field)s." - -#~ msgid "Please correct the duplicate values below." -#~ msgstr "Veuillez rendre uniques les valeurs ci-dessous." - -#~ msgid "The inline value did not match the parent instance." -#~ msgstr "La valeur renseignée ne correspond pas à l'objet parent." - -#~ msgid "" -#~ "Select a valid choice. That choice is not one of the available choices." -#~ msgstr "Faites un choix possible." - -#~ msgid "\"%(pk)s\" is not a valid value." -#~ msgstr "\"%(pk)s\" n'est pas une valeur autorisée." - -#~ msgid "" -#~ "%(datetime)s couldn't be interpreted in time zone %(current_timezone)s; " -#~ "it may be ambiguous or it may not exist." -#~ msgstr "" -#~ "%(datetime)s n'a pas pu être interprété dans la zone " -#~ "%(current_timezone)s, La valeur est ambigüe ou impossible." - -#~ msgid "Clear" -#~ msgstr "Vider" - -#~ msgid "Currently" -#~ msgstr "Activité en cours" - -#~ msgid "Change" -#~ msgstr "Modifier" - -#~ msgid "Unknown" -#~ msgstr "Inconnu" - -#~ msgid "yes,no,maybe" -#~ msgstr "oui, non, peut-être" - -#~ msgid "%(size)d byte" -#~ msgid_plural "%(size)d bytes" -#~ msgstr[0] "%(size)d octet" -#~ msgstr[1] "%(size)d octets" - -#~ msgid "%s KB" -#~ msgstr "%s Ko" - -#~ msgid "%s MB" -#~ msgstr "%s Mo" - -#~ msgid "%s GB" -#~ msgstr "%s Go" - -#~ msgid "%s TB" -#~ msgstr "%s To" - -#~ msgid "%s PB" -#~ msgstr "%s Po" - -#~ msgid "p.m." -#~ msgstr "p.m." - -#~ msgid "a.m." -#~ msgstr "a.m." - -#~ msgid "PM" -#~ msgstr "PM" - -#~ msgid "AM" -#~ msgstr "AM" - -#~ msgid "midnight" -#~ msgstr "minuit" - -#~ msgid "noon" -#~ msgstr "midi" - -#~ msgid "Monday" -#~ msgstr "Lundi" - -#~ msgid "Tuesday" -#~ msgstr "Mardi" - -#~ msgid "Wednesday" -#~ msgstr "Mercredi" - -#~ msgid "Thursday" -#~ msgstr "Jeudi" - -#~ msgid "Friday" -#~ msgstr "Vendredi" - -#~ msgid "Saturday" -#~ msgstr "Samedi" - -#~ msgid "Sunday" -#~ msgstr "Dimanche" - -#~ msgid "Mon" -#~ msgstr "Lun" - -#~ msgid "Tue" -#~ msgstr "Mar" - -#~ msgid "Wed" -#~ msgstr "Mer" - -#~ msgid "Thu" -#~ msgstr "Jeu" - -#~ msgid "Fri" -#~ msgstr "Ven" - -#~ msgid "Sat" -#~ msgstr "Sam" - -#~ msgid "Sun" -#~ msgstr "Dim" - -#~ msgid "January" -#~ msgstr "Janvier" - -#~ msgid "February" -#~ msgstr "Février" - -#~ msgid "March" -#~ msgstr "Mars" - -#~ msgid "April" -#~ msgstr "Avril" - -#~ msgid "May" -#~ msgstr "Mai" - -#~ msgid "June" -#~ msgstr "Juin" - -#~ msgid "July" -#~ msgstr "Juillet" - -#~ msgid "August" -#~ msgstr "Août" - -#~ msgid "September" -#~ msgstr "Septembre" - -#~ msgid "October" -#~ msgstr "Octobre" - -#~ msgid "November" -#~ msgstr "Novembre" - -#~ msgid "December" -#~ msgstr "Décembre" - -#~ msgid "jan" -#~ msgstr "jan" - -#~ msgid "feb" -#~ msgstr "fev" - -#~ msgid "mar" -#~ msgstr "mar" - -#~ msgid "apr" -#~ msgstr "avr" - -#~ msgid "may" -#~ msgstr "mai" - -#~ msgid "jun" -#~ msgstr "jun" - -#~ msgid "jul" -#~ msgstr "jui" - -#~ msgid "aug" -#~ msgstr "aou" - -#~ msgid "sep" -#~ msgstr "sep" - -#~ msgid "oct" -#~ msgstr "oct" - -#~ msgid "nov" -#~ msgstr "nov" - -#~ msgid "dec" -#~ msgstr "dec" - -#~ msgctxt "abbrev. month" -#~ msgid "Jan." -#~ msgstr "[abbrev. month] Jan." - -#~ msgctxt "abbrev. month" -#~ msgid "Feb." -#~ msgstr "[abbrev. month] Fév." - -#~ msgctxt "abbrev. month" -#~ msgid "March" -#~ msgstr "[abbrev. month] Mars" - -#~ msgctxt "abbrev. month" -#~ msgid "April" -#~ msgstr "[abbrev. month] Avril" - -#~ msgctxt "abbrev. month" -#~ msgid "May" -#~ msgstr "[abbrev. month] Mai" - -#~ msgctxt "abbrev. month" -#~ msgid "June" -#~ msgstr "[abbrev. month] Juin" - -#~ msgctxt "abbrev. month" -#~ msgid "July" -#~ msgstr "[abbrev. month] Jui" - -#~ msgctxt "abbrev. month" -#~ msgid "Aug." -#~ msgstr "[abbrev. month] Août." - -#~ msgctxt "abbrev. month" -#~ msgid "Sept." -#~ msgstr "[abbrev. month] Sep." - -#~ msgctxt "abbrev. month" -#~ msgid "Oct." -#~ msgstr "[abbrev. month] Oct." - -#~ msgctxt "abbrev. month" -#~ msgid "Nov." -#~ msgstr "[abbrev. month] Nov." - -#~ msgctxt "abbrev. month" -#~ msgid "Dec." -#~ msgstr "[abbrev. month] Dec." - -#~ msgctxt "alt. month" -#~ msgid "January" -#~ msgstr "[alt. month] Janvier" - -#~ msgctxt "alt. month" -#~ msgid "February" -#~ msgstr "[alt. month] Février" - -#~ msgctxt "alt. month" -#~ msgid "March" -#~ msgstr "[alt. month] Mars" - -#~ msgctxt "alt. month" -#~ msgid "April" -#~ msgstr "[alt. month] Avril" - -#~ msgctxt "alt. month" -#~ msgid "May" -#~ msgstr "[alt. month] Mai" - -#~ msgctxt "alt. month" -#~ msgid "June" -#~ msgstr "[alt. month] Juin" - -#~ msgctxt "alt. month" -#~ msgid "July" -#~ msgstr "[alt. month] Juillet" - -#~ msgctxt "alt. month" -#~ msgid "August" -#~ msgstr "[alt. month] Août" - -#~ msgctxt "alt. month" -#~ msgid "September" -#~ msgstr "[alt. month] Septembre" - -#~ msgctxt "alt. month" -#~ msgid "October" -#~ msgstr "[alt. month] Octobre" - -#~ msgctxt "alt. month" -#~ msgid "November" -#~ msgstr "[alt. month] Novembre" - -#~ msgctxt "alt. month" -#~ msgid "December" -#~ msgstr "[alt. month] Décembre" - -#~ msgid "This is not a valid IPv6 address." -#~ msgstr "Adresse IPv6 non valide." - -#~ msgctxt "String to return when truncating text" -#~ msgid "%(truncated_text)s…" -#~ msgstr "[String to return when truncating text] %(truncated_text)s…" - -#~ msgid "or" -#~ msgstr "ou" - -#~ msgid ", " -#~ msgstr ", " - -#~ msgid "%d year" -#~ msgid_plural "%d years" -#~ msgstr[0] "%d année" -#~ msgstr[1] "%d années" - -#~ msgid "%d month" -#~ msgid_plural "%d months" -#~ msgstr[0] "%d mois" -#~ msgstr[1] "%d mois" - -#~ msgid "%d week" -#~ msgid_plural "%d weeks" -#~ msgstr[0] "%d semaine" -#~ msgstr[1] "%d semaines" - -#~ msgid "%d day" -#~ msgid_plural "%d days" -#~ msgstr[0] "%d jour" -#~ msgstr[1] "%d jours" - -#~ msgid "%d hour" -#~ msgid_plural "%d hours" -#~ msgstr[0] "%d heure" -#~ msgstr[1] "%d heures" - -#~ msgid "%d minute" -#~ msgid_plural "%d minutes" -#~ msgstr[0] "%d minute" -#~ msgstr[1] "%d minutes" - -#~ msgid "0 minutes" -#~ msgstr "0 minutes" - -#~ msgid "Forbidden" -#~ msgstr "Interdit" - -#~ msgid "CSRF verification failed. Request aborted." -#~ msgstr "Echec de la vérification CSRF. Requête interrompue." - -#~ msgid "" -#~ "You are seeing this message because this HTTPS site requires a 'Referer " -#~ "header' to be sent by your Web browser, but none was sent. This header is " -#~ "required for security reasons, to ensure that your browser is not being " -#~ "hijacked by third parties." -#~ msgstr "" -#~ "Vous voyez ce message car ce site HTTPS nécessite l'envoi d'un \"en-tête " -#~ "référent\" par votre navigateur. Cet en-tête est necessaire pour des " -#~ "raisons de sécurité, pour confirmer que votre navigateur n'est pas " -#~ "compromis par un tiers." - -#~ msgid "" -#~ "If you have configured your browser to disable 'Referer' headers, please " -#~ "re-enable them, at least for this site, or for HTTPS connections, or for " -#~ "'same-origin' requests." -#~ msgstr "" -#~ "Si vous avez configurer votre navigateur pour désactiver les \"en-tête " -#~ "référent\", veuillez les réactiver, au moins pour ce site, ou pour les " -#~ "requêtes HTTPS, ou les requête de même origine." - -#~ msgid "" -#~ "If you are using the tag " -#~ "or including the 'Referrer-Policy: no-referrer' header, please remove " -#~ "them. The CSRF protection requires the 'Referer' header to do strict " -#~ "referer checking. If you're concerned about privacy, use alternatives " -#~ "like for links to third-party sites." -#~ msgstr "" -#~ "Si vous utiliser le tag " -#~ "ou incluez l'en-tête 'Referrer-Policy: no-referrer', veuillez les " -#~ "retirer. La protection CSRF nécessite que l'en-tête référent fasse une " -#~ "vérification stricte. Si vous avez des préoccupations pour votre vie " -#~ "privée, vous pouvez utiliser des alternatives comme pour les liens vers des sites tiers." - -#~ msgid "" -#~ "You are seeing this message because this site requires a CSRF cookie when " -#~ "submitting forms. This cookie is required for security reasons, to ensure " -#~ "that your browser is not being hijacked by third parties." -#~ msgstr "" -#~ "Vous voyez ce message car ce site nécessite un cookie CSRF pour l'envoi " -#~ "de formulaire. Ce cookie est demandé pour des raisons de sécurité, pour " -#~ "s'assurer que votre navigateur n'est pas compromis par un tiers." - -#~ msgid "" -#~ "If you have configured your browser to disable cookies, please re-enable " -#~ "them, at least for this site, or for 'same-origin' requests." -#~ msgstr "" -#~ "Si vous avez configuré votre navigateur pour désactiver les cookies, " -#~ "veuillez les réactiver, au moins pour ce site, ou pour les requêtes de " -#~ "même origine." - -#~ msgid "More information is available with DEBUG=True." -#~ msgstr "Plus d'informations disponible avec DEBUG=True." - -#~ msgid "No year specified" -#~ msgstr "Pas d'année spécifiée" - -#~ msgid "Date out of range" -#~ msgstr "Date impossible" - -#~ msgid "No month specified" -#~ msgstr "Pas de mois spécifié" - -#~ msgid "No day specified" -#~ msgstr "Pas de jours spécifié" - -#~ msgid "No week specified" -#~ msgstr "Pas de semaine spécifiée" - -#~ msgid "No %(verbose_name_plural)s available" -#~ msgstr "%(verbose_name_plural)s non disponible" - -#~ msgid "" -#~ "Future %(verbose_name_plural)s not available because %(class_name)s." -#~ "allow_future is False." -#~ msgstr "" -#~ "Le futur %(verbose_name_plural)s n'est pas possible car %(class_name)s." -#~ "allow_future = False." - -#~ msgid "Invalid date string '%(datestr)s' given format '%(format)s'" -#~ msgstr "Date '%(datestr)s' au format non valide '%(format)s'." - -#~ msgid "No %(verbose_name)s found matching the query" -#~ msgstr "Aucun %(verbose_name)s trouvé pour cette requête" - -#~ msgid "Page is not 'last', nor can it be converted to an int." -#~ msgstr "" -#~ "La page spécifié n'est pas la dernière (last) et ne peux pas être " -#~ "convertie en nombre entier." - -#~ msgid "Invalid page (%(page_number)s): %(message)s" -#~ msgstr "Le numéro de page %(page_number)s est non valide: %(message)s" - -#~ msgid "Empty list and '%(class_name)s.allow_empty' is False." -#~ msgstr "Liste vide et '%(class_name)s.allow_empty = False." - -#~ msgid "Directory indexes are not allowed here." -#~ msgstr "L'index de Dossier n'est pas autorisé ici." - -#~ msgid "\"%(path)s\" does not exist" -#~ msgstr "\"%(path)s\" n'existe pas" - -#~ msgid "Index of %(directory)s" -#~ msgstr "Contenu de %(directory)s" - -#~ msgid "Django: the Web framework for perfectionists with deadlines." -#~ msgstr "" -#~ "Django: Le cadriciel Web pour les perfectionniste avec des dates limites." - -#~ msgid "" -#~ "View release notes for Django " -#~ "%(version)s" -#~ msgstr "" -#~ "Visiter les Notes de versions\" pour " -#~ "Django %(version)s" - -#~ msgid "The install worked successfully! Congratulations!" -#~ msgstr "L'installation a réussie! Félicitation!" - -#~ msgid "" -#~ "You are seeing this page because DEBUG=True is in your settings file and you have not configured " -#~ "any URLs." -#~ msgstr "" -#~ "Vous voyez cette page car DEBUG=True est renseigné dans vos paramètres et vous n'avez pas " -#~ "configuré d'URLs." - -#~ msgid "Django Documentation" -#~ msgstr "Documentation Django" - -#~ msgid "Topics, references, & how-to's" -#~ msgstr "Sujet, references & how-to" - -#~ msgid "Tutorial: A Polling App" -#~ msgstr "Tutoriel: Une application de sondage" - -#~ msgid "Get started with Django" -#~ msgstr "Débuter avec Django" - -#~ msgid "Django Community" -#~ msgstr "Communauté Django" - -#~ msgid "Connect, get help, or contribute" -#~ msgstr "Prendre contact, obtenir de l'aide ou contribuer" From cb545417acd8a7bd3583b692de569c6a96fee1b1 Mon Sep 17 00:00:00 2001 From: Alexandre Iooss Date: Fri, 4 Sep 2020 07:47:52 +0200 Subject: [PATCH 16/95] Linting does not require deps --- .gitlab-ci.yml | 10 +--------- tox.ini | 1 - 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e8bd9b44..80d85791 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -40,15 +40,7 @@ linters: stage: quality-assurance image: debian:buster-backports before_script: - - > - apt-get update && - apt-get install --no-install-recommends -t buster-backports -y - python3-django python3-django-crispy-forms - python3-django-extensions python3-django-filters python3-django-polymorphic - python3-djangorestframework python3-django-cas-server python3-psycopg2 python3-pil - python3-babel python3-lockfile python3-pip python3-phonenumbers - python3-bs4 python3-setuptools tox - texlive-latex-extra texlive-lang-french lmodern texlive-fonts-recommended + - apt-get update && apt-get install -y tox script: tox -e linters # Be nice to new contributors, but please use `tox` diff --git a/tox.ini b/tox.ini index ad5cdc6f..b160324b 100644 --- a/tox.ini +++ b/tox.ini @@ -21,7 +21,6 @@ commands = [testenv:linters] deps = - -r{toxinidir}/requirements.txt flake8 flake8-colors flake8-import-order From b6847415b588601f74aeff7438575b02ba7f95f6 Mon Sep 17 00:00:00 2001 From: Alexandre Iooss Date: Fri, 4 Sep 2020 07:53:31 +0200 Subject: [PATCH 17/95] Remove unused imports in tests --- apps/member/tests/test_login.py | 3 --- apps/member/tests/test_memberships.py | 2 -- 2 files changed, 5 deletions(-) diff --git a/apps/member/tests/test_login.py b/apps/member/tests/test_login.py index c4467f81..e022c4ea 100644 --- a/apps/member/tests/test_login.py +++ b/apps/member/tests/test_login.py @@ -5,8 +5,6 @@ from django.contrib.auth.models import User from django.test import TestCase from django.urls import reverse -from note.models import TransactionTemplate, TemplateCategory - """ Test that login page still works """ @@ -56,4 +54,3 @@ class TemplateLoggedInTests(TestCase): def test_accounts_password_reset(self): response = self.client.get('/accounts/password_reset/') self.assertEqual(response.status_code, 200) - diff --git a/apps/member/tests/test_memberships.py b/apps/member/tests/test_memberships.py index 4852de77..ef8b8209 100644 --- a/apps/member/tests/test_memberships.py +++ b/apps/member/tests/test_memberships.py @@ -5,14 +5,12 @@ import hashlib import os from datetime import date, timedelta -from django.conf import settings 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 c03c18e93a9cca155a988148c5387becf964cd69 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Fri, 4 Sep 2020 15:53:00 +0200 Subject: [PATCH 18/95] Test and cover treasury app --- .../templates/activity/activity_entry.html | 2 +- apps/treasury/admin.py | 4 +- apps/treasury/api/views.py | 10 +- apps/treasury/forms.py | 10 +- apps/treasury/models.py | 4 +- .../treasury}/static/img/Finalist.png | Bin .../treasury}/static/img/Kataclist.png | Bin .../treasury}/static/img/Listorique.png | Bin .../treasury}/static/img/Monopolist.png | Bin .../treasury}/static/img/Saperlistpopette.png | Bin .../treasury}/static/img/Satellist.png | Bin apps/treasury/tables.py | 2 +- .../templates/treasury/invoice_list.html | 2 +- .../templates/treasury/invoice_sample.tex | 2 +- .../templates/treasury/remittance_list.html | 2 +- .../templates/treasury/sogecredit_list.html | 5 +- apps/treasury/tests/__init__.py | 0 apps/treasury/tests/test_treasury.py | 403 ++++++++++++++++++ apps/treasury/views.py | 17 +- note_kfet/static/js/transfer.js | 6 +- 20 files changed, 438 insertions(+), 31 deletions(-) rename {note_kfet => apps/treasury}/static/img/Finalist.png (100%) rename {note_kfet => apps/treasury}/static/img/Kataclist.png (100%) rename {note_kfet => apps/treasury}/static/img/Listorique.png (100%) rename {note_kfet => apps/treasury}/static/img/Monopolist.png (100%) rename {note_kfet => apps/treasury}/static/img/Saperlistpopette.png (100%) rename {note_kfet => apps/treasury}/static/img/Satellist.png (100%) create mode 100644 apps/treasury/tests/__init__.py create mode 100644 apps/treasury/tests/test_treasury.py diff --git a/apps/activity/templates/activity/activity_entry.html b/apps/activity/templates/activity/activity_entry.html index d59a4c48..d778490f 100644 --- a/apps/activity/templates/activity/activity_entry.html +++ b/apps/activity/templates/activity/activity_entry.html @@ -9,7 +9,7 @@ SPDX-License-Identifier: GPL-3.0-or-later

{{ title }}

-
+
{% trans "Transfer" %} diff --git a/apps/treasury/admin.py b/apps/treasury/admin.py index 1db820b2..25b4f4cb 100644 --- a/apps/treasury/admin.py +++ b/apps/treasury/admin.py @@ -24,9 +24,7 @@ class RemittanceAdmin(admin.ModelAdmin): list_display = ('remittance_type', 'date', 'comment', 'count', 'amount', 'closed', ) def has_change_permission(self, request, obj=None): - if not obj: - return True - return not obj.closed and super().has_change_permission(request, obj) + return not obj or (not obj.closed and super().has_change_permission(request, obj)) @admin.register(SogeCredit, site=admin_site) diff --git a/apps/treasury/api/views.py b/apps/treasury/api/views.py index ee97e6ac..82a0ed1e 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.all() + queryset = Invoice.objects.order_by("id").all() serializer_class = InvoiceSerializer filter_backends = [DjangoFilterBackend] filterset_fields = ['bde', ] @@ -28,7 +28,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.all() + queryset = Product.objects.order_by("invoice_id", "id").all() serializer_class = ProductSerializer filter_backends = [SearchFilter] search_fields = ['$designation', ] @@ -40,7 +40,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 + queryset = RemittanceType.objects.order_by("id") serializer_class = RemittanceTypeSerializer @@ -50,7 +50,7 @@ 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 + queryset = Remittance.objects.order_by("id") serializer_class = RemittanceSerializer @@ -60,5 +60,5 @@ 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 + queryset = SogeCredit.objects.order_by("id") serializer_class = SogeCreditSerializer diff --git a/apps/treasury/forms.py b/apps/treasury/forms.py index 38da324d..c2461f76 100644 --- a/apps/treasury/forms.py +++ b/apps/treasury/forms.py @@ -16,21 +16,15 @@ class InvoiceForm(forms.ModelForm): """ def clean(self): + # If the invoice is locked, it can't be updated. if self.instance and self.instance.locked: for field_name in self.fields: self.cleaned_data[field_name] = getattr(self.instance, field_name) self.errors.clear() + self.add_error(None, _('This invoice is locked and can no longer be edited.')) return self.cleaned_data return super().clean() - def save(self, commit=True): - """ - If the invoice is locked, don't save it - """ - if not self.instance.locked: - super().save(commit) - return self.instance - class Meta: model = Invoice exclude = ('bde', 'date', 'tex', ) diff --git a/apps/treasury/models.py b/apps/treasury/models.py index 6d5b4021..762c7bb5 100644 --- a/apps/treasury/models.py +++ b/apps/treasury/models.py @@ -85,7 +85,7 @@ class Invoice(models.Model): old_invoice = Invoice.objects.filter(id=self.id) if old_invoice.exists(): - if old_invoice.get().locked: + if old_invoice.get().locked and not self._force_save: raise ValidationError(_("This invoice is locked and can no longer be edited.")) products = self.products.all() @@ -224,7 +224,7 @@ class Remittance(models.Model): def save(self, force_insert=False, force_update=False, using=None, update_fields=None): # Check if all transactions have the right type. - if self.transactions.filter(~Q(source=self.remittance_type.note)).exists(): + if self.transactions.exists() and self.transactions.filter(~Q(source=self.remittance_type.note)).exists(): raise ValidationError("All transactions in a remittance must have the same type") return super().save(force_insert, force_update, using, update_fields) diff --git a/note_kfet/static/img/Finalist.png b/apps/treasury/static/img/Finalist.png similarity index 100% rename from note_kfet/static/img/Finalist.png rename to apps/treasury/static/img/Finalist.png diff --git a/note_kfet/static/img/Kataclist.png b/apps/treasury/static/img/Kataclist.png similarity index 100% rename from note_kfet/static/img/Kataclist.png rename to apps/treasury/static/img/Kataclist.png diff --git a/note_kfet/static/img/Listorique.png b/apps/treasury/static/img/Listorique.png similarity index 100% rename from note_kfet/static/img/Listorique.png rename to apps/treasury/static/img/Listorique.png diff --git a/note_kfet/static/img/Monopolist.png b/apps/treasury/static/img/Monopolist.png similarity index 100% rename from note_kfet/static/img/Monopolist.png rename to apps/treasury/static/img/Monopolist.png diff --git a/note_kfet/static/img/Saperlistpopette.png b/apps/treasury/static/img/Saperlistpopette.png similarity index 100% rename from note_kfet/static/img/Saperlistpopette.png rename to apps/treasury/static/img/Saperlistpopette.png diff --git a/note_kfet/static/img/Satellist.png b/apps/treasury/static/img/Satellist.png similarity index 100% rename from note_kfet/static/img/Satellist.png rename to apps/treasury/static/img/Satellist.png diff --git a/apps/treasury/tables.py b/apps/treasury/tables.py index 14044f1c..9a72ecf3 100644 --- a/apps/treasury/tables.py +++ b/apps/treasury/tables.py @@ -34,7 +34,7 @@ class InvoiceTable(tables.Table): delete = tables.LinkColumn( 'treasury:invoice_delete', - args=[A('pk')], + args=[A('id')], verbose_name=_("delete"), text=_("Delete"), attrs={ diff --git a/apps/treasury/templates/treasury/invoice_list.html b/apps/treasury/templates/treasury/invoice_list.html index 32c1b1c1..d9cd8a3e 100644 --- a/apps/treasury/templates/treasury/invoice_list.html +++ b/apps/treasury/templates/treasury/invoice_list.html @@ -8,7 +8,7 @@ SPDX-License-Identifier: GPL-3.0-or-later {% block content %}
-
+
{% trans "Invoice" %}s diff --git a/apps/treasury/templates/treasury/invoice_sample.tex b/apps/treasury/templates/treasury/invoice_sample.tex index 4e6342b0..d7ec7391 100644 --- a/apps/treasury/templates/treasury/invoice_sample.tex +++ b/apps/treasury/templates/treasury/invoice_sample.tex @@ -58,7 +58,7 @@ \parbox[b][\paperheight]{\paperwidth}{% \vfill \centering - {\transparent{0.1}\includegraphics[width=\textwidth]{../../static/img/{{ obj.bde }}}}% + {\transparent{0.1}\includegraphics[width=\textwidth]{../../apps/treasury/static/img/{{ obj.bde }}}}% \vfill } } diff --git a/apps/treasury/templates/treasury/remittance_list.html b/apps/treasury/templates/treasury/remittance_list.html index c400f18f..8ced1ad0 100644 --- a/apps/treasury/templates/treasury/remittance_list.html +++ b/apps/treasury/templates/treasury/remittance_list.html @@ -8,7 +8,7 @@ SPDX-License-Identifier: GPL-3.0-or-later {% block content %}
-
+
{% trans "Invoice" %}s diff --git a/apps/treasury/templates/treasury/sogecredit_list.html b/apps/treasury/templates/treasury/sogecredit_list.html index c3862811..1eb1aba5 100644 --- a/apps/treasury/templates/treasury/sogecredit_list.html +++ b/apps/treasury/templates/treasury/sogecredit_list.html @@ -8,7 +8,7 @@ SPDX-License-Identifier: GPL-3.0-or-later {% block content %}
-
+
{% trans "Invoice" %}s @@ -59,9 +59,6 @@ SPDX-License-Identifier: GPL-3.0-or-later function reloadTable() { let pattern = searchbar_obj.val(); - if (pattern === old_pattern || pattern === "") - return; - $("#credits_table").load(location.pathname + "?search=" + pattern.replace(" ", "%20") + ( invalid_only_obj.is(':checked') ? "&valid=false" : "") + " #credits_table"); diff --git a/apps/treasury/tests/__init__.py b/apps/treasury/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/treasury/tests/test_treasury.py b/apps/treasury/tests/test_treasury.py new file mode 100644 index 00000000..15d35cb3 --- /dev/null +++ b/apps/treasury/tests/test_treasury.py @@ -0,0 +1,403 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.db.models import Q +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 + + +class TestInvoices(TestCase): + """ + Check that invoices can be created and rendered properly. + """ + def setUp(self) -> None: + self.user = User.objects.create_superuser( + username="admintoto", + password="totototo", + email="admin@example.com", + ) + self.client.force_login(self.user) + sess = self.client.session + sess["permission_mask"] = 42 + sess.save() + + 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, + ) + + def test_admin_page(self): + """ + Display the invoice admin page. + """ + response = self.client.get(reverse("admin:index") + "treasury/invoice/") + self.assertEqual(response.status_code, 200) + + def test_invoices_list(self): + """ + Display the list of invoices. + """ + response = self.client.get(reverse("treasury:invoice_list")) + self.assertEqual(response.status_code, 200) + + def test_invoice_create(self): + """ + Try to create a new invoice. + """ + response = self.client.get(reverse("treasury:invoice_create")) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("treasury:invoice_create"), data={ + "id": 42, + "object": "Same object", + "description": "Longer description", + "name": "Me and others", + "address": "Alwways earth", + "acquitted": True, + "products-0-designation": "Designation", + "products-0-quantity": 1, + "products-0-amount": 42, + "products-TOTAL_FORMS": 1, + "products-INITIAL_FORMS": 0, + "products-MIN_NUM_FORMS": 0, + "products-MAX_NUM_FORMS": 1000, + }) + self.assertRedirects(response, reverse("treasury:invoice_list"), 302, 200) + self.assertTrue(Invoice.objects.filter(object="Same object", id=42).exists()) + self.assertTrue(Product.objects.filter(designation="Designation", invoice_id=42).exists()) + self.assertTrue(Invoice.objects.get(id=42).tex) + + def test_invoice_update(self): + """ + Try to update an invoice. + """ + response = self.client.get(reverse("treasury:invoice_update", args=(self.invoice.id,))) + self.assertEqual(response.status_code, 200) + + data = { + "object": "Same object", + "description": "Longer description", + "name": "Me and others", + "address": "Always earth", + "acquitted": True, + "locked": True, + "products-0-designation": "Designation", + "products-0-quantity": 1, + "products-0-amount": 4200, + "products-1-designation": "Second designation", + "products-1-quantity": 5, + "products-1-amount": -1800, + "products-TOTAL_FORMS": 2, + "products-INITIAL_FORMS": 0, + "products-MIN_NUM_FORMS": 0, + "products-MAX_NUM_FORMS": 1000, + } + + response = self.client.post(reverse("treasury:invoice_update", args=(self.invoice.id,)), data=data) + self.assertRedirects(response, reverse("treasury:invoice_list"), 302, 200) + self.invoice.refresh_from_db() + self.assertTrue(Invoice.objects.filter(pk=1, object="Same object", locked=True).exists()) + self.assertTrue(Product.objects.filter(designation="Second designation", invoice_id=1).exists()) + + # Resend the same data, but the invoice is locked. + response = self.client.get(reverse("treasury:invoice_update", args=(self.invoice.id,))) + self.assertTrue(response.status_code, 200) + response = self.client.post(reverse("treasury:invoice_update", args=(self.invoice.id,)), data=data) + self.assertTrue(response.status_code, 200) + + def test_delete_invoice(self): + """ + Try to delete an invoice. + """ + response = self.client.get(reverse("treasury:invoice_delete", args=(self.invoice.id,))) + self.assertEqual(response.status_code, 200) + + # Can't delete a locked invoice + self.invoice.locked = True + self.invoice.save() + response = self.client.delete(reverse("treasury:invoice_delete", args=(self.invoice.id,))) + self.assertEqual(response.status_code, 403) + self.assertTrue(Invoice.objects.filter(pk=self.invoice.id).exists()) + + # Unlock invoice and truly delete it. + self.invoice.locked = False + self.invoice._force_save = True + self.invoice.save() + response = self.client.delete(reverse("treasury:invoice_delete", args=(self.invoice.id,))) + self.assertRedirects(response, reverse("treasury:invoice_list"), 302, 200) + self.assertFalse(Invoice.objects.filter(pk=self.invoice.id).exists()) + + def test_invoice_render_pdf(self): + """ + Generate the PDF file of an invoice. + """ + response = self.client.get(reverse("treasury:invoice_render", args=(self.invoice.id,))) + self.assertEqual(response.status_code, 200) + + def test_invoice_api(self): + """ + Load some API pages + """ + response = self.client.get("/api/treasury/invoice/") + self.assertEqual(response.status_code, 200) + response = self.client.get("/api/treasury/product/") + self.assertEqual(response.status_code, 200) + + +class TestRemittances(TestCase): + """ + Create some credits and close remittances. + """ + + fixtures = ('initial',) + + def setUp(self) -> None: + self.user = User.objects.create_superuser( + username="admintoto", + password="totototo", + email="admin@example.com", + ) + self.client.force_login(self.user) + sess = self.client.session + sess["permission_mask"] = 42 + sess.save() + + 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.second_credit = SpecialTransaction.objects.create( + source=self.user.note, + destination=NoteSpecial.objects.get(special_type="Chèque"), + amount=424200, + reason="Second 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() + + def test_admin_page(self): + """ + Load the admin page. + """ + response = self.client.get(reverse("admin:index") + "treasury/remittance/") + self.assertEqual(response.status_code, 200) + + def test_remittances_list(self): + """ + Display the remittance list. + :return: + """ + response = self.client.get(reverse("treasury:remittance_list")) + self.assertEqual(response.status_code, 200) + + def test_remittance_create(self): + """ + Create a new Remittance. + """ + response = self.client.get(reverse("treasury:remittance_create")) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("treasury:remittance_create"), data=dict( + remittance_type=RemittanceType.objects.get().pk, + comment="Created remittance", + )) + self.assertRedirects(response, reverse("treasury:remittance_list"), 302, 200) + self.assertTrue(Remittance.objects.filter(comment="Created remittance").exists()) + + def test_remittance_update(self): + """ + Update an existing remittance. + """ + response = self.client.get(reverse("treasury:remittance_update", args=(self.remittance.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("treasury:remittance_update", args=(self.remittance.pk,)), data=dict( + comment="Updated remittance", + )) + self.assertRedirects(response, reverse("treasury:remittance_list"), 302, 200) + self.assertTrue(Remittance.objects.filter(comment="Updated remittance").exists()) + + def test_remittance_close(self): + """ + Try to close an open remittance. + """ + response = self.client.get(reverse("treasury:remittance_update", args=(self.remittance.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("treasury:remittance_update", args=(self.remittance.pk,)), data=dict( + comment="Closed remittance", + close=True, + )) + self.assertRedirects(response, reverse("treasury:remittance_list"), 302, 200) + self.assertTrue(Remittance.objects.filter(comment="Closed remittance", closed=True).exists()) + + def test_remittance_link_transaction(self): + """ + Link a transaction to an open remittance. + """ + response = self.client.get(reverse("treasury:link_transaction", args=(self.credit.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("treasury:link_transaction", args=(self.credit.pk,)), data=dict( + remittance=self.remittance.pk, + last_name="Last Name", + first_name="First Name", + bank="Bank", + )) + self.assertRedirects(response, reverse("treasury:remittance_list"), 302, 200) + self.credit.refresh_from_db() + self.assertEqual(self.credit.last_name, "Last Name") + self.assertEqual(self.remittance.transactions.count(), 1) + + response = self.client.get(reverse("treasury:unlink_transaction", args=(self.credit.pk,))) + self.assertRedirects(response, reverse("treasury:remittance_list"), 302, 200) + + def test_invoice_api(self): + """ + Load some API pages + """ + response = self.client.get("/api/treasury/remittance_type/") + self.assertEqual(response.status_code, 200) + response = self.client.get("/api/treasury/remittance/") + self.assertEqual(response.status_code, 200) + + +class TestSogeCredits(TestCase): + """ + Check that credits from the Société générale are working correctly. + """ + + fixtures = ('initial',) + + def setUp(self) -> None: + self.user = User.objects.create_superuser( + username="admintoto", + password="totototo", + email="admin@example.com", + ) + self.client.force_login(self.user) + sess = self.client.session + sess["permission_mask"] = 42 + sess.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_admin_page(self): + """ + Render the admin page. + """ + response = self.client.get(reverse("admin:index") + "treasury/sogecredit/") + self.assertEqual(response.status_code, 200) + + def test_sogecredit_list(self): + """ + Display the list of all credits. + """ + response = self.client.get(reverse("treasury:soge_credits")) + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("treasury:soge_credits") + "?search=toto&valid=") + self.assertEqual(response.status_code, 200) + + def test_validate_soge_credit(self): + """ + Try to validate a credit. + """ + soge_credit = SogeCredit.objects.get(user=self.user) + + response = self.client.get(reverse("treasury:manage_soge_credit", args=(soge_credit.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)), data=dict( + validate=True, + )) + self.assertRedirects(response, reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)), 302, 200) + soge_credit.refresh_from_db() + self.assertTrue(soge_credit.valid) + self.user.note.refresh_from_db() + self.assertEqual(self.user.note.balance, 0) + self.assertEqual( + Transaction.objects.filter(Q(source=self.user.note) | Q(destination=self.user.note)).count(), 3) + self.assertTrue(self.user.profile.soge) + + def test_delete_soge_credit(self): + """ + Try to invalidate a credit. + """ + soge_credit = SogeCredit.objects.get(user=self.user) + + 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 + + SpecialTransaction.objects.create( + source=NoteSpecial.objects.get(special_type="Carte bancaire"), + destination=self.user.note, + amount=self.bde.membership_fee_paid + self.kfet.membership_fee_paid, + quantity=1, + reason="Registration is not complete, pliz pay", + last_name="TOTO", + first_name="Toto", + ) + + response = self.client.post(reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)), + data=dict(delete=True)) + # 403 because no SogeCredit exists anymore, then a PermissionDenied is raised + self.assertRedirects(response, reverse("treasury:soge_credits"), 302, 403) + self.assertFalse(SogeCredit.objects.filter(pk=soge_credit.pk)) + self.user.note.refresh_from_db() + self.assertEqual(self.user.note.balance, 0) + self.assertEqual( + Transaction.objects.filter(Q(source=self.user.note) | Q(destination=self.user.note)).count(), 3) + self.assertFalse(self.user.profile.soge) + + def test_invoice_api(self): + """ + Load some API pages + """ + response = self.client.get("/api/treasury/soge_credit/") + self.assertEqual(response.status_code, 200) diff --git a/apps/treasury/views.py b/apps/treasury/views.py index c2265289..5889f8b5 100644 --- a/apps/treasury/views.py +++ b/apps/treasury/views.py @@ -60,6 +60,11 @@ class InvoiceCreateView(ProtectQuerysetMixin, ProtectedCreateView): return context + def get_form(self, form_class=None): + form = super().get_form(form_class) + del form.fields["locked"] + return form + def form_valid(self, form): ret = super().form_valid(form) @@ -134,6 +139,11 @@ class InvoiceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): return context + def get_form(self, form_class=None): + form = super().get_form(form_class) + del form.fields["id"] + return form + def form_valid(self, form): ret = super().form_valid(form) @@ -165,6 +175,11 @@ class InvoiceDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView): model = Invoice extra_context = {"title": _("Delete invoice")} + def delete(self, request, *args, **kwargs): + if self.get_object().locked: + raise PermissionDenied(_("This invoice is locked and can't be deleted.")) + return super().delete(request, *args, **kwargs) + def get_success_url(self): return reverse_lazy('treasury:invoice_list') @@ -387,7 +402,7 @@ class SogeCreditListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableVi if not request.user.is_authenticated: return self.handle_no_permission() - if not self.get_queryset().exists(): + if not super().get_queryset().exists(): raise PermissionDenied(_("You are not able to see the treasury interface.")) return super().dispatch(request, *args, **kwargs) diff --git a/note_kfet/static/js/transfer.js b/note_kfet/static/js/transfer.js index cbae7456..e22d2b3f 100644 --- a/note_kfet/static/js/transfer.js +++ b/note_kfet/static/js/transfer.js @@ -96,7 +96,7 @@ $(document).ready(function() { let source = $("#source_note"); let dest = $("#dest_note"); - $("#type_transfer").click(function() { + $("#type_transfer").change(function() { if (LOCK) return; @@ -117,7 +117,7 @@ $(document).ready(function() { location.hash = "transfer"; }); - $("#type_credit").click(function() { + $("#type_credit").change(function() { if (LOCK) return; @@ -146,7 +146,7 @@ $(document).ready(function() { location.hash = "credit"; }); - $("#type_debit").click(function() { + $("#type_debit").change(function() { if (LOCK) return; From f71fb1fa81dd7a14d02b4512abe9dec532505df8 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Fri, 4 Sep 2020 16:02:42 +0200 Subject: [PATCH 19/95] Use pre-defined queryset by default in API views --- apps/api/viewsets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/viewsets.py b/apps/api/viewsets.py index 333ae5e3..fa2fc941 100644 --- a/apps/api/viewsets.py +++ b/apps/api/viewsets.py @@ -26,7 +26,7 @@ class ReadProtectedModelViewSet(ModelViewSet): def get_queryset(self): user = self.request.user get_current_session().setdefault("permission_mask", 42) - return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct() + return self.queryset.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct() class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet): @@ -41,7 +41,7 @@ class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet): def get_queryset(self): user = self.request.user get_current_session().setdefault("permission_mask", 42) - return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct() + return self.queryset.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct() class UserViewSet(ReadProtectedModelViewSet): From c93c81861d420dc423fc42a542ff1c1f93eb43f5 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Fri, 4 Sep 2020 16:28:50 +0200 Subject: [PATCH 20/95] Users can change their password, fix #59 --- apps/permission/fixtures/initial.json | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/permission/fixtures/initial.json b/apps/permission/fixtures/initial.json index 120bb1ee..9012414d 100644 --- a/apps/permission/fixtures/initial.json +++ b/apps/permission/fixtures/initial.json @@ -2567,6 +2567,22 @@ "description": "(Dé)bloquer sa propre note et modifier la raison" } }, + { + "model": "permission.permission", + "pk": 165, + "fields": { + "model": [ + "auth", + "user" + ], + "query": "{}", + "type": "change", + "mask": 1, + "field": "password", + "permanent": true, + "description": "Changer son mot de passe" + } + }, { "model": "permission.role", "pk": 1, @@ -2591,7 +2607,8 @@ 52, 126, 161, - 162 + 162, + 165 ] } }, @@ -2932,7 +2949,8 @@ 161, 162, 163, - 164 + 164, + 165 ] } }, From 9b4923fc04e08ba8b00df01e4802088ddccb1aca Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Fri, 4 Sep 2020 16:37:17 +0200 Subject: [PATCH 21/95] Fix some permissions, grant temporary all treasurers to make transactions from anyone to anyone while a better system is not implemented --- apps/permission/fixtures/initial.json | 63 ++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/apps/permission/fixtures/initial.json b/apps/permission/fixtures/initial.json index 9012414d..cb9b8d04 100644 --- a/apps/permission/fixtures/initial.json +++ b/apps/permission/fixtures/initial.json @@ -2583,6 +2583,54 @@ "description": "Changer son mot de passe" } }, + { + "model": "permission.permission", + "pk": 166, + "fields": { + "model": [ + "note", + "transaction" + ], + "query": "[\"OR\", {\"source__balance__gte\": {\"F\": [\"SUB\", [\"MUL\", [\"F\", \"amount\"], [\"F\", \"quantity\"]], 5000]}}, {\"valid\": false}]", + "type": "add", + "mask": 2, + "field": "", + "permanent": false, + "description": "Créer une transaction quelconque tant que la source reste au-dessus de -50 €" + } + }, + { + "model": "permission.permission", + "pk": 167, + "fields": { + "model": [ + "note", + "transaction" + ], + "query": "[\"OR\", {\"source\": [\"club\", \"note\"]}, {\"destination\": [\"club\", \"note\"]}], [\"OR\", {\"source__balance__gte\": {\"F\": [\"SUB\", [\"MUL\", [\"F\", \"amount\"], [\"F\", \"quantity\"]], 5000]}, \"valid\": true}, {\"destination__balance__gte\": {\"F\": [\"SUB\", [\"MUL\", [\"F\", \"amount\"], [\"F\", \"quantity\"]], 5000]}, \"valid\": false}]", + "type": "change", + "mask": 2, + "field": "valid", + "permanent": false, + "description": "Modifier le statut de validation d'une transaction si c'est possible" + } + }, + { + "model": "permission.permission", + "pk": 168, + "fields": { + "model": [ + "note", + "transaction" + ], + "query": "[\"OR\", {\"source__balance__gte\": {\"F\": [\"SUB\", [\"MUL\", [\"F\", \"amount\"], [\"F\", \"quantity\"]], 5000]}, \"valid\": true}, {\"destination__balance__gte\": {\"F\": [\"SUB\", [\"MUL\", [\"F\", \"amount\"], [\"F\", \"quantity\"]], 5000]}, \"valid\": false}]", + "type": "change", + "mask": 2, + "field": "invalidity_reason", + "permanent": false, + "description": "Modifier la raison d'invalidité d'une transaction si c'est possible" + } + }, { "model": "permission.role", "pk": 1, @@ -2714,7 +2762,11 @@ 127, 133, 141, - 142 + 142, + 150, + 166, + 167, + 168 ] } }, @@ -2728,8 +2780,7 @@ 24, 25, 26, - 27, - 33 + 27 ] } }, @@ -2962,7 +3013,6 @@ "name": "GC Kfet", "permissions": [ 32, - 33, 56, 58, 55, @@ -2977,7 +3027,10 @@ 29, 30, 31, - 143 + 143, + 166, + 167, + 168 ] } }, From 5c7fe716adf71623fb5e43914ac618234761063f Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Fri, 4 Sep 2020 16:43:57 +0200 Subject: [PATCH 22/95] Fix JSON --- apps/logs/signals.py | 5 +-- apps/permission/fixtures/initial.json | 2 +- .../tests/test_permission_queries.py | 35 ++++++++++--------- 3 files changed, 20 insertions(+), 22 deletions(-) diff --git a/apps/logs/signals.py b/apps/logs/signals.py index 2d443d13..89e7d05d 100644 --- a/apps/logs/signals.py +++ b/apps/logs/signals.py @@ -50,10 +50,7 @@ def save_object(sender, instance, **kwargs): in order to store each modification made """ # noinspection PyProtectedMember - if instance._meta.label_lower in EXCLUDED: - return - - if hasattr(instance, "_no_log"): + if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_log"): return # noinspection PyProtectedMember diff --git a/apps/permission/fixtures/initial.json b/apps/permission/fixtures/initial.json index cb9b8d04..5f266788 100644 --- a/apps/permission/fixtures/initial.json +++ b/apps/permission/fixtures/initial.json @@ -2607,7 +2607,7 @@ "note", "transaction" ], - "query": "[\"OR\", {\"source\": [\"club\", \"note\"]}, {\"destination\": [\"club\", \"note\"]}], [\"OR\", {\"source__balance__gte\": {\"F\": [\"SUB\", [\"MUL\", [\"F\", \"amount\"], [\"F\", \"quantity\"]], 5000]}, \"valid\": true}, {\"destination__balance__gte\": {\"F\": [\"SUB\", [\"MUL\", [\"F\", \"amount\"], [\"F\", \"quantity\"]], 5000]}, \"valid\": false}]", + "query": "[\"OR\", {\"source__balance__gte\": {\"F\": [\"SUB\", [\"MUL\", [\"F\", \"amount\"], [\"F\", \"quantity\"]], 5000]}, \"valid\": true}, {\"destination__balance__gte\": {\"F\": [\"SUB\", [\"MUL\", [\"F\", \"amount\"], [\"F\", \"quantity\"]], 5000]}, \"valid\": false}]", "type": "change", "mask": 2, "field": "valid", diff --git a/apps/permission/tests/test_permission_queries.py b/apps/permission/tests/test_permission_queries.py index e0af9cf0..4d73ae11 100644 --- a/apps/permission/tests/test_permission_queries.py +++ b/apps/permission/tests/test_permission_queries.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from datetime import date +from json.decoder import JSONDecodeError from django.contrib.auth.models import User from django.core.exceptions import FieldError @@ -56,29 +57,29 @@ class PermissionQueryTestCase(TestCase): We use a random user with a random WEIClub (to use permissions for the WEI) in a random team in a random bus. """ for perm in Permission.objects.all(): - instanced = perm.about( - user=User.objects.get(), - club=WEIClub.objects.get(), - membership=Membership.objects.get(), - User=User, - Club=Club, - Membership=Membership, - Note=Note, - NoteUser=NoteUser, - NoteClub=NoteClub, - NoteSpecial=NoteSpecial, - F=F, - Q=Q, - now=timezone.now(), - today=date.today(), - ) try: + instanced = perm.about( + user=User.objects.get(), + club=WEIClub.objects.get(), + membership=Membership.objects.get(), + User=User, + Club=Club, + Membership=Membership, + Note=Note, + NoteUser=NoteUser, + NoteClub=NoteClub, + NoteSpecial=NoteSpecial, + F=F, + Q=Q, + now=timezone.now(), + today=date.today(), + ) instanced.update_query() query = instanced.query model = perm.model.model_class() model.objects.filter(query).all() # print("Good query for permission", perm) - except (FieldError, AttributeError, ValueError, TypeError): + except (FieldError, AttributeError, ValueError, TypeError, JSONDecodeError): print("Query error for permission", perm) print("Query:", perm.query) if instanced.query: From 70e1a611dd1445758eaac14473bb17a7518ba128 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Fri, 4 Sep 2020 18:36:20 +0200 Subject: [PATCH 23/95] Export activites as an ICS Calendar --- apps/activity/urls.py | 1 + apps/activity/views.py | 627 ++++++++++++++++++++++------------------- apps/logs/signals.py | 5 +- 3 files changed, 346 insertions(+), 287 deletions(-) diff --git a/apps/activity/urls.py b/apps/activity/urls.py index f074e8f7..155229d4 100644 --- a/apps/activity/urls.py +++ b/apps/activity/urls.py @@ -14,4 +14,5 @@ urlpatterns = [ path('/entry/', views.ActivityEntryView.as_view(), name='activity_entry'), path('/update/', views.ActivityUpdateView.as_view(), name='activity_update'), path('new/', views.ActivityCreateView.as_view(), name='activity_create'), + path('calendar.ics', views.CalendarView.as_view(), name='calendar_ics'), ] diff --git a/apps/activity/views.py b/apps/activity/views.py index fd218db5..79934245 100644 --- a/apps/activity/views.py +++ b/apps/activity/views.py @@ -1,283 +1,344 @@ -# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay -# SPDX-License-Identifier: GPL-3.0-or-later - -from django.conf import settings -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import PermissionDenied -from django.db.models import F, Q -from django.urls import reverse_lazy -from django.utils import timezone -from django.utils.translation import gettext_lazy as _ -from django.views.generic import DetailView, TemplateView, UpdateView -from django_tables2.views import SingleTableView -from note.models import Alias, NoteSpecial, NoteUser -from permission.backends import PermissionBackend -from permission.views import ProtectQuerysetMixin, ProtectedCreateView - -from .forms import ActivityForm, GuestForm -from .models import Activity, Entry, Guest -from .tables import ActivityTable, EntryTable, GuestTable - - -class ActivityCreateView(ProtectQuerysetMixin, ProtectedCreateView): - """ - View to create a new Activity - """ - model = Activity - form_class = ActivityForm - extra_context = {"title": _("Create new activity")} - - def get_sample_object(self): - return Activity( - name="", - description="", - creater=self.request.user, - activity_type_id=1, - organizer_id=1, - attendees_club_id=1, - date_start=timezone.now(), - date_end=timezone.now(), - ) - - def form_valid(self, form): - form.instance.creater = self.request.user - return super().form_valid(form) - - def get_success_url(self, **kwargs): - self.object.refresh_from_db() - return reverse_lazy('activity:activity_detail', kwargs={"pk": self.object.pk}) - - -class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): - """ - Displays all Activities, and classify if they are on-going or upcoming ones. - """ - model = Activity - table_class = ActivityTable - ordering = ('-date_start',) - extra_context = {"title": _("Activities")} - - def get_queryset(self): - return super().get_queryset().distinct() - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - upcoming_activities = Activity.objects.filter(date_end__gt=timezone.now()) - context['upcoming'] = ActivityTable( - data=upcoming_activities.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")), - prefix='upcoming-', - ) - - started_activities = Activity.objects\ - .filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ - .filter(open=True, valid=True).all() - context["started_activities"] = started_activities - - return context - - -class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): - """ - Shows details about one activity. Add guest to context - """ - model = Activity - context_object_name = "activity" - extra_context = {"title": _("Activity detail")} - - def get_context_data(self, **kwargs): - context = super().get_context_data() - - table = GuestTable(data=Guest.objects.filter(activity=self.object) - .filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))) - context["guests"] = table - - context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start) - - return context - - -class ActivityUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): - """ - Updates one Activity - """ - model = Activity - form_class = ActivityForm - extra_context = {"title": _("Update activity")} - - def get_success_url(self, **kwargs): - return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]}) - - -class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView): - """ - Invite a Guest, The rules to invites someone are defined in `forms:activity.GuestForm` - """ - model = Guest - form_class = GuestForm - template_name = "activity/activity_form.html" - - def get_sample_object(self): - """ Creates a standart Guest binds to the Activity""" - activity = Activity.objects.get(pk=self.kwargs["pk"]) - return Guest( - activity=activity, - first_name="", - last_name="", - inviter=self.request.user.note, - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - activity = context["form"].activity - context["title"] = _('Invite guest to the activity "{}"').format(activity.name) - return context - - def get_form(self, form_class=None): - form = super().get_form(form_class) - form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ - .get(pk=self.kwargs["pk"]) - form.fields["inviter"].initial = self.request.user.note - return form - - def form_valid(self, form): - form.instance.activity = Activity.objects\ - .filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")).get(pk=self.kwargs["pk"]) - return super().form_valid(form) - - def get_success_url(self, **kwargs): - return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]}) - - -class ActivityEntryView(LoginRequiredMixin, TemplateView): - """ - Manages entry to an activity - """ - template_name = "activity/activity_entry.html" - - def dispatch(self, request, *args, **kwargs): - """ - Don't display the entry interface if the user has no right to see it (no right to add an entry for itself), - it is closed or doesn't manage entries. - """ - activity = Activity.objects.get(pk=self.kwargs["pk"]) - - sample_entry = Entry(activity=activity, note=self.request.user.note) - if not PermissionBackend.check_perm(self.request.user, "activity.add_entry", sample_entry): - raise PermissionDenied(_("You are not allowed to display the entry interface for this activity.")) - - if not activity.activity_type.manage_entries: - raise PermissionDenied(_("This activity does not support activity entries.")) - - if not activity.open: - raise PermissionDenied(_("This activity is closed.")) - return super().dispatch(request, *args, **kwargs) - - def get_invited_guest(self, activity): - """ - Retrieves all Guests to the activity - """ - - guest_qs = Guest.objects\ - .annotate(balance=F("inviter__balance"), note_name=F("inviter__user__username"))\ - .filter(activity=activity)\ - .filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))\ - .order_by('last_name', 'first_name').distinct() - - if "search" in self.request.GET and self.request.GET["search"]: - pattern = self.request.GET["search"] - if pattern[0] != "^": - pattern = "^" + pattern - guest_qs = guest_qs.filter( - Q(first_name__regex=pattern) - | Q(last_name__regex=pattern) - | Q(inviter__alias__name__regex=pattern) - | Q(inviter__alias__normalized_name__regex=Alias.normalize(pattern)) - ) - else: - guest_qs = guest_qs.none() - return guest_qs - - def get_invited_note(self, activity): - """ - Retrieves all Note that can attend the activity, - they need to have an up-to-date membership in the attendees_club. - """ - note_qs = Alias.objects.annotate(last_name=F("note__noteuser__user__last_name"), - first_name=F("note__noteuser__user__first_name"), - username=F("note__noteuser__user__username"), - note_name=F("name"), - balance=F("note__balance")) - - # Keep only users that have a note - note_qs = note_qs.filter(note__noteuser__isnull=False) - - # Keep only members - note_qs = note_qs.filter( - note__noteuser__user__memberships__club=activity.attendees_club, - note__noteuser__user__memberships__date_start__lte=timezone.now(), - note__noteuser__user__memberships__date_end__gte=timezone.now(), - ) - - # Filter with permission backend - note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view")) - - if "search" in self.request.GET and self.request.GET["search"]: - pattern = self.request.GET["search"] - note_qs = note_qs.filter( - Q(note__noteuser__user__first_name__regex=pattern) - | Q(note__noteuser__user__last_name__regex=pattern) - | Q(name__regex=pattern) - | Q(normalized_name__regex=Alias.normalize(pattern)) - ) - else: - note_qs = note_qs.none() - - if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql': - note_qs = note_qs.distinct('note__pk')[:20] - else: - # SQLite doesn't support distinct fields. For compatibility reason (in dev mode), the note list will only - # have distinct aliases rather than distinct notes with a SQLite DB, but it can fill the result page. - # In production mode, please use PostgreSQL. - note_qs = note_qs.distinct()[:20] - return note_qs - - def get_context_data(self, **kwargs): - """ - Query the list of Guest and Note to the activity and add information to makes entry with JS. - """ - context = super().get_context_data(**kwargs) - - activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ - .distinct().get(pk=self.kwargs["pk"]) - context["activity"] = activity - - matched = [] - - for guest in self.get_invited_guest(activity): - guest.type = "Invité" - matched.append(guest) - - for note in self.get_invited_note(activity): - note.type = "Adhérent" - note.activity = activity - matched.append(note) - - table = EntryTable(data=matched) - context["table"] = table - - context["entries"] = Entry.objects.filter(activity=activity) - - context["title"] = _('Entry for activity "{}"').format(activity.name) - context["noteuser_ctype"] = ContentType.objects.get_for_model(NoteUser).pk - context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk - - activities_open = Activity.objects.filter(open=True).filter( - PermissionBackend.filter_queryset(self.request.user, Activity, "view")).distinct().all() - context["activities_open"] = [a for a in activities_open - if PermissionBackend.check_perm(self.request.user, - "activity.add_entry", - Entry(activity=a, note=self.request.user.note,))] - - return context +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later +from hashlib import md5 + +from django.conf import settings +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import PermissionDenied +from django.db.models import F, Q +from django.http import HttpResponse +from django.urls import reverse_lazy +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django.views import View +from django.views.generic import DetailView, TemplateView, UpdateView +from django_tables2.views import SingleTableView +from note.models import Alias, NoteSpecial, NoteUser +from permission.backends import PermissionBackend +from permission.views import ProtectQuerysetMixin, ProtectedCreateView + +from .forms import ActivityForm, GuestForm +from .models import Activity, Entry, Guest +from .tables import ActivityTable, EntryTable, GuestTable + + +class ActivityCreateView(ProtectQuerysetMixin, ProtectedCreateView): + """ + View to create a new Activity + """ + model = Activity + form_class = ActivityForm + extra_context = {"title": _("Create new activity")} + + def get_sample_object(self): + return Activity( + name="", + description="", + creater=self.request.user, + activity_type_id=1, + organizer_id=1, + attendees_club_id=1, + date_start=timezone.now(), + date_end=timezone.now(), + ) + + def form_valid(self, form): + form.instance.creater = self.request.user + return super().form_valid(form) + + def get_success_url(self, **kwargs): + self.object.refresh_from_db() + return reverse_lazy('activity:activity_detail', kwargs={"pk": self.object.pk}) + + +class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): + """ + Displays all Activities, and classify if they are on-going or upcoming ones. + """ + model = Activity + table_class = ActivityTable + ordering = ('-date_start',) + extra_context = {"title": _("Activities")} + + def get_queryset(self): + return super().get_queryset().distinct() + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + upcoming_activities = Activity.objects.filter(date_end__gt=timezone.now()) + context['upcoming'] = ActivityTable( + data=upcoming_activities.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")), + prefix='upcoming-', + ) + + started_activities = Activity.objects\ + .filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ + .filter(open=True, valid=True).all() + context["started_activities"] = started_activities + + return context + + +class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): + """ + Shows details about one activity. Add guest to context + """ + model = Activity + context_object_name = "activity" + extra_context = {"title": _("Activity detail")} + + def get_context_data(self, **kwargs): + context = super().get_context_data() + + table = GuestTable(data=Guest.objects.filter(activity=self.object) + .filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))) + context["guests"] = table + + context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start) + + return context + + +class ActivityUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): + """ + Updates one Activity + """ + model = Activity + form_class = ActivityForm + extra_context = {"title": _("Update activity")} + + def get_success_url(self, **kwargs): + return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]}) + + +class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView): + """ + Invite a Guest, The rules to invites someone are defined in `forms:activity.GuestForm` + """ + model = Guest + form_class = GuestForm + template_name = "activity/activity_form.html" + + def get_sample_object(self): + """ Creates a standart Guest binds to the Activity""" + activity = Activity.objects.get(pk=self.kwargs["pk"]) + return Guest( + activity=activity, + first_name="", + last_name="", + inviter=self.request.user.note, + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + activity = context["form"].activity + context["title"] = _('Invite guest to the activity "{}"').format(activity.name) + return context + + def get_form(self, form_class=None): + form = super().get_form(form_class) + form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ + .get(pk=self.kwargs["pk"]) + form.fields["inviter"].initial = self.request.user.note + return form + + def form_valid(self, form): + form.instance.activity = Activity.objects\ + .filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")).get(pk=self.kwargs["pk"]) + return super().form_valid(form) + + def get_success_url(self, **kwargs): + return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]}) + + +class ActivityEntryView(LoginRequiredMixin, TemplateView): + """ + Manages entry to an activity + """ + template_name = "activity/activity_entry.html" + + def dispatch(self, request, *args, **kwargs): + """ + Don't display the entry interface if the user has no right to see it (no right to add an entry for itself), + it is closed or doesn't manage entries. + """ + activity = Activity.objects.get(pk=self.kwargs["pk"]) + + sample_entry = Entry(activity=activity, note=self.request.user.note) + if not PermissionBackend.check_perm(self.request.user, "activity.add_entry", sample_entry): + raise PermissionDenied(_("You are not allowed to display the entry interface for this activity.")) + + if not activity.activity_type.manage_entries: + raise PermissionDenied(_("This activity does not support activity entries.")) + + if not activity.open: + raise PermissionDenied(_("This activity is closed.")) + return super().dispatch(request, *args, **kwargs) + + def get_invited_guest(self, activity): + """ + Retrieves all Guests to the activity + """ + + guest_qs = Guest.objects\ + .annotate(balance=F("inviter__balance"), note_name=F("inviter__user__username"))\ + .filter(activity=activity)\ + .filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))\ + .order_by('last_name', 'first_name').distinct() + + if "search" in self.request.GET and self.request.GET["search"]: + pattern = self.request.GET["search"] + if pattern[0] != "^": + pattern = "^" + pattern + guest_qs = guest_qs.filter( + Q(first_name__regex=pattern) + | Q(last_name__regex=pattern) + | Q(inviter__alias__name__regex=pattern) + | Q(inviter__alias__normalized_name__regex=Alias.normalize(pattern)) + ) + else: + guest_qs = guest_qs.none() + return guest_qs + + def get_invited_note(self, activity): + """ + Retrieves all Note that can attend the activity, + they need to have an up-to-date membership in the attendees_club. + """ + note_qs = Alias.objects.annotate(last_name=F("note__noteuser__user__last_name"), + first_name=F("note__noteuser__user__first_name"), + username=F("note__noteuser__user__username"), + note_name=F("name"), + balance=F("note__balance")) + + # Keep only users that have a note + note_qs = note_qs.filter(note__noteuser__isnull=False) + + # Keep only members + note_qs = note_qs.filter( + note__noteuser__user__memberships__club=activity.attendees_club, + note__noteuser__user__memberships__date_start__lte=timezone.now(), + note__noteuser__user__memberships__date_end__gte=timezone.now(), + ) + + # Filter with permission backend + note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view")) + + if "search" in self.request.GET and self.request.GET["search"]: + pattern = self.request.GET["search"] + note_qs = note_qs.filter( + Q(note__noteuser__user__first_name__regex=pattern) + | Q(note__noteuser__user__last_name__regex=pattern) + | Q(name__regex=pattern) + | Q(normalized_name__regex=Alias.normalize(pattern)) + ) + else: + note_qs = note_qs.none() + + if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql': + note_qs = note_qs.distinct('note__pk')[:20] + else: + # SQLite doesn't support distinct fields. For compatibility reason (in dev mode), the note list will only + # have distinct aliases rather than distinct notes with a SQLite DB, but it can fill the result page. + # In production mode, please use PostgreSQL. + note_qs = note_qs.distinct()[:20] + return note_qs + + def get_context_data(self, **kwargs): + """ + Query the list of Guest and Note to the activity and add information to makes entry with JS. + """ + context = super().get_context_data(**kwargs) + + activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ + .distinct().get(pk=self.kwargs["pk"]) + context["activity"] = activity + + matched = [] + + for guest in self.get_invited_guest(activity): + guest.type = "Invité" + matched.append(guest) + + for note in self.get_invited_note(activity): + note.type = "Adhérent" + note.activity = activity + matched.append(note) + + table = EntryTable(data=matched) + context["table"] = table + + context["entries"] = Entry.objects.filter(activity=activity) + + context["title"] = _('Entry for activity "{}"').format(activity.name) + context["noteuser_ctype"] = ContentType.objects.get_for_model(NoteUser).pk + context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk + + activities_open = Activity.objects.filter(open=True).filter( + PermissionBackend.filter_queryset(self.request.user, Activity, "view")).distinct().all() + context["activities_open"] = [a for a in activities_open + if PermissionBackend.check_perm(self.request.user, + "activity.add_entry", + Entry(activity=a, note=self.request.user.note,))] + + return context + + +class CalendarView(View): + """ + Render an ICS calendar with all valid activities. + """ + + def multilines(self, string, maxlength, offset=0): + newstring = string[:maxlength - offset] + string = string[maxlength - offset:] + while string: + newstring += "\r\n " + newstring += string[:maxlength - 1] + string = string[maxlength - 1:] + return newstring + + def get(self, request, *args, **kwargs): + ics = """BEGIN:VCALENDAR +VERSION: 2.0 +PRODID:Note Kfet 2020 +X-WR-CALNAME:Kfet Calendar +NAME:Kfet Calendar +CALSCALE:GREGORIAN +BEGIN:VTIMEZONE +TZID:Europe/Berlin +TZURL:http://tzurl.org/zoneinfo-outlook/Europe/Berlin +X-LIC-LOCATION:Europe/Berlin +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE +""" + for activity in Activity.objects.filter(valid=True).order_by("-date_start").all(): + ics += f"""BEGIN:VEVENT +DTSTAMP:{"{:%Y%m%dT%H%M%S}".format(activity.date_start)}Z +UID:{activity.id} +SUMMARY;CHARSET=UTF-8:{self.multilines(activity.name, 75, 22)} +DTSTART;TZID=Europe/Berlin:{"{:%Y%m%dT%H%M%S}".format(activity.date_start)} +DTEND;TZID=Europe/Berlin:{"{:%Y%m%dT%H%M%S}".format(activity.date_end)} +LOCATION:{self.multilines(activity.location, 75, 9) if activity.location else "Kfet"} +DESCRIPTION;CHARSET=UTF-8:{self.multilines(activity.description, 75, 26)} + -- {activity.organizer.name} +END:VEVENT +""" + ics += "END:VCALENDAR" + ics = ics.replace("\r", "").replace("\n", "\r\n") + return HttpResponse(ics, content_type="text/calendar; charset=UTF-8") diff --git a/apps/logs/signals.py b/apps/logs/signals.py index 89e7d05d..e58ba7c1 100644 --- a/apps/logs/signals.py +++ b/apps/logs/signals.py @@ -117,10 +117,7 @@ def delete_object(sender, instance, **kwargs): Each time a model is deleted, an entry in the table `Changelog` is added in the database """ # noinspection PyProtectedMember - if instance._meta.label_lower in EXCLUDED: - return - - if hasattr(instance, "_no_log"): + if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_log"): return # Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP From 6d1b75b9b67d21a0fa2c37be36e9e94808aeca27 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Fri, 4 Sep 2020 19:24:48 +0200 Subject: [PATCH 24/95] Fix linebreaks in ICS file --- apps/activity/views.py | 689 +++++++++++++++++++++-------------------- 1 file changed, 345 insertions(+), 344 deletions(-) diff --git a/apps/activity/views.py b/apps/activity/views.py index 79934245..f6dae500 100644 --- a/apps/activity/views.py +++ b/apps/activity/views.py @@ -1,344 +1,345 @@ -# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay -# SPDX-License-Identifier: GPL-3.0-or-later -from hashlib import md5 - -from django.conf import settings -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import PermissionDenied -from django.db.models import F, Q -from django.http import HttpResponse -from django.urls import reverse_lazy -from django.utils import timezone -from django.utils.translation import gettext_lazy as _ -from django.views import View -from django.views.generic import DetailView, TemplateView, UpdateView -from django_tables2.views import SingleTableView -from note.models import Alias, NoteSpecial, NoteUser -from permission.backends import PermissionBackend -from permission.views import ProtectQuerysetMixin, ProtectedCreateView - -from .forms import ActivityForm, GuestForm -from .models import Activity, Entry, Guest -from .tables import ActivityTable, EntryTable, GuestTable - - -class ActivityCreateView(ProtectQuerysetMixin, ProtectedCreateView): - """ - View to create a new Activity - """ - model = Activity - form_class = ActivityForm - extra_context = {"title": _("Create new activity")} - - def get_sample_object(self): - return Activity( - name="", - description="", - creater=self.request.user, - activity_type_id=1, - organizer_id=1, - attendees_club_id=1, - date_start=timezone.now(), - date_end=timezone.now(), - ) - - def form_valid(self, form): - form.instance.creater = self.request.user - return super().form_valid(form) - - def get_success_url(self, **kwargs): - self.object.refresh_from_db() - return reverse_lazy('activity:activity_detail', kwargs={"pk": self.object.pk}) - - -class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): - """ - Displays all Activities, and classify if they are on-going or upcoming ones. - """ - model = Activity - table_class = ActivityTable - ordering = ('-date_start',) - extra_context = {"title": _("Activities")} - - def get_queryset(self): - return super().get_queryset().distinct() - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - upcoming_activities = Activity.objects.filter(date_end__gt=timezone.now()) - context['upcoming'] = ActivityTable( - data=upcoming_activities.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")), - prefix='upcoming-', - ) - - started_activities = Activity.objects\ - .filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ - .filter(open=True, valid=True).all() - context["started_activities"] = started_activities - - return context - - -class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): - """ - Shows details about one activity. Add guest to context - """ - model = Activity - context_object_name = "activity" - extra_context = {"title": _("Activity detail")} - - def get_context_data(self, **kwargs): - context = super().get_context_data() - - table = GuestTable(data=Guest.objects.filter(activity=self.object) - .filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))) - context["guests"] = table - - context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start) - - return context - - -class ActivityUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): - """ - Updates one Activity - """ - model = Activity - form_class = ActivityForm - extra_context = {"title": _("Update activity")} - - def get_success_url(self, **kwargs): - return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]}) - - -class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView): - """ - Invite a Guest, The rules to invites someone are defined in `forms:activity.GuestForm` - """ - model = Guest - form_class = GuestForm - template_name = "activity/activity_form.html" - - def get_sample_object(self): - """ Creates a standart Guest binds to the Activity""" - activity = Activity.objects.get(pk=self.kwargs["pk"]) - return Guest( - activity=activity, - first_name="", - last_name="", - inviter=self.request.user.note, - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - activity = context["form"].activity - context["title"] = _('Invite guest to the activity "{}"').format(activity.name) - return context - - def get_form(self, form_class=None): - form = super().get_form(form_class) - form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ - .get(pk=self.kwargs["pk"]) - form.fields["inviter"].initial = self.request.user.note - return form - - def form_valid(self, form): - form.instance.activity = Activity.objects\ - .filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")).get(pk=self.kwargs["pk"]) - return super().form_valid(form) - - def get_success_url(self, **kwargs): - return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]}) - - -class ActivityEntryView(LoginRequiredMixin, TemplateView): - """ - Manages entry to an activity - """ - template_name = "activity/activity_entry.html" - - def dispatch(self, request, *args, **kwargs): - """ - Don't display the entry interface if the user has no right to see it (no right to add an entry for itself), - it is closed or doesn't manage entries. - """ - activity = Activity.objects.get(pk=self.kwargs["pk"]) - - sample_entry = Entry(activity=activity, note=self.request.user.note) - if not PermissionBackend.check_perm(self.request.user, "activity.add_entry", sample_entry): - raise PermissionDenied(_("You are not allowed to display the entry interface for this activity.")) - - if not activity.activity_type.manage_entries: - raise PermissionDenied(_("This activity does not support activity entries.")) - - if not activity.open: - raise PermissionDenied(_("This activity is closed.")) - return super().dispatch(request, *args, **kwargs) - - def get_invited_guest(self, activity): - """ - Retrieves all Guests to the activity - """ - - guest_qs = Guest.objects\ - .annotate(balance=F("inviter__balance"), note_name=F("inviter__user__username"))\ - .filter(activity=activity)\ - .filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))\ - .order_by('last_name', 'first_name').distinct() - - if "search" in self.request.GET and self.request.GET["search"]: - pattern = self.request.GET["search"] - if pattern[0] != "^": - pattern = "^" + pattern - guest_qs = guest_qs.filter( - Q(first_name__regex=pattern) - | Q(last_name__regex=pattern) - | Q(inviter__alias__name__regex=pattern) - | Q(inviter__alias__normalized_name__regex=Alias.normalize(pattern)) - ) - else: - guest_qs = guest_qs.none() - return guest_qs - - def get_invited_note(self, activity): - """ - Retrieves all Note that can attend the activity, - they need to have an up-to-date membership in the attendees_club. - """ - note_qs = Alias.objects.annotate(last_name=F("note__noteuser__user__last_name"), - first_name=F("note__noteuser__user__first_name"), - username=F("note__noteuser__user__username"), - note_name=F("name"), - balance=F("note__balance")) - - # Keep only users that have a note - note_qs = note_qs.filter(note__noteuser__isnull=False) - - # Keep only members - note_qs = note_qs.filter( - note__noteuser__user__memberships__club=activity.attendees_club, - note__noteuser__user__memberships__date_start__lte=timezone.now(), - note__noteuser__user__memberships__date_end__gte=timezone.now(), - ) - - # Filter with permission backend - note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view")) - - if "search" in self.request.GET and self.request.GET["search"]: - pattern = self.request.GET["search"] - note_qs = note_qs.filter( - Q(note__noteuser__user__first_name__regex=pattern) - | Q(note__noteuser__user__last_name__regex=pattern) - | Q(name__regex=pattern) - | Q(normalized_name__regex=Alias.normalize(pattern)) - ) - else: - note_qs = note_qs.none() - - if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql': - note_qs = note_qs.distinct('note__pk')[:20] - else: - # SQLite doesn't support distinct fields. For compatibility reason (in dev mode), the note list will only - # have distinct aliases rather than distinct notes with a SQLite DB, but it can fill the result page. - # In production mode, please use PostgreSQL. - note_qs = note_qs.distinct()[:20] - return note_qs - - def get_context_data(self, **kwargs): - """ - Query the list of Guest and Note to the activity and add information to makes entry with JS. - """ - context = super().get_context_data(**kwargs) - - activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ - .distinct().get(pk=self.kwargs["pk"]) - context["activity"] = activity - - matched = [] - - for guest in self.get_invited_guest(activity): - guest.type = "Invité" - matched.append(guest) - - for note in self.get_invited_note(activity): - note.type = "Adhérent" - note.activity = activity - matched.append(note) - - table = EntryTable(data=matched) - context["table"] = table - - context["entries"] = Entry.objects.filter(activity=activity) - - context["title"] = _('Entry for activity "{}"').format(activity.name) - context["noteuser_ctype"] = ContentType.objects.get_for_model(NoteUser).pk - context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk - - activities_open = Activity.objects.filter(open=True).filter( - PermissionBackend.filter_queryset(self.request.user, Activity, "view")).distinct().all() - context["activities_open"] = [a for a in activities_open - if PermissionBackend.check_perm(self.request.user, - "activity.add_entry", - Entry(activity=a, note=self.request.user.note,))] - - return context - - -class CalendarView(View): - """ - Render an ICS calendar with all valid activities. - """ - - def multilines(self, string, maxlength, offset=0): - newstring = string[:maxlength - offset] - string = string[maxlength - offset:] - while string: - newstring += "\r\n " - newstring += string[:maxlength - 1] - string = string[maxlength - 1:] - return newstring - - def get(self, request, *args, **kwargs): - ics = """BEGIN:VCALENDAR -VERSION: 2.0 -PRODID:Note Kfet 2020 -X-WR-CALNAME:Kfet Calendar -NAME:Kfet Calendar -CALSCALE:GREGORIAN -BEGIN:VTIMEZONE -TZID:Europe/Berlin -TZURL:http://tzurl.org/zoneinfo-outlook/Europe/Berlin -X-LIC-LOCATION:Europe/Berlin -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU -END:STANDARD -END:VTIMEZONE -""" - for activity in Activity.objects.filter(valid=True).order_by("-date_start").all(): - ics += f"""BEGIN:VEVENT -DTSTAMP:{"{:%Y%m%dT%H%M%S}".format(activity.date_start)}Z -UID:{activity.id} -SUMMARY;CHARSET=UTF-8:{self.multilines(activity.name, 75, 22)} -DTSTART;TZID=Europe/Berlin:{"{:%Y%m%dT%H%M%S}".format(activity.date_start)} -DTEND;TZID=Europe/Berlin:{"{:%Y%m%dT%H%M%S}".format(activity.date_end)} -LOCATION:{self.multilines(activity.location, 75, 9) if activity.location else "Kfet"} -DESCRIPTION;CHARSET=UTF-8:{self.multilines(activity.description, 75, 26)} - -- {activity.organizer.name} -END:VEVENT -""" - ics += "END:VCALENDAR" - ics = ics.replace("\r", "").replace("\n", "\r\n") - return HttpResponse(ics, content_type="text/calendar; charset=UTF-8") +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later +from hashlib import md5 +from random import randint + +from django.conf import settings +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import PermissionDenied +from django.db.models import F, Q +from django.http import HttpResponse +from django.urls import reverse_lazy +from django.utils import timezone +from django.utils.crypto import get_random_string +from django.utils.translation import gettext_lazy as _ +from django.views import View +from django.views.generic import DetailView, TemplateView, UpdateView +from django_tables2.views import SingleTableView +from note.models import Alias, NoteSpecial, NoteUser +from permission.backends import PermissionBackend +from permission.views import ProtectQuerysetMixin, ProtectedCreateView + +from .forms import ActivityForm, GuestForm +from .models import Activity, Entry, Guest +from .tables import ActivityTable, EntryTable, GuestTable + + +class ActivityCreateView(ProtectQuerysetMixin, ProtectedCreateView): + """ + View to create a new Activity + """ + model = Activity + form_class = ActivityForm + extra_context = {"title": _("Create new activity")} + + def get_sample_object(self): + return Activity( + name="", + description="", + creater=self.request.user, + activity_type_id=1, + organizer_id=1, + attendees_club_id=1, + date_start=timezone.now(), + date_end=timezone.now(), + ) + + def form_valid(self, form): + form.instance.creater = self.request.user + return super().form_valid(form) + + def get_success_url(self, **kwargs): + self.object.refresh_from_db() + return reverse_lazy('activity:activity_detail', kwargs={"pk": self.object.pk}) + + +class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): + """ + Displays all Activities, and classify if they are on-going or upcoming ones. + """ + model = Activity + table_class = ActivityTable + ordering = ('-date_start',) + extra_context = {"title": _("Activities")} + + def get_queryset(self): + return super().get_queryset().distinct() + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + upcoming_activities = Activity.objects.filter(date_end__gt=timezone.now()) + context['upcoming'] = ActivityTable( + data=upcoming_activities.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")), + prefix='upcoming-', + ) + + started_activities = Activity.objects\ + .filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ + .filter(open=True, valid=True).all() + context["started_activities"] = started_activities + + return context + + +class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): + """ + Shows details about one activity. Add guest to context + """ + model = Activity + context_object_name = "activity" + extra_context = {"title": _("Activity detail")} + + def get_context_data(self, **kwargs): + context = super().get_context_data() + + table = GuestTable(data=Guest.objects.filter(activity=self.object) + .filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))) + context["guests"] = table + + context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start) + + return context + + +class ActivityUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): + """ + Updates one Activity + """ + model = Activity + form_class = ActivityForm + extra_context = {"title": _("Update activity")} + + def get_success_url(self, **kwargs): + return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]}) + + +class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView): + """ + Invite a Guest, The rules to invites someone are defined in `forms:activity.GuestForm` + """ + model = Guest + form_class = GuestForm + template_name = "activity/activity_form.html" + + def get_sample_object(self): + """ Creates a standart Guest binds to the Activity""" + activity = Activity.objects.get(pk=self.kwargs["pk"]) + return Guest( + activity=activity, + first_name="", + last_name="", + inviter=self.request.user.note, + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + activity = context["form"].activity + context["title"] = _('Invite guest to the activity "{}"').format(activity.name) + return context + + def get_form(self, form_class=None): + form = super().get_form(form_class) + form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ + .get(pk=self.kwargs["pk"]) + form.fields["inviter"].initial = self.request.user.note + return form + + def form_valid(self, form): + form.instance.activity = Activity.objects\ + .filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")).get(pk=self.kwargs["pk"]) + return super().form_valid(form) + + def get_success_url(self, **kwargs): + return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]}) + + +class ActivityEntryView(LoginRequiredMixin, TemplateView): + """ + Manages entry to an activity + """ + template_name = "activity/activity_entry.html" + + def dispatch(self, request, *args, **kwargs): + """ + Don't display the entry interface if the user has no right to see it (no right to add an entry for itself), + it is closed or doesn't manage entries. + """ + activity = Activity.objects.get(pk=self.kwargs["pk"]) + + sample_entry = Entry(activity=activity, note=self.request.user.note) + if not PermissionBackend.check_perm(self.request.user, "activity.add_entry", sample_entry): + raise PermissionDenied(_("You are not allowed to display the entry interface for this activity.")) + + if not activity.activity_type.manage_entries: + raise PermissionDenied(_("This activity does not support activity entries.")) + + if not activity.open: + raise PermissionDenied(_("This activity is closed.")) + return super().dispatch(request, *args, **kwargs) + + def get_invited_guest(self, activity): + """ + Retrieves all Guests to the activity + """ + + guest_qs = Guest.objects\ + .annotate(balance=F("inviter__balance"), note_name=F("inviter__user__username"))\ + .filter(activity=activity)\ + .filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))\ + .order_by('last_name', 'first_name').distinct() + + if "search" in self.request.GET and self.request.GET["search"]: + pattern = self.request.GET["search"] + if pattern[0] != "^": + pattern = "^" + pattern + guest_qs = guest_qs.filter( + Q(first_name__regex=pattern) + | Q(last_name__regex=pattern) + | Q(inviter__alias__name__regex=pattern) + | Q(inviter__alias__normalized_name__regex=Alias.normalize(pattern)) + ) + else: + guest_qs = guest_qs.none() + return guest_qs + + def get_invited_note(self, activity): + """ + Retrieves all Note that can attend the activity, + they need to have an up-to-date membership in the attendees_club. + """ + note_qs = Alias.objects.annotate(last_name=F("note__noteuser__user__last_name"), + first_name=F("note__noteuser__user__first_name"), + username=F("note__noteuser__user__username"), + note_name=F("name"), + balance=F("note__balance")) + + # Keep only users that have a note + note_qs = note_qs.filter(note__noteuser__isnull=False) + + # Keep only members + note_qs = note_qs.filter( + note__noteuser__user__memberships__club=activity.attendees_club, + note__noteuser__user__memberships__date_start__lte=timezone.now(), + note__noteuser__user__memberships__date_end__gte=timezone.now(), + ) + + # Filter with permission backend + note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view")) + + if "search" in self.request.GET and self.request.GET["search"]: + pattern = self.request.GET["search"] + note_qs = note_qs.filter( + Q(note__noteuser__user__first_name__regex=pattern) + | Q(note__noteuser__user__last_name__regex=pattern) + | Q(name__regex=pattern) + | Q(normalized_name__regex=Alias.normalize(pattern)) + ) + else: + note_qs = note_qs.none() + + if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql': + note_qs = note_qs.distinct('note__pk')[:20] + else: + # SQLite doesn't support distinct fields. For compatibility reason (in dev mode), the note list will only + # have distinct aliases rather than distinct notes with a SQLite DB, but it can fill the result page. + # In production mode, please use PostgreSQL. + note_qs = note_qs.distinct()[:20] + return note_qs + + def get_context_data(self, **kwargs): + """ + Query the list of Guest and Note to the activity and add information to makes entry with JS. + """ + context = super().get_context_data(**kwargs) + + activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ + .distinct().get(pk=self.kwargs["pk"]) + context["activity"] = activity + + matched = [] + + for guest in self.get_invited_guest(activity): + guest.type = "Invité" + matched.append(guest) + + for note in self.get_invited_note(activity): + note.type = "Adhérent" + note.activity = activity + matched.append(note) + + table = EntryTable(data=matched) + context["table"] = table + + context["entries"] = Entry.objects.filter(activity=activity) + + context["title"] = _('Entry for activity "{}"').format(activity.name) + context["noteuser_ctype"] = ContentType.objects.get_for_model(NoteUser).pk + context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk + + activities_open = Activity.objects.filter(open=True).filter( + PermissionBackend.filter_queryset(self.request.user, Activity, "view")).distinct().all() + context["activities_open"] = [a for a in activities_open + if PermissionBackend.check_perm(self.request.user, + "activity.add_entry", + Entry(activity=a, note=self.request.user.note,))] + + return context + + +class CalendarView(View): + """ + Render an ICS calendar with all valid activities. + """ + + def multilines(self, string, maxlength, offset=0): + newstring = string[:maxlength - offset] + string = string[maxlength - offset:] + while string: + newstring += "\r\n " + newstring += string[:maxlength - 1] + string = string[maxlength - 1:] + return newstring + + def get(self, request, *args, **kwargs): + ics = """BEGIN:VCALENDAR +VERSION: 2.0 +PRODID:Note Kfet 2020 +X-WR-CALNAME:Kfet Calendar +NAME:Kfet Calendar +CALSCALE:GREGORIAN +BEGIN:VTIMEZONE +TZID:Europe/Berlin +X-LIC-LOCATION:Europe/Berlin +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE +""" + for activity in Activity.objects.filter(valid=True).order_by("-date_start").all(): + ics += f"""BEGIN:VEVENT +DTSTAMP:{"{:%Y%m%dT%H%M%S}".format(activity.date_start)}Z +UID:{md5((activity.name + "$" + str(activity.id) + str(activity.date_start)).encode("UTF-8")).hexdigest()} +SUMMARY;CHARSET=UTF-8:{self.multilines(activity.name, 75, 22)} +DTSTART;TZID=Europe/Berlin:{"{:%Y%m%dT%H%M%S}".format(activity.date_start)} +DTEND;TZID=Europe/Berlin:{"{:%Y%m%dT%H%M%S}".format(activity.date_end)} +LOCATION:{self.multilines(activity.location, 75, 9) if activity.location else "Kfet"} +DESCRIPTION;CHARSET=UTF-8:""" + self.multilines(activity.description.replace("\n", "\\n"), 75, 26) + """ + -- {activity.organizer.name} +END:VEVENT +""" + ics += "END:VCALENDAR" + ics = ics.replace("\r", "").replace("\n", "\r\n") + return HttpResponse(ics, content_type="text/calendar; charset=UTF-8") From 4ddd763886bd426a7c06a4ca7a34b882131044c2 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Fri, 4 Sep 2020 21:46:40 +0200 Subject: [PATCH 25/95] Test activity app --- apps/activity/models.py | 2 +- apps/activity/tests/test_activities.py | 176 +++++++++++++++++++++++++ apps/activity/views.py | 31 ++--- 3 files changed, 191 insertions(+), 18 deletions(-) create mode 100644 apps/activity/tests/test_activities.py diff --git a/apps/activity/models.py b/apps/activity/models.py index 131cd725..0aefaf59 100644 --- a/apps/activity/models.py +++ b/apps/activity/models.py @@ -130,7 +130,7 @@ class Activity(models.Model): raise ValidationError(_("The end date must be after the start date.")) ret = super().save(*args, **kwargs) - if settings.DEBUG and self.pk and "scripts" in settings.INSTALLED_APPS: + if not settings.DEBUG and self.pk and "scripts" in settings.INSTALLED_APPS: def refresh_activities(): from scripts.management.commands.refresh_activities import Command as RefreshActivitiesCommand RefreshActivitiesCommand.refresh_human_readable_wiki_page("Modification de l'activité " + self.name) diff --git a/apps/activity/tests/test_activities.py b/apps/activity/tests/test_activities.py new file mode 100644 index 00000000..db83fb0e --- /dev/null +++ b/apps/activity/tests/test_activities.py @@ -0,0 +1,176 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from datetime import timedelta + +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 + + +class TestActivities(TestCase): + """ + Test activities + """ + fixtures = ('initial',) + + def setUp(self): + self.user = User.objects.create_superuser( + username="admintoto", + password="tototototo", + email="toto@example.com" + ) + self.client.force_login(self.user) + + sess = self.client.session + sess["permission_mask"] = 42 + sess.save() + + 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", + ) + + def test_activity_list(self): + """ + Display the list of all activities + """ + response = self.client.get(reverse("activity:activity_list")) + self.assertEqual(response.status_code, 200) + + def test_activity_create(self): + """ + Create a new activity + """ + response = self.client.get(reverse("activity:activity_create")) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("activity:activity_create"), data=dict( + name="Activity created", + description="This activity was successfully created.", + location="Earth", + activity_type=ActivityType.objects.get(name="Soirée de club").id, + creater=self.user.id, + organizer=Club.objects.get(name="Kfet").id, + attendees_club=Club.objects.get(name="Kfet").id, + date_start="{:%Y-%m-%d %H:%M}".format(timezone.now()), + date_end="{:%Y-%m-%d %H:%M}".format(timezone.now() + timedelta(days=2)), + valid=True, + )) + self.assertTrue(Activity.objects.filter(name="Activity created").exists()) + activity = Activity.objects.get(name="Activity created") + self.assertRedirects(response, reverse("activity:activity_detail", args=(activity.pk,)), 302, 200) + + def test_activity_detail(self): + """ + Display the detail of an activity + """ + response = self.client.get(reverse("activity:activity_detail", args=(self.activity.pk,))) + self.assertEqual(response.status_code, 200) + + def test_activity_update(self): + """ + Update an activity + """ + response = self.client.get(reverse("activity:activity_update", args=(self.activity.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("activity:activity_update", args=(self.activity.pk,)), data=dict( + name=str(self.activity) + " updated", + description="This activity was successfully updated.", + location="Earth", + activity_type=ActivityType.objects.get(name="Autre").id, + creater=self.user.id, + organizer=Club.objects.get(name="Kfet").id, + attendees_club=Club.objects.get(name="Kfet").id, + date_start="{:%Y-%m-%d %H:%M}".format(timezone.now()), + date_end="{:%Y-%m-%d %H:%M}".format(timezone.now() + timedelta(days=2)), + valid=True, + )) + self.assertTrue(Activity.objects.filter(name="Activity updated").exists()) + self.assertRedirects(response, reverse("activity:activity_detail", args=(self.activity.pk,)), 302, 200) + + def test_activity_entry(self): + """ + Create some entries + """ + self.activity.open = True + self.activity.save() + + response = self.client.get(reverse("activity:activity_entry", args=(self.activity.pk,))) + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("activity:activity_entry", args=(self.activity.pk,)) + "?search=guest") + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("activity:activity_entry", args=(self.activity.pk,)) + "?search=admin") + self.assertEqual(response.status_code, 200) + + # User entry + response = self.client.post("/api/activity/entry/", data=dict( + activity=self.activity.id, + note=self.user.note.id, + guest="", + )) + self.assertEqual(response.status_code, 201) # 201 = Created + self.assertTrue(Entry.objects.filter(note=self.user.note, guest=None, activity=self.activity).exists()) + + # Guest entry + response = self.client.post("/api/activity/entry/", data=dict( + activity=self.activity.id, + note=self.user.note.id, + guest=self.guest.id, + )) + self.assertEqual(response.status_code, 201) # 201 = Created + self.assertTrue(Entry.objects.filter(note=self.user.note, guest=self.guest.id, activity=self.activity).exists()) + + def test_activity_invite(self): + """ + Try to invite people to an activity + """ + response = self.client.get(reverse("activity:activity_invite", args=(self.activity.pk,))) + self.assertEqual(response.status_code, 200) + + # The activity is started, can't invite + response = self.client.post(reverse("activity:activity_invite", args=(self.activity.pk,)), data=dict( + activity=self.activity.id, + inviter=self.user.note.id, + last_name="GUEST2", + first_name="Guest", + )) + self.assertEqual(response.status_code, 200) + + self.activity.date_start += timedelta(days=1) + self.activity.save() + + response = self.client.post(reverse("activity:activity_invite", args=(self.activity.pk,)), data=dict( + activity=self.activity.id, + inviter=self.user.note.id, + last_name="GUEST2", + first_name="Guest", + )) + self.assertRedirects(response, reverse("activity:activity_detail", args=(self.activity.pk,)), 302, 200) + + def test_activity_ics(self): + """ + Render the ICS calendar + """ + response = self.client.get(reverse("activity:calendar_ics")) + self.assertEqual(response.status_code, 200) diff --git a/apps/activity/views.py b/apps/activity/views.py index f6dae500..7de31b0c 100644 --- a/apps/activity/views.py +++ b/apps/activity/views.py @@ -1,7 +1,7 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later + from hashlib import md5 -from random import randint from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin @@ -11,7 +11,6 @@ from django.db.models import F, Q from django.http import HttpResponse from django.urls import reverse_lazy from django.utils import timezone -from django.utils.crypto import get_random_string from django.utils.translation import gettext_lazy as _ from django.views import View from django.views.generic import DetailView, TemplateView, UpdateView @@ -195,10 +194,10 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView): if pattern[0] != "^": pattern = "^" + pattern guest_qs = guest_qs.filter( - Q(first_name__regex=pattern) - | Q(last_name__regex=pattern) - | Q(inviter__alias__name__regex=pattern) - | Q(inviter__alias__normalized_name__regex=Alias.normalize(pattern)) + Q(first_name__iregex=pattern) + | Q(last_name__iregex=pattern) + | Q(inviter__alias__name__iregex=pattern) + | Q(inviter__alias__normalized_name__iregex=Alias.normalize(pattern)) ) else: guest_qs = guest_qs.none() @@ -231,21 +230,19 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView): if "search" in self.request.GET and self.request.GET["search"]: pattern = self.request.GET["search"] note_qs = note_qs.filter( - Q(note__noteuser__user__first_name__regex=pattern) - | Q(note__noteuser__user__last_name__regex=pattern) - | Q(name__regex=pattern) - | Q(normalized_name__regex=Alias.normalize(pattern)) + Q(note__noteuser__user__first_name__iregex=pattern) + | Q(note__noteuser__user__last_name__iregex=pattern) + | Q(name__iregex=pattern) + | Q(normalized_name__iregex=Alias.normalize(pattern)) ) else: note_qs = note_qs.none() - if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql': - note_qs = note_qs.distinct('note__pk')[:20] - else: - # SQLite doesn't support distinct fields. For compatibility reason (in dev mode), the note list will only - # have distinct aliases rather than distinct notes with a SQLite DB, but it can fill the result page. - # In production mode, please use PostgreSQL. - note_qs = note_qs.distinct()[:20] + # SQLite doesn't support distinct fields. For compatibility reason (in dev mode), the note list will only + # have distinct aliases rather than distinct notes with a SQLite DB, but it can fill the result page. + # In production mode, please use PostgreSQL. + note_qs = note_qs.distinct('note__pk')[:20]\ + if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql' else note_qs.distinct()[:20] return note_qs def get_context_data(self, **kwargs): From 0f47412c3805ad572def6b1fb6f30b22c249644a Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Fri, 4 Sep 2020 22:37:18 +0200 Subject: [PATCH 26/95] Fix Ansible script for production --- ansible/base.yml | 8 ++- ansible/hosts | 1 + ansible/roles/2-nk20/tasks/main.yml | 2 +- ansible/roles/4-nginx/tasks/main.yml | 44 ------------- .../roles/4-nginx/templates/nginx_note.conf | 63 ------------------- ansible/roles/5-certbot/tasks/main.yml | 21 ------- .../templates/letsencrypt/conf.d/nk20.ini.j2 | 20 ------ ansible/roles/7-postinstall/tasks/main.yml | 6 ++ apps/scripts | 2 +- note_kfet/templates/base.html | 11 ---- 10 files changed, 14 insertions(+), 164 deletions(-) delete mode 100644 ansible/roles/4-nginx/tasks/main.yml delete mode 100644 ansible/roles/4-nginx/templates/nginx_note.conf delete mode 100644 ansible/roles/5-certbot/tasks/main.yml delete mode 100644 ansible/roles/5-certbot/templates/letsencrypt/conf.d/nk20.ini.j2 diff --git a/ansible/base.yml b/ansible/base.yml index 56ba83d9..330089d5 100755 --- a/ansible/base.yml +++ b/ansible/base.yml @@ -1,18 +1,20 @@ #!/usr/bin/env ansible-playbook --- -- hosts: bde-nk20-beta.adh.crans.org +- hosts: bde-note.adh.crans.org vars_prompt: - name: DB_PASSWORD prompt: "Password of the database" private: yes vars: mirror: deb.debian.org + note: + server_name: bde-note.adh.crans.org roles: - 1-apt-basic - 2-nk20 - 3-pip - - 4-nginx - - 5-certbot + - 4-certbot + - 5-nginx - 6-psql - 7-postinstall diff --git a/ansible/hosts b/ansible/hosts index beafcc55..454b7aa0 100644 --- a/ansible/hosts +++ b/ansible/hosts @@ -1,5 +1,6 @@ [server] bde-nk20-beta.adh.crans.org +bde-note.adh.crans.org [all:vars] ansible_python_interpreter=/usr/bin/python3 diff --git a/ansible/roles/2-nk20/tasks/main.yml b/ansible/roles/2-nk20/tasks/main.yml index 37d29819..57615f52 100644 --- a/ansible/roles/2-nk20/tasks/main.yml +++ b/ansible/roles/2-nk20/tasks/main.yml @@ -11,7 +11,7 @@ git: repo: https://gitlab.crans.org/bde/nk20.git dest: /var/www/note_kfet - version: beta + version: master force: true - name: Use default env vars (should be updated!) diff --git a/ansible/roles/4-nginx/tasks/main.yml b/ansible/roles/4-nginx/tasks/main.yml deleted file mode 100644 index 431e470b..00000000 --- a/ansible/roles/4-nginx/tasks/main.yml +++ /dev/null @@ -1,44 +0,0 @@ ---- -- name: Install NGINX - apt: - name: nginx - register: pkg_result - retries: 3 - until: pkg_result is succeeded - -- name: Copy conf of Nginx - template: - src: "nginx_note.conf" - dest: /etc/nginx/sites-available/nginx_note.conf - mode: 0644 - owner: www-data - group: www-data - -- name: Enable Nginx site - file: - src: /etc/nginx/sites-available/nginx_note.conf - dest: /etc/nginx/sites-enabled/nginx_note.conf - owner: www-data - group: www-data - state: link - -- name: Disable default Nginx site - file: - dest: /etc/nginx/sites-enabled/default - state: absent - -- name: Copy conf of UWSGI - file: - src: /var/www/note_kfet/uwsgi_note.ini - dest: /etc/uwsgi/apps-enabled/uwsgi_note.ini - state: link - -- name: Reload Nginx - systemd: - name: nginx - state: reloaded - -- name: Restart UWSGI - systemd: - name: uwsgi - state: restarted diff --git a/ansible/roles/4-nginx/templates/nginx_note.conf b/ansible/roles/4-nginx/templates/nginx_note.conf deleted file mode 100644 index b195e739..00000000 --- a/ansible/roles/4-nginx/templates/nginx_note.conf +++ /dev/null @@ -1,63 +0,0 @@ -# the upstream component nginx needs to connect to -upstream note{ - server unix:///var/www/note_kfet/note_kfet.sock; # file socket -} - -# Redirect HTTP to nk20 HTTPS -server { - listen 80 default_server; - listen [::]:80 default_server; - - location / { - return 301 https://nk20-beta.crans.org$request_uri; - } -} - -# Redirect all HTTPS to nk20 HTTPS -server { - listen 443 ssl default_server; - listen [::]:443 ssl default_server; - - location / { - return 301 https://nk20-beta.crans.org$request_uri; - } - - ssl_certificate /etc/letsencrypt/live/nk20-beta.crans.org/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/nk20-beta.crans.org/privkey.pem; - include /etc/letsencrypt/options-ssl-nginx.conf; - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; -} - -# configuration of the server -server { - listen 443 ssl; - listen [::]:443 ssl; - - # the port your site will be served on - # the domain name it will serve for - server_name nk20-beta.crans.org; # substitute your machine's IP address or FQDN - charset utf-8; - - # max upload size - client_max_body_size 75M; # adjust to taste - - # Django media - location /media { - alias /var/www/note_kfet/media; # your Django project's media files - amend as required - } - - location /static { - alias /var/www/note_kfet/static; # your Django project's static files - amend as required - } - - # Finally, send all non-media requests to the Django server. - location / { - uwsgi_pass note; - include /etc/nginx/uwsgi_params; - } - - ssl_certificate /etc/letsencrypt/live/nk20-beta.crans.org/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/nk20-beta.crans.org/privkey.pem; - include /etc/letsencrypt/options-ssl-nginx.conf; - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; -} diff --git a/ansible/roles/5-certbot/tasks/main.yml b/ansible/roles/5-certbot/tasks/main.yml deleted file mode 100644 index 52bc0d67..00000000 --- a/ansible/roles/5-certbot/tasks/main.yml +++ /dev/null @@ -1,21 +0,0 @@ ---- -- name: Install basic APT packages - apt: - update_cache: true - name: - - certbot - - python3-certbot-nginx - register: pkg_result - retries: 3 - until: pkg_result is succeeded - -- name: Create /etc/letsencrypt/conf.d - file: - path: /etc/letsencrypt/conf.d - state: directory - -- name: Add Certbot configuration - template: - src: "letsencrypt/conf.d/nk20.ini.j2" - dest: "/etc/letsencrypt/conf.d/nk20.ini" - mode: 0644 diff --git a/ansible/roles/5-certbot/templates/letsencrypt/conf.d/nk20.ini.j2 b/ansible/roles/5-certbot/templates/letsencrypt/conf.d/nk20.ini.j2 deleted file mode 100644 index b02abf5a..00000000 --- a/ansible/roles/5-certbot/templates/letsencrypt/conf.d/nk20.ini.j2 +++ /dev/null @@ -1,20 +0,0 @@ -{{ ansible_managed | comment }} - -# To generate the certificate, please use the following command -# certbot --config /etc/letsencrypt/conf.d/nk20.ini certonly - -# Use a 4096 bit RSA key instead of 2048 -rsa-key-size = 4096 - -# Always use the staging/testing server -# server = https://acme-staging.api.letsencrypt.org/directory - -# Uncomment and update to register with the specified e-mail address -email = notekfet2020@lists.crans.org - -# Uncomment to use a text interface instead of ncurses -text = True - -# Use DNS-01 challenge -authenticator = nginx - diff --git a/ansible/roles/7-postinstall/tasks/main.yml b/ansible/roles/7-postinstall/tasks/main.yml index 34a9011b..25fde0e7 100644 --- a/ansible/roles/7-postinstall/tasks/main.yml +++ b/ansible/roles/7-postinstall/tasks/main.yml @@ -22,3 +22,9 @@ args: chdir: /var/www/note_kfet become_user: postgres + +- name: Collect static files + command: /var/www/note_kfet/env/bin/python manage.py collectstatic --noinput + args: + chdir: /var/www/note_kfet + become_user: www-data diff --git a/apps/scripts b/apps/scripts index 4e1bcd18..525f091b 160000 --- a/apps/scripts +++ b/apps/scripts @@ -1 +1 @@ -Subproject commit 4e1bcd1808a24b532aa27bf2a119f6f8155af534 +Subproject commit 525f091b0caddc69cb2da7eba545ab9609bb1bb0 diff --git a/note_kfet/templates/base.html b/note_kfet/templates/base.html index fcee608a..6d092367 100644 --- a/note_kfet/templates/base.html +++ b/note_kfet/templates/base.html @@ -154,17 +154,6 @@ SPDX-License-Identifier: GPL-3.0-or-later {% trans "Your e-mail address is not validated. Please check your mail inbox and click on the validation link." %}
{% endif %} -
- - Attention : la Note Kfet 2020 est en phase de beta. Des fonctionnalités pourront être rajoutées d'ici à la version - finale, et des bugs peuvent survenir. Pour tout problème, merci d'envoyer un mail à l'adresse - - notekfet2020@lists.crans.org, - ou bien levez une issue sur le dépôt Gitlab, - ou encore posez un commentaire sur le pad.

- - Certaines données ont été anonymisées afin de limiter les fuites de données, et peuvent ne pas correspondre avec vos données réelles. -
{% block content %}

Default content...

From 2e80233cbcec6ad4de704fd2cdfb6f236a2a627c Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Sat, 5 Sep 2020 00:45:14 +0200 Subject: [PATCH 27/95] Change debug option to "print stdout" / "edit wiki" in the Refresh activities script --- apps/scripts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/scripts b/apps/scripts index 525f091b..7246f4d1 160000 --- a/apps/scripts +++ b/apps/scripts @@ -1 +1 @@ -Subproject commit 525f091b0caddc69cb2da7eba545ab9609bb1bb0 +Subproject commit 7246f4d18aa4f5e161b6e185b02fa28187e04747 From 2fc13e5418e2ef9bf34e7ccc6fa7a909077d656a Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Sat, 5 Sep 2020 00:47:30 +0200 Subject: [PATCH 28/95] Edit the wiki after an activity update iff the wiki password is defined, and don't run the script asynchronous with a SQLite database --- apps/activity/models.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/activity/models.py b/apps/activity/models.py index 0aefaf59..9d3431be 100644 --- a/apps/activity/models.py +++ b/apps/activity/models.py @@ -1,6 +1,7 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +import os from datetime import timedelta from threading import Thread @@ -133,9 +134,13 @@ class Activity(models.Model): if not settings.DEBUG and self.pk and "scripts" in settings.INSTALLED_APPS: def refresh_activities(): from scripts.management.commands.refresh_activities import Command as RefreshActivitiesCommand - RefreshActivitiesCommand.refresh_human_readable_wiki_page("Modification de l'activité " + self.name) - RefreshActivitiesCommand.refresh_raw_wiki_page("Modification de l'activité " + self.name) - Thread(daemon=True, target=refresh_activities).start() + # Consider that we can update the wiki iff the WIKI_PASSWORD env var is not empty + RefreshActivitiesCommand.refresh_human_readable_wiki_page("Modification de l'activité " + self.name, + False, os.getenv("WIKI_PASSWORD")) + RefreshActivitiesCommand.refresh_raw_wiki_page("Modification de l'activité " + self.name, + False, os.getenv("WIKI_PASSWORD")) + Thread(daemon=True, target=refresh_activities).start()\ + if settings.DATABASES["default"]["ENGINE"] == 'django.db.backends.postgresql' else refresh_activities() return ret def __str__(self): From 94706328ff583db0da2fd0d62352cd2ae0966351 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Sat, 5 Sep 2020 00:47:55 +0200 Subject: [PATCH 29/95] Tests can run between 12pm and 2am --- apps/member/tests/test_memberships.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/member/tests/test_memberships.py b/apps/member/tests/test_memberships.py index ef8b8209..90b1f382 100644 --- a/apps/member/tests/test_memberships.py +++ b/apps/member/tests/test_memberships.py @@ -197,7 +197,7 @@ class TestMemberships(TestCase): # Create a new membership response = self.client.post(reverse("member:club_add_member", args=(club.pk,)), data=dict( user=user.pk, - date_start="{:%Y-%m-%d}".format(timezone.now().date()), + date_start="{:%Y-%m-%d}".format(date.today()), soge=False, credit_type=NoteSpecial.objects.get(special_type="Espèces").id, credit_amount=4200, @@ -236,7 +236,7 @@ class TestMemberships(TestCase): # Renew membership response = self.client.post(reverse("member:club_renew_membership", args=(membership.pk,)), data=dict( user=user.pk, - date_start="{:%Y-%m-%d}".format(timezone.now().date()), + date_start="{:%Y-%m-%d}".format(date.today()), soge=bde_parent, credit_type=NoteSpecial.objects.get(special_type="Chèque").id, credit_amount=14242, @@ -265,7 +265,7 @@ class TestMemberships(TestCase): response = self.client.post(reverse("member:club_add_member", args=(bde.pk,)), data=dict( user=user.pk, - date_start="{:%Y-%m-%d}".format(timezone.now().date()), + date_start="{:%Y-%m-%d}".format(date.today()), soge=True, credit_type=NoteSpecial.objects.get(special_type="Virement bancaire").id, credit_amount=(bde.membership_fee_paid + kfet.membership_fee_paid) / 100, From a97a36bc9e035b12aa06fbedfbaaf6b0ab3739f4 Mon Sep 17 00:00:00 2001 From: Alexandre Iooss Date: Sat, 5 Sep 2020 08:17:27 +0200 Subject: [PATCH 30/95] Add apps/script submodule to CI --- .gitlab-ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 80d85791..c5ec9610 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,6 +2,10 @@ stages: - test - quality-assurance +# Also fetch submodules +variables: + GIT_SUBMODULE_STRATEGY: recursive + # Debian Buster py37-django22: stage: test From bad5fe3c22ffb302431b09a3f7e02e7130a27347 Mon Sep 17 00:00:00 2001 From: Alexandre Iooss Date: Sat, 5 Sep 2020 08:30:41 +0200 Subject: [PATCH 31/95] Format JS files --- apps/member/static/member/js/alias.js | 56 +- note_kfet/static/js/autocomplete_model.js | 90 ++- note_kfet/static/js/base.js | 519 +++++++------- note_kfet/static/js/consos.js | 408 ++++++----- note_kfet/static/js/dynamic-formset.js | 447 ++++++------ note_kfet/static/js/konami.js | 62 +- note_kfet/static/js/transfer.js | 822 +++++++++++----------- 7 files changed, 1175 insertions(+), 1229 deletions(-) diff --git a/apps/member/static/member/js/alias.js b/apps/member/static/member/js/alias.js index 2d652dde..444e1bcf 100644 --- a/apps/member/static/member/js/alias.js +++ b/apps/member/static/member/js/alias.js @@ -2,22 +2,22 @@ * On form submit, create a new alias */ function create_alias (e) { - // Do not submit HTML form - e.preventDefault(); + // Do not submit HTML form + e.preventDefault() - // Get data and send to API - const formData = new FormData(e.target); - $.post("/api/note/alias/", { - "csrfmiddlewaretoken": formData.get("csrfmiddlewaretoken"), - "name": formData.get("name"), - "note": formData.get("note") - }).done(function () { - // Reload table - $("#alias_table").load(location.pathname + " #alias_table"); - addMsg("Alias ajouté", "success"); - }).fail(function (xhr, _textStatus, _error) { - errMsg(xhr.responseJSON); - }); + // Get data and send to API + const formData = new FormData(e.target) + $.post('/api/note/alias/', { + csrfmiddlewaretoken: formData.get('csrfmiddlewaretoken'), + name: formData.get('name'), + note: formData.get('note') + }).done(function () { + // Reload table + $('#alias_table').load(location.pathname + ' #alias_table') + addMsg('Alias ajouté', 'success') + }).fail(function (xhr, _textStatus, _error) { + errMsg(xhr.responseJSON) + }) } /** @@ -25,19 +25,19 @@ function create_alias (e) { * @param Integer button_id Alias id to remove */ function delete_button (button_id) { - $.ajax({ - url: "/api/note/alias/" + button_id + "/", - method: "DELETE", - headers: { "X-CSRFTOKEN": CSRF_TOKEN } - }).done(function () { - addMsg('Alias supprimé', 'success'); - $("#alias_table").load(location.pathname + " #alias_table"); - }).fail(function (xhr, _textStatus, _error) { - errMsg(xhr.responseJSON); - }); + $.ajax({ + url: '/api/note/alias/' + button_id + '/', + method: 'DELETE', + headers: { 'X-CSRFTOKEN': CSRF_TOKEN } + }).done(function () { + addMsg('Alias supprimé', 'success') + $('#alias_table').load(location.pathname + ' #alias_table') + }).fail(function (xhr, _textStatus, _error) { + errMsg(xhr.responseJSON) + }) } $(document).ready(function () { - // Attach event - document.getElementById("form_alias").addEventListener("submit", create_alias); -}) \ No newline at end of file + // Attach event + document.getElementById('form_alias').addEventListener('submit', create_alias) +}) diff --git a/note_kfet/static/js/autocomplete_model.js b/note_kfet/static/js/autocomplete_model.js index c4f9405b..f7aafbc6 100644 --- a/note_kfet/static/js/autocomplete_model.js +++ b/note_kfet/static/js/autocomplete_model.js @@ -1,57 +1,53 @@ $(document).ready(function () { - $(".autocomplete").keyup(function(e) { - let target = $("#" + e.target.id); - let prefix = target.attr("id"); - let api_url = target.attr("api_url"); - let api_url_suffix = target.attr("api_url_suffix"); - if (!api_url_suffix) - api_url_suffix = ""; - let name_field = target.attr("name_field"); - if (!name_field) - name_field = "name"; - let input = target.val(); - target.addClass("is-invalid"); - target.removeClass("is-valid"); - $("#" + prefix + "_reset").removeClass("d-none"); + $('.autocomplete').keyup(function (e) { + const target = $('#' + e.target.id) + const prefix = target.attr('id') + const api_url = target.attr('api_url') + let api_url_suffix = target.attr('api_url_suffix') + if (!api_url_suffix) { api_url_suffix = '' } + let name_field = target.attr('name_field') + if (!name_field) { name_field = 'name' } + const input = target.val() + target.addClass('is-invalid') + target.removeClass('is-valid') + $('#' + prefix + '_reset').removeClass('d-none') - $.getJSON(api_url + (api_url.includes("?") ? "&" : "?") + "format=json&search=^" + input + api_url_suffix, function(objects) { - let html = ""; + $.getJSON(api_url + (api_url.includes('?') ? '&' : '?') + 'format=json&search=^' + input + api_url_suffix, function (objects) { + let html = '' - objects.results.forEach(function (obj) { - html += li(prefix + "_" + obj.id, obj[name_field]); - }); + objects.results.forEach(function (obj) { + html += li(prefix + '_' + obj.id, obj[name_field]) + }) - let results_list = $("#" + prefix + "_list"); - results_list.html(html); + const results_list = $('#' + prefix + '_list') + results_list.html(html) - objects.results.forEach(function (obj) { - $("#" + prefix + "_" + obj.id).click(function() { - target.val(obj[name_field]); - $("#" + prefix + "_pk").val(obj.id); + objects.results.forEach(function (obj) { + $('#' + prefix + '_' + obj.id).click(function () { + target.val(obj[name_field]) + $('#' + prefix + '_pk').val(obj.id) - results_list.html(""); - target.removeClass("is-invalid"); - target.addClass("is-valid"); + results_list.html('') + target.removeClass('is-invalid') + target.addClass('is-valid') - if (typeof autocompleted != 'undefined') - autocompleted(obj, prefix) - }); + if (typeof autocompleted !== 'undefined') { autocompleted(obj, prefix) } + }) - if (input === obj[name_field]) - $("#" + prefix + "_pk").val(obj.id); - }); + if (input === obj[name_field]) { $('#' + prefix + '_pk').val(obj.id) } + }) - if (results_list.children().length === 1 && e.originalEvent.keyCode >= 32) { - results_list.children().first().trigger("click"); - } - }); - }); + if (results_list.children().length === 1 && e.originalEvent.keyCode >= 32) { + results_list.children().first().trigger('click') + } + }) + }) - $(".autocomplete-reset").click(function() { - let name = $(this).attr("id").replace("_reset", ""); - $("#" + name + "_pk").val(""); - $("#" + name).val(""); - $("#" + name + "_list").html(""); - $(this).addClass("d-none"); - }); -}); \ No newline at end of file + $('.autocomplete-reset').click(function () { + const name = $(this).attr('id').replace('_reset', '') + $('#' + name + '_pk').val('') + $('#' + name).val('') + $('#' + name + '_list').html('') + $(this).addClass('d-none') + }) +}) diff --git a/note_kfet/static/js/base.js b/note_kfet/static/js/base.js index 8315f01a..87a99f64 100644 --- a/note_kfet/static/js/base.js +++ b/note_kfet/static/js/base.js @@ -1,18 +1,16 @@ // Copyright (C) 2018-2020 by BDE ENS Paris-Saclay // SPDX-License-Identifier: GPL-3.0-or-later - /** * Convert balance in cents to a human readable amount * @param value the balance, in cents * @returns {string} */ function pretty_money (value) { - if (value % 100 === 0) - return (value < 0 ? "- " : "") + Math.floor(Math.abs(value) / 100) + " €"; - else - return (value < 0 ? "- " : "") + Math.floor(Math.abs(value) / 100) + "." - + (Math.abs(value) % 100 < 10 ? "0" : "") + (Math.abs(value) % 100) + " €"; + if (value % 100 === 0) { return (value < 0 ? '- ' : '') + Math.floor(Math.abs(value) / 100) + ' €' } else { + return (value < 0 ? '- ' : '') + Math.floor(Math.abs(value) / 100) + '.' + + (Math.abs(value) % 100 < 10 ? '0' : '') + (Math.abs(value) % 100) + ' €' + } } /** @@ -22,19 +20,19 @@ function pretty_money (value) { * @param timeout The delay (in millis) after that the message is auto-closed. If negative, then it is ignored. */ function addMsg (msg, alert_type, timeout = -1) { - let msgDiv = $("#messages"); - let html = msgDiv.html(); - let id = Math.floor(10000 * Math.random() + 1); - html += "
" + - "" - + msg + "
\n"; - msgDiv.html(html); + const msgDiv = $('#messages') + let html = msgDiv.html() + const id = Math.floor(10000 * Math.random() + 1) + html += '
' + + '' + + msg + '
\n' + msgDiv.html(html) - if (timeout > 0) { - setTimeout(function () { - $("#close-message-" + id).click(); - }, timeout); - } + if (timeout > 0) { + setTimeout(function () { + $('#close-message-' + id).click() + }, timeout) + } } /** @@ -43,34 +41,34 @@ function addMsg (msg, alert_type, timeout = -1) { * @param timeout The delay (in millis) after that the message is auto-closed. If negative, then it is ignored. */ function errMsg (errs_obj, timeout = -1) { - for (const err_msg of Object.values(errs_obj)) { - addMsg(err_msg, 'danger', timeout); - } + for (const err_msg of Object.values(errs_obj)) { + addMsg(err_msg, 'danger', timeout) + } } var reloadWithTurbolinks = (function () { - var scrollPosition; + var scrollPosition - function reload () { - scrollPosition = [window.scrollX, window.scrollY]; - Turbolinks.visit(window.location.toString(), { action: 'replace' }) + function reload () { + scrollPosition = [window.scrollX, window.scrollY] + Turbolinks.visit(window.location.toString(), { action: 'replace' }) + } + + document.addEventListener('turbolinks:load', function () { + if (scrollPosition) { + window.scrollTo.apply(window, scrollPosition) + scrollPosition = null } + }) - document.addEventListener('turbolinks:load', function () { - if (scrollPosition) { - window.scrollTo.apply(window, scrollPosition); - scrollPosition = null - } - }); - - return reload; -})(); + return reload +})() /** * Reload the balance of the user on the right top corner */ function refreshBalance () { - $("#user_balance").load("/ #user_balance"); + $('#user_balance').load('/ #user_balance') } /** @@ -79,15 +77,15 @@ function refreshBalance () { * @param fun For each found note with the matched alias `alias`, fun(note, alias) is called. */ function getMatchedNotes (pattern, fun) { - $.getJSON("/api/note/alias/?format=json&alias=" + pattern + "&search=user|club", fun); + $.getJSON('/api/note/alias/?format=json&alias=' + pattern + '&search=user|club', fun) } /** * Generate a
  • entry with a given id and text */ function li (id, text, extra_css) { - return "
  • " + text + "
  • \n"; + return '
  • ' + text + '
  • \n' } /** @@ -95,24 +93,13 @@ function li (id, text, extra_css) { * @param note The concerned note. */ function displayStyle (note) { - if (!note) - return ""; - let balance = note.balance; - var css = ""; - if (balance < -5000) - css += " text-danger bg-dark"; - else if (balance < -1000) - css += " text-danger"; - else if (balance < 0) - css += " text-warning"; - else if (!note.email_confirmed) - css += " text-white bg-primary"; - else if (!note.is_active || (note.membership && note.membership.date_end < new Date().toISOString())) - css += "text-white bg-info"; - return css; + if (!note) { return '' } + const balance = note.balance + var css = '' + if (balance < -5000) { css += ' text-danger bg-dark' } else if (balance < -1000) { css += ' text-danger' } else if (balance < 0) { css += ' text-warning' } else if (!note.email_confirmed) { css += ' text-white bg-primary' } else if (!note.is_active || (note.membership && note.membership.date_end < new Date().toISOString())) { css += 'text-white bg-info' } + return css } - /** * Render note name and picture * @param note The note to render @@ -121,23 +108,22 @@ function displayStyle (note) { * @param profile_pic_field */ function displayNote (note, alias, user_note_field = null, profile_pic_field = null) { - if (!note.display_image) { - note.display_image = '/static/member/img/default_picture.png'; - } - let img = note.display_image; - if (alias !== note.name && note.name) - alias += " (aka. " + note.name + ")"; - if (user_note_field !== null) { - $("#" + user_note_field).removeAttr('class'); - $("#" + user_note_field).addClass(displayStyle(note)); - $("#" + user_note_field).text(alias + (note.balance == null ? "" : (" :\n" + pretty_money(note.balance)))); - if (profile_pic_field != null) { - $("#" + profile_pic_field).attr('src', img); - $("#" + profile_pic_field + "_link").attr('href', note.resourcetype === "NoteUser" ? - "/accounts/user/" + note.user : note.resourcetype === "NoteClub" ? - "/accounts/club/" + note.club : "#"); - } + if (!note.display_image) { + note.display_image = '/static/member/img/default_picture.png' + } + const img = note.display_image + if (alias !== note.name && note.name) { alias += ' (aka. ' + note.name + ')' } + if (user_note_field !== null) { + $('#' + user_note_field).removeAttr('class') + $('#' + user_note_field).addClass(displayStyle(note)) + $('#' + user_note_field).text(alias + (note.balance == null ? '' : (' :\n' + pretty_money(note.balance)))) + if (profile_pic_field != null) { + $('#' + profile_pic_field).attr('src', img) + $('#' + profile_pic_field + '_link').attr('href', note.resourcetype === 'NoteUser' + ? '/accounts/user/' + note.user : note.resourcetype === 'NoteClub' + ? '/accounts/club/' + note.club : '#') } + } } /** @@ -152,35 +138,34 @@ function displayNote (note, alias, user_note_field = null, profile_pic_field = n * (useful in consumptions, put null if not used) * @returns an anonymous function to be compatible with jQuery events */ -function removeNote (d, note_prefix = "note", notes_display, note_list_id, user_note_field = null, profile_pic_field = null) { - return (function () { - let new_notes_display = []; - let html = ""; - notes_display.forEach(function (disp) { - if (disp.quantity > 1 || disp.id !== d.id) { - disp.quantity -= disp.id === d.id ? 1 : 0; - new_notes_display.push(disp); - html += li(note_prefix + "_" + disp.id, disp.name - + "" + disp.quantity + "", - displayStyle(disp.note)); - } - }); +function removeNote (d, note_prefix = 'note', notes_display, note_list_id, user_note_field = null, profile_pic_field = null) { + return function () { + const new_notes_display = [] + let html = '' + notes_display.forEach(function (disp) { + if (disp.quantity > 1 || disp.id !== d.id) { + disp.quantity -= disp.id === d.id ? 1 : 0 + new_notes_display.push(disp) + html += li(note_prefix + '_' + disp.id, disp.name + + '' + disp.quantity + '', + displayStyle(disp.note)) + } + }) - notes_display.length = 0; - new_notes_display.forEach(function (disp) { - notes_display.push(disp); - }); + notes_display.length = 0 + new_notes_display.forEach(function (disp) { + notes_display.push(disp) + }) - $("#" + note_list_id).html(html); - notes_display.forEach(function (disp) { - let obj = $("#" + note_prefix + "_" + disp.id); - obj.click(removeNote(disp, note_prefix, notes_display, note_list_id, user_note_field, profile_pic_field)); - obj.hover(function () { - if (disp.note) - displayNote(disp.note, disp.name, user_note_field, profile_pic_field); - }); - }); - }); + $('#' + note_list_id).html(html) + notes_display.forEach(function (disp) { + const obj = $('#' + note_prefix + '_' + disp.id) + obj.click(removeNote(disp, note_prefix, notes_display, note_list_id, user_note_field, profile_pic_field)) + obj.hover(function () { + if (disp.note) { displayNote(disp.note, disp.name, user_note_field, profile_pic_field) } + }) + }) + } } /** @@ -199,203 +184,193 @@ function removeNote (d, note_prefix = "note", notes_display, note_list_id, user_ * the associated note is not displayed. * Useful for a consumption if the item is selected before. */ -function autoCompleteNote (field_id, note_list_id, notes, notes_display, alias_prefix = "alias", - note_prefix = "note", user_note_field = null, profile_pic_field = null, alias_click = null) { - let field = $("#" + field_id); +function autoCompleteNote (field_id, note_list_id, notes, notes_display, alias_prefix = 'alias', + note_prefix = 'note', user_note_field = null, profile_pic_field = null, alias_click = null) { + const field = $('#' + field_id) - // Configure tooltip - field.tooltip({ - html: true, - placement: 'bottom', - title: 'Loading...', - trigger: 'manual', - container: field.parent(), - fallbackPlacement: 'clockwise' - }); + // Configure tooltip + field.tooltip({ + html: true, + placement: 'bottom', + title: 'Loading...', + trigger: 'manual', + container: field.parent(), + fallbackPlacement: 'clockwise' + }) - // When the user clicks elsewhere, we hide the tooltip - $(document).click(function(e) { - if (!e.target.id.startsWith(alias_prefix)) { - field.tooltip("hide"); - } - }); + // When the user clicks elsewhere, we hide the tooltip + $(document).click(function (e) { + if (!e.target.id.startsWith(alias_prefix)) { + field.tooltip('hide') + } + }) - let old_pattern = null; + let old_pattern = null - // Clear search on click - field.click(function () { - field.tooltip('hide'); - field.removeClass('is-invalid'); - field.val(""); - old_pattern = ""; - }); + // Clear search on click + field.click(function () { + field.tooltip('hide') + field.removeClass('is-invalid') + field.val('') + old_pattern = '' + }) - // When the user type "Enter", the first alias is clicked - field.keypress(function (event) { - if (event.originalEvent.charCode === 13 && notes.length > 0) { - let li_obj = field.parent().find("ul li").first(); - displayNote(notes[0], li_obj.text(), user_note_field, profile_pic_field); - li_obj.trigger("click"); - } - }); + // When the user type "Enter", the first alias is clicked + field.keypress(function (event) { + if (event.originalEvent.charCode === 13 && notes.length > 0) { + const li_obj = field.parent().find('ul li').first() + displayNote(notes[0], li_obj.text(), user_note_field, profile_pic_field) + li_obj.trigger('click') + } + }) - // When the user type something, the matched aliases are refreshed - field.keyup(function (e) { - field.removeClass('is-invalid'); + // When the user type something, the matched aliases are refreshed + field.keyup(function (e) { + field.removeClass('is-invalid') - if (e.originalEvent.charCode === 13) - return; + if (e.originalEvent.charCode === 13) { return } - let pattern = field.val(); + const pattern = field.val() - // If the pattern is not modified, we don't query the API - if (pattern === old_pattern) - return; - old_pattern = pattern; - notes.length = 0; + // If the pattern is not modified, we don't query the API + if (pattern === old_pattern) { return } + old_pattern = pattern + notes.length = 0 - // get matched Alias with note associated - if (pattern === "") { - field.tooltip('hide'); - notes.length = 0; - return; - } + // get matched Alias with note associated + if (pattern === '') { + field.tooltip('hide') + notes.length = 0 + return + } - $.getJSON("/api/note/consumer/?format=json&alias=" + pattern + "&search=user|club", - function (consumers) { - // The response arrived too late, we stop the request - if (pattern !== $("#" + field_id).val()) - return; + $.getJSON('/api/note/consumer/?format=json&alias=' + pattern + '&search=user|club', + function (consumers) { + // The response arrived too late, we stop the request + if (pattern !== $('#' + field_id).val()) { return } - // Build tooltip content - let aliases_matched_html = '
      '; - consumers.results.forEach(function (consumer) { - let note = consumer.note; - note.email_confirmed = consumer.email_confirmed; - if (consumer.hasOwnProperty("membership") && consumer.membership) - note.membership = consumer.membership; - else - note.membership = undefined; - let extra_css = displayStyle(note); - aliases_matched_html += li(alias_prefix + '_' + consumer.id, - consumer.name, - extra_css); - notes.push(note); - }); - aliases_matched_html += '
    '; + // Build tooltip content + let aliases_matched_html = '
      ' + consumers.results.forEach(function (consumer) { + const note = consumer.note + note.email_confirmed = consumer.email_confirmed + if (consumer.hasOwnProperty('membership') && consumer.membership) { note.membership = consumer.membership } else { note.membership = undefined } + const extra_css = displayStyle(note) + aliases_matched_html += li(alias_prefix + '_' + consumer.id, + consumer.name, + extra_css) + notes.push(note) + }) + aliases_matched_html += '
    ' - // Show tooltip - field.attr('data-original-title', aliases_matched_html).tooltip('show'); + // Show tooltip + field.attr('data-original-title', aliases_matched_html).tooltip('show') - consumers.results.forEach(function (consumer) { - let consumer_obj = $("#" + alias_prefix + "_" + consumer.id); - consumer_obj.hover(function () { - displayNote(consumer.note, consumer.name, user_note_field, profile_pic_field) - }); - consumer_obj.click(function () { - var disp = null; - notes_display.forEach(function (d) { - // We compare the alias ids - if (d.id === consumer.id) { - d.quantity += 1; - disp = d; - } - }); - // In the other case, we add a new emitter - if (disp == null) { - disp = { - name: consumer.name, - id: consumer.id, - note: consumer.note, - quantity: 1 - }; - notes_display.push(disp); - } + consumers.results.forEach(function (consumer) { + const consumer_obj = $('#' + alias_prefix + '_' + consumer.id) + consumer_obj.hover(function () { + displayNote(consumer.note, consumer.name, user_note_field, profile_pic_field) + }) + consumer_obj.click(function () { + var disp = null + notes_display.forEach(function (d) { + // We compare the alias ids + if (d.id === consumer.id) { + d.quantity += 1 + disp = d + } + }) + // In the other case, we add a new emitter + if (disp == null) { + disp = { + name: consumer.name, + id: consumer.id, + note: consumer.note, + quantity: 1 + } + notes_display.push(disp) + } - // If the function alias_click exists, it is called. If it doesn't return true, then the notes are - // note displayed. Useful for a consumption when a button is already clicked - if (alias_click && !alias_click()) - return; + // If the function alias_click exists, it is called. If it doesn't return true, then the notes are + // note displayed. Useful for a consumption when a button is already clicked + if (alias_click && !alias_click()) { return } - let note_list = $("#" + note_list_id); - let html = ""; - notes_display.forEach(function (disp) { - html += li(note_prefix + "_" + disp.id, - disp.name - + "" - + disp.quantity + "", - displayStyle(disp.note)); - }); + const note_list = $('#' + note_list_id) + let html = '' + notes_display.forEach(function (disp) { + html += li(note_prefix + '_' + disp.id, + disp.name + + '' + + disp.quantity + '', + displayStyle(disp.note)) + }) - // Emitters are displayed - note_list.html(html); + // Emitters are displayed + note_list.html(html) - // Update tooltip position - field.tooltip('update'); + // Update tooltip position + field.tooltip('update') - notes_display.forEach(function (disp) { - let line_obj = $("#" + note_prefix + "_" + disp.id); - // Hover an emitter display also the profile picture - line_obj.hover(function () { - displayNote(disp.note, disp.name, user_note_field, profile_pic_field); - }); + notes_display.forEach(function (disp) { + const line_obj = $('#' + note_prefix + '_' + disp.id) + // Hover an emitter display also the profile picture + line_obj.hover(function () { + displayNote(disp.note, disp.name, user_note_field, profile_pic_field) + }) - // When an emitter is clicked, it is removed - line_obj.click(removeNote(disp, note_prefix, notes_display, note_list_id, user_note_field, - profile_pic_field)); - }); - }) - }); - - });// end getJSON alias - }); + // When an emitter is clicked, it is removed + line_obj.click(removeNote(disp, note_prefix, notes_display, note_list_id, user_note_field, + profile_pic_field)) + }) + }) + }) + })// end getJSON alias + }) }// end function autocomplete - // When a validate button is clicked, we switch the validation status function de_validate (id, validated, resourcetype) { - let validate_obj = $("#validate_" + id); + const validate_obj = $('#validate_' + id) - if (validate_obj.data("pending")) - // The button is already clicked - return; + if (validate_obj.data('pending')) + // The button is already clicked + { return } - let invalidity_reason = $("#invalidity_reason_" + id).val(); - validate_obj.html(""); - validate_obj.data("pending", true); + const invalidity_reason = $('#invalidity_reason_' + id).val() + validate_obj.html('') + validate_obj.data('pending', true) - // Perform a PATCH request to the API in order to update the transaction - // If the user has insufficient rights, an error message will appear - $.ajax({ - "url": "/api/note/transaction/transaction/" + id + "/", - type: "PATCH", - dataType: "json", - headers: { - "X-CSRFTOKEN": CSRF_TOKEN - }, - data: { - "resourcetype": resourcetype, - "valid": !validated, - "invalidity_reason": invalidity_reason, - }, - success: function () { - refreshBalance(); - // error if this method doesn't exist. Please define it. - refreshHistory(); - }, - error: function (err) { - let errObj = JSON.parse(err.responseText); - let error = errObj["detail"] ? errObj["detail"] : errObj["non_field_errors"]; - if (!error) - error = err.responseText; - addMsg("Une erreur est survenue lors de la validation/dévalidation " + - "de cette transaction : " + error, "danger"); + // Perform a PATCH request to the API in order to update the transaction + // If the user has insufficient rights, an error message will appear + $.ajax({ + url: '/api/note/transaction/transaction/' + id + '/', + type: 'PATCH', + dataType: 'json', + headers: { + 'X-CSRFTOKEN': CSRF_TOKEN + }, + data: { + resourcetype: resourcetype, + valid: !validated, + invalidity_reason: invalidity_reason + }, + success: function () { + refreshBalance() + // error if this method doesn't exist. Please define it. + refreshHistory() + }, + error: function (err) { + const errObj = JSON.parse(err.responseText) + let error = errObj.detail ? errObj.detail : errObj.non_field_errors + if (!error) { error = err.responseText } + addMsg('Une erreur est survenue lors de la validation/dévalidation ' + + 'de cette transaction : ' + error, 'danger') - refreshBalance(); - // error if this method doesn't exist. Please define it. - refreshHistory(); - } - }); + refreshBalance() + // error if this method doesn't exist. Please define it. + refreshHistory() + } + }) } /** @@ -404,10 +379,10 @@ function de_validate (id, validated, resourcetype) { * @param wait Debounced milliseconds */ function debounce (callback, wait) { - let timeout; - return (...args) => { - const context = this; - clearTimeout(timeout); - timeout = setTimeout(() => callback.apply(context, args), wait); - }; + let timeout + return (...args) => { + const context = this + clearTimeout(timeout) + timeout = setTimeout(() => callback.apply(context, args), wait) + } } diff --git a/note_kfet/static/js/consos.js b/note_kfet/static/js/consos.js index 25e8113e..5a7e6144 100644 --- a/note_kfet/static/js/consos.js +++ b/note_kfet/static/js/consos.js @@ -2,95 +2,92 @@ // SPDX-License-Identifier: GPL-3.0-or-later // When a transaction is performed, lock the interface to prevent spam clicks. -var LOCK = false; +var LOCK = false /** * Refresh the history table on the consumptions page. */ -function refreshHistory() { - $("#history").load("/note/consos/ #history"); - $("#most_used").load("/note/consos/ #most_used"); +function refreshHistory () { + $('#history').load('/note/consos/ #history') + $('#most_used').load('/note/consos/ #most_used') } -$(document).ready(function() { - // If hash of a category in the URL, then select this category - // else select the first one - if (location.hash) { - $("a[href='" + location.hash + "']").tab("show"); - } else { - $("a[data-toggle='tab']").first().tab("show"); +$(document).ready(function () { + // If hash of a category in the URL, then select this category + // else select the first one + if (location.hash) { + $("a[href='" + location.hash + "']").tab('show') + } else { + $("a[data-toggle='tab']").first().tab('show') + } + + // When selecting a category, change URL + $(document.body).on('click', "a[data-toggle='tab']", function () { + location.hash = this.getAttribute('href') + }) + + // Switching in double consumptions mode should update the layout + $('#double_conso').change(function () { + $('#consos_list_div').removeClass('d-none') + $('#user_select_div').attr('class', 'col-xl-4') + $('#infos_div').attr('class', 'col-sm-5 col-xl-6') + + const note_list_obj = $('#note_list') + if (buttons.length > 0 && note_list_obj.text().length > 0) { + $('#consos_list').html(note_list_obj.html()) + note_list_obj.html('') + + buttons.forEach(function (button) { + $('#conso_button_' + button.id).click(function () { + if (LOCK) { return } + removeNote(button, 'conso_button', buttons, 'consos_list')() + }) + }) } + }) - // When selecting a category, change URL - $(document.body).on("click", "a[data-toggle='tab']", function() { - location.hash = this.getAttribute("href"); - }); + $('#single_conso').change(function () { + $('#consos_list_div').addClass('d-none') + $('#user_select_div').attr('class', 'col-xl-7') + $('#infos_div').attr('class', 'col-sm-5 col-md-4') - // Switching in double consumptions mode should update the layout - $("#double_conso").change(function() { - $("#consos_list_div").removeClass('d-none'); - $("#user_select_div").attr('class', 'col-xl-4'); - $("#infos_div").attr('class', 'col-sm-5 col-xl-6'); + const consos_list_obj = $('#consos_list') + if (buttons.length > 0) { + if (notes_display.length === 0 && consos_list_obj.text().length > 0) { + $('#note_list').html(consos_list_obj.html()) + consos_list_obj.html('') + buttons.forEach(function (button) { + $('#conso_button_' + button.id).click(function () { + if (LOCK) { return } + removeNote(button, 'conso_button', buttons, 'note_list')() + }) + }) + } else { + buttons.length = 0 + consos_list_obj.html('') + } + } + }) - let note_list_obj = $("#note_list"); - if (buttons.length > 0 && note_list_obj.text().length > 0) { - $("#consos_list").html(note_list_obj.html()); - note_list_obj.html(""); + // Ensure we begin in single consumption. Fix issue with TurboLinks and BootstrapJS + $("label[for='double_conso']").removeClass('active') - buttons.forEach(function(button) { - $("#conso_button_" + button.id).click(function() { - if (LOCK) - return; - removeNote(button, "conso_button", buttons,"consos_list")(); - }); - }); - } - }); + $('#consume_all').click(consumeAll) +}) - $("#single_conso").change(function() { - $("#consos_list_div").addClass('d-none'); - $("#user_select_div").attr('class', 'col-xl-7'); - $("#infos_div").attr('class', 'col-sm-5 col-md-4'); - - let consos_list_obj = $("#consos_list"); - if (buttons.length > 0) { - if (notes_display.length === 0 && consos_list_obj.text().length > 0) { - $("#note_list").html(consos_list_obj.html()); - consos_list_obj.html(""); - buttons.forEach(function(button) { - $("#conso_button_" + button.id).click(function() { - if (LOCK) - return; - removeNote(button, "conso_button", buttons,"note_list")(); - }); - }); - } - else { - buttons.length = 0; - consos_list_obj.html(""); - } - } - }); - - // Ensure we begin in single consumption. Fix issue with TurboLinks and BootstrapJS - $("label[for='double_conso']").removeClass('active'); - - $("#consume_all").click(consumeAll); -}); - -notes = []; -notes_display = []; -buttons = []; +notes = [] +notes_display = [] +buttons = [] // When the user searches an alias, we update the auto-completion -autoCompleteNote("note", "note_list", notes, notes_display, - "alias", "note", "user_note", "profile_pic", function() { - if (buttons.length > 0 && $("#single_conso").is(":checked")) { - consumeAll(); - return false; - } - return true; - }); +autoCompleteNote('note', 'note_list', notes, notes_display, + 'alias', 'note', 'user_note', 'profile_pic', function () { + if (buttons.length > 0 && $('#single_conso').is(':checked')) { + consumeAll() + return false + } + return true + }) /** * Add a transaction from a button. @@ -102,103 +99,98 @@ autoCompleteNote("note", "note_list", notes, notes_display, * @param template_id The identifier of the button * @param template_name The name of the button */ -function addConso(dest, amount, type, category_id, category_name, template_id, template_name) { - var button = null; - buttons.forEach(function(b) { - if (b.id === template_id) { - b.quantity += 1; - button = b; - } - }); - if (button == null) { - button = { - id: template_id, - name: template_name, - dest: dest, - quantity: 1, - amount: amount, - type: type, - category_id: category_id, - category_name: category_name - }; - buttons.push(button); +function addConso (dest, amount, type, category_id, category_name, template_id, template_name) { + var button = null + buttons.forEach(function (b) { + if (b.id === template_id) { + b.quantity += 1 + button = b } - - let dc_obj = $("#double_conso"); - if (dc_obj.is(":checked") || notes_display.length === 0) { - let list = dc_obj.is(":checked") ? "consos_list" : "note_list"; - let html = ""; - buttons.forEach(function(button) { - html += li("conso_button_" + button.id, button.name - + "" + button.quantity + ""); - }); - - $("#" + list).html(html); - - buttons.forEach(function(button) { - $("#conso_button_" + button.id).click(function() { - if (LOCK) - return; - removeNote(button, "conso_button", buttons, list)(); - }); - }); + }) + if (button == null) { + button = { + id: template_id, + name: template_name, + dest: dest, + quantity: 1, + amount: amount, + type: type, + category_id: category_id, + category_name: category_name } - else - consumeAll(); + buttons.push(button) + } + + const dc_obj = $('#double_conso') + if (dc_obj.is(':checked') || notes_display.length === 0) { + const list = dc_obj.is(':checked') ? 'consos_list' : 'note_list' + let html = '' + buttons.forEach(function (button) { + html += li('conso_button_' + button.id, button.name + + '' + button.quantity + '') + }) + + $('#' + list).html(html) + + buttons.forEach(function (button) { + $('#conso_button_' + button.id).click(function () { + if (LOCK) { return } + removeNote(button, 'conso_button', buttons, list)() + }) + }) + } else { consumeAll() } } /** * Reset the page as its initial state. */ -function reset() { - notes_display.length = 0; - notes.length = 0; - buttons.length = 0; - $("#note_list").html(""); - $("#consos_list").html(""); - $("#note").val(""); - $("#note").attr("data-original-title", "").tooltip("hide"); - $("#profile_pic").attr("src", "/static/member/img/default_picture.png"); - $("#profile_pic_link").attr("href", "#"); - refreshHistory(); - refreshBalance(); - LOCK = false; +function reset () { + notes_display.length = 0 + notes.length = 0 + buttons.length = 0 + $('#note_list').html('') + $('#consos_list').html('') + $('#note').val('') + $('#note').attr('data-original-title', '').tooltip('hide') + $('#profile_pic').attr('src', '/static/member/img/default_picture.png') + $('#profile_pic_link').attr('href', '#') + refreshHistory() + refreshBalance() + LOCK = false } - /** * Apply all transactions: all notes in `notes` buy each item in `buttons` */ -function consumeAll() { - if (LOCK) - return; +function consumeAll () { + if (LOCK) { return } - LOCK = true; + LOCK = true - let error = false; + let error = false - if (notes_display.length === 0) { - $("#note").addClass('is-invalid'); - $("#note_list").html(li("", "Ajoutez des émetteurs.", "text-danger")); - error = true; - } + if (notes_display.length === 0) { + $('#note').addClass('is-invalid') + $('#note_list').html(li('', 'Ajoutez des émetteurs.', 'text-danger')) + error = true + } - if (buttons.length === 0) { - $("#consos_list").html(li("", "Ajoutez des consommations.", "text-danger")); - error = true; - } + if (buttons.length === 0) { + $('#consos_list').html(li('', 'Ajoutez des consommations.', 'text-danger')) + error = true + } - if (error) { - LOCK = false; - return; - } + if (error) { + LOCK = false + return + } - notes_display.forEach(function(note_display) { - buttons.forEach(function(button) { - consume(note_display.note, note_display.name, button.dest, button.quantity * note_display.quantity, button.amount, - button.name + " (" + button.category_name + ")", button.type, button.category_id, button.id); - }); - }); + notes_display.forEach(function (note_display) { + buttons.forEach(function (button) { + consume(note_display.note, note_display.name, button.dest, button.quantity * note_display.quantity, button.amount, + button.name + ' (' + button.category_name + ')', button.type, button.category_id, button.id) + }) + }) } /** @@ -213,58 +205,60 @@ function consumeAll() { * @param category The category id of the button (type: int) * @param template The button id (type: int) */ -function consume(source, source_alias, dest, quantity, amount, reason, type, category, template) { - $.post("/api/note/transaction/transaction/", +function consume (source, source_alias, dest, quantity, amount, reason, type, category, template) { + $.post('/api/note/transaction/transaction/', + { + csrfmiddlewaretoken: CSRF_TOKEN, + quantity: quantity, + amount: amount, + reason: reason, + valid: true, + polymorphic_ctype: type, + resourcetype: 'RecurrentTransaction', + source: source.id, + source_alias: source_alias, + destination: dest, + template: template + }) + .done(function () { + if (!isNaN(source.balance)) { + const newBalance = source.balance - quantity * amount + if (newBalance <= -5000) { + addMsg('Attention, La transaction depuis la note ' + source_alias + ' a été réalisée avec ' + + 'succès, mais la note émettrice ' + source_alias + ' est en négatif sévère.', + 'danger', 30000) + } else if (newBalance < 0) { + addMsg('Attention, La transaction depuis la note ' + source_alias + ' a été réalisée avec ' + + 'succès, mais la note émettrice ' + source_alias + ' est en négatif.', + 'warning', 30000) + } + if (source.membership && source.membership.date_end < new Date().toISOString()) { + addMsg('Attention : la note émettrice ' + source.name + " n'est plus adhérente.", + 'danger', 30000) + } + } + reset() + }).fail(function (e) { + $.post('/api/note/transaction/transaction/', { - "csrfmiddlewaretoken": CSRF_TOKEN, - "quantity": quantity, - "amount": amount, - "reason": reason, - "valid": true, - "polymorphic_ctype": type, - "resourcetype": "RecurrentTransaction", - "source": source.id, - "source_alias": source_alias, - "destination": dest, - "template": template - }) - .done(function () { - if (!isNaN(source.balance)) { - let newBalance = source.balance - quantity * amount; - if (newBalance <= -5000) - addMsg("Attention, La transaction depuis la note " + source_alias + " a été réalisée avec " + - "succès, mais la note émettrice " + source_alias + " est en négatif sévère.", - "danger", 30000); - else if (newBalance < 0) - addMsg("Attention, La transaction depuis la note " + source_alias + " a été réalisée avec " + - "succès, mais la note émettrice " + source_alias + " est en négatif.", - "warning", 30000); - if (source.membership && source.membership.date_end < new Date().toISOString()) - addMsg("Attention : la note émettrice " + source.name + " n'est plus adhérente.", - "danger", 30000); - } - reset(); - }).fail(function (e) { - $.post("/api/note/transaction/transaction/", - { - "csrfmiddlewaretoken": CSRF_TOKEN, - "quantity": quantity, - "amount": amount, - "reason": reason, - "valid": false, - "invalidity_reason": "Solde insuffisant", - "polymorphic_ctype": type, - "resourcetype": "RecurrentTransaction", - "source": source, - "source_alias": source_alias, - "destination": dest, - "template": template - }).done(function() { - reset(); - addMsg("La transaction n'a pas pu être validée pour cause de solde insuffisant.", "danger", 10000); - }).fail(function () { - reset(); - errMsg(e.responseJSON); - }); - }); + csrfmiddlewaretoken: CSRF_TOKEN, + quantity: quantity, + amount: amount, + reason: reason, + valid: false, + invalidity_reason: 'Solde insuffisant', + polymorphic_ctype: type, + resourcetype: 'RecurrentTransaction', + source: source, + source_alias: source_alias, + destination: dest, + template: template + }).done(function () { + reset() + addMsg("La transaction n'a pas pu être validée pour cause de solde insuffisant.", 'danger', 10000) + }).fail(function () { + reset() + errMsg(e.responseJSON) + }) + }) } diff --git a/note_kfet/static/js/dynamic-formset.js b/note_kfet/static/js/dynamic-formset.js index c6ff3328..cb6151df 100644 --- a/note_kfet/static/js/dynamic-formset.js +++ b/note_kfet/static/js/dynamic-formset.js @@ -9,241 +9,240 @@ * Licensed under the New BSD License * See: http://www.opensource.org/licenses/bsd-license.php */ -;(function($) { - $.fn.formset = function(opts) - { - var options = $.extend({}, $.fn.formset.defaults, opts), - flatExtraClasses = options.extraClasses.join(' '), - totalForms = $('#id_' + options.prefix + '-TOTAL_FORMS'), - maxForms = $('#id_' + options.prefix + '-MAX_NUM_FORMS'), - minForms = $('#id_' + options.prefix + '-MIN_NUM_FORMS'), - childElementSelector = 'input,select,textarea,label,div', - $$ = $(this), +;(function ($) { + $.fn.formset = function (opts) { + var options = $.extend({}, $.fn.formset.defaults, opts) + var flatExtraClasses = options.extraClasses.join(' ') + var totalForms = $('#id_' + options.prefix + '-TOTAL_FORMS') + var maxForms = $('#id_' + options.prefix + '-MAX_NUM_FORMS') + var minForms = $('#id_' + options.prefix + '-MIN_NUM_FORMS') + var childElementSelector = 'input,select,textarea,label,div' + var $$ = $(this) - applyExtraClasses = function(row, ndx) { - if (options.extraClasses) { - row.removeClass(flatExtraClasses); - row.addClass(options.extraClasses[ndx % options.extraClasses.length]); - } - }, + var applyExtraClasses = function (row, ndx) { + if (options.extraClasses) { + row.removeClass(flatExtraClasses) + row.addClass(options.extraClasses[ndx % options.extraClasses.length]) + } + } - updateElementIndex = function(elem, prefix, ndx) { - var idRegex = new RegExp(prefix + '-(\\d+|__prefix__)-'), - replacement = prefix + '-' + ndx + '-'; - if (elem.attr("for")) elem.attr("for", elem.attr("for").replace(idRegex, replacement)); - if (elem.attr('id')) elem.attr('id', elem.attr('id').replace(idRegex, replacement)); - if (elem.attr('name')) elem.attr('name', elem.attr('name').replace(idRegex, replacement)); - }, + var updateElementIndex = function (elem, prefix, ndx) { + var idRegex = new RegExp(prefix + '-(\\d+|__prefix__)-') + var replacement = prefix + '-' + ndx + '-' + if (elem.attr('for')) elem.attr('for', elem.attr('for').replace(idRegex, replacement)) + if (elem.attr('id')) elem.attr('id', elem.attr('id').replace(idRegex, replacement)) + if (elem.attr('name')) elem.attr('name', elem.attr('name').replace(idRegex, replacement)) + } - hasChildElements = function(row) { - return row.find(childElementSelector).length > 0; - }, + var hasChildElements = function (row) { + return row.find(childElementSelector).length > 0 + } - showAddButton = function() { - return maxForms.length == 0 || // For Django versions pre 1.2 - (maxForms.val() == '' || (maxForms.val() - totalForms.val() > 0)); - }, + var showAddButton = function () { + return maxForms.length == 0 || // For Django versions pre 1.2 + (maxForms.val() == '' || (maxForms.val() - totalForms.val() > 0)) + } - /** + /** * Indicates whether delete link(s) can be displayed - when total forms > min forms */ - showDeleteLinks = function() { - return minForms.length == 0 || // For Django versions pre 1.7 - (minForms.val() == '' || (totalForms.val() - minForms.val() > 0)); - }, + var showDeleteLinks = function () { + return minForms.length == 0 || // For Django versions pre 1.7 + (minForms.val() == '' || (totalForms.val() - minForms.val() > 0)) + } - insertDeleteLink = function(row) { - var delCssSelector = $.trim(options.deleteCssClass).replace(/\s+/g, '.'), - addCssSelector = $.trim(options.addCssClass).replace(/\s+/g, '.'); + var insertDeleteLink = function (row) { + var delCssSelector = $.trim(options.deleteCssClass).replace(/\s+/g, '.') + var addCssSelector = $.trim(options.addCssClass).replace(/\s+/g, '.') - var delButtonHTML = '' + options.deleteText +''; - if (options.deleteContainerClass) { - // If we have a specific container for the remove button, - // place it as the last child of that container: - row.find('[class*="' + options.deleteContainerClass + '"]').append(delButtonHTML); - } else if (row.is('TR')) { - // If the forms are laid out in table rows, insert - // the remove button into the last table cell: - row.children('td:last').append(delButtonHTML); - } else if (row.is('UL') || row.is('OL')) { - // If they're laid out as an ordered/unordered list, - // insert an
  • after the last list item: - row.append('
  • ' + delButtonHTML + '
  • '); - } else { - // Otherwise, just insert the remove button as the - // last child element of the form's container: - row.append(delButtonHTML); - } + var delButtonHTML = '' + options.deleteText + '' + if (options.deleteContainerClass) { + // If we have a specific container for the remove button, + // place it as the last child of that container: + row.find('[class*="' + options.deleteContainerClass + '"]').append(delButtonHTML) + } else if (row.is('TR')) { + // If the forms are laid out in table rows, insert + // the remove button into the last table cell: + row.children('td:last').append(delButtonHTML) + } else if (row.is('UL') || row.is('OL')) { + // If they're laid out as an ordered/unordered list, + // insert an
  • after the last list item: + row.append('
  • ' + delButtonHTML + '
  • ') + } else { + // Otherwise, just insert the remove button as the + // last child element of the form's container: + row.append(delButtonHTML) + } - // Check if we're under the minimum number of forms - not to display delete link at rendering - if (!showDeleteLinks()){ - row.find('a.' + delCssSelector).hide(); - } + // Check if we're under the minimum number of forms - not to display delete link at rendering + if (!showDeleteLinks()) { + row.find('a.' + delCssSelector).hide() + } - row.find('a.' + delCssSelector).click(function() { - var row = $(this).parents('.' + options.formCssClass), - del = row.find('input:hidden[id $= "-DELETE"]'), - buttonRow = row.siblings("a." + addCssSelector + ', .' + options.formCssClass + '-add'), - forms; - if (del.length) { - // We're dealing with an inline formset. - // Rather than remove this form from the DOM, we'll mark it as deleted - // and hide it, then let Django handle the deleting: - del.val('on'); - row.hide(); - forms = $('.' + options.formCssClass).not(':hidden'); - } else { - row.remove(); - // Update the TOTAL_FORMS count: - forms = $('.' + options.formCssClass).not('.formset-custom-template'); - totalForms.val(forms.length); - } - for (var i=0, formCount=forms.length; i