1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-06-26 19:57:57 +02:00

Compare commits

..

117 Commits

Author SHA1 Message Date
048d251f75 Merge branch 'charliep-main-patch-40779' into 'main'
update Copyright 2024

See merge request bde/nk20!228
2024-02-07 02:05:59 +01:00
7b11cb0797 update Copyright 2024 2024-02-07 01:37:43 +01:00
ff3c30517e Merge branch 'happy-new-year' into 'main'
happy new year

See merge request bde/nk20!226
2024-01-11 16:48:06 +01:00
f481ea6acb happy new year (contain annually WEI change and update to follow Django Style Guide) 2024-01-11 16:32:37 +01:00
802fd8c2d7 Merge branch 'search_conso_bugfix' into 'main'
Bugfix

See merge request bde/nk20!225
2023-11-13 14:29:29 +01:00
5209a586a9 Fixed const being redeclared when script is reevaluated 2023-11-08 17:10:05 +01:00
24f54ac876 Merge branch 'search-conso' into 'main'
Added a search tab for the conso page, fixes #58

Closes #58

See merge request bde/nk20!224
2023-10-27 16:45:41 +02:00
988b4c9e88 Linting 2023-10-26 21:03:48 +02:00
e32c267995 Moved js code to the external conso file 2023-10-26 19:10:43 +02:00
5e39209ab1 Made searchbar completely client-based 2023-10-26 19:01:09 +02:00
08b2fabe07 Removing jquery means changing the event API... 2023-10-26 00:22:51 +02:00
405479e5ad Execute script to add behavior to searched buttons 2023-10-26 00:10:56 +02:00
0cc130092f Added a search tab for the conso page 2023-10-25 20:01:48 +02:00
ff6e207512 Merge branch 'beta' into 'main'
check for a model in permission and use that in treasury

See merge request bde/nk20!222
2023-09-29 12:08:00 +02:00
0f1e4d2e60 check for a model in permission and use that in treasury 2023-09-28 18:48:57 +02:00
6255bcbbb1 Merge branch 'beta' into 'main'
Merge beta

See merge request bde/nk20!221
2023-09-27 17:14:49 +02:00
d82a1001c4 Moved transaction through frienships right to basic rights 2023-09-27 16:55:00 +02:00
31a54482f0 Updated doc to tell maintainers to create psql superusers 2023-09-27 16:53:30 +02:00
4ee02345d4 Merge branch 'better-friendship-view' into 'main'
Rework of the friendships page

See merge request bde/nk20!220
2023-09-21 15:48:00 +02:00
422c087d17 fix wei test 2023-09-20 07:04:13 +02:00
30d6e2c95e Added trusts to note admin site 2023-09-19 15:07:30 +02:00
f3a3f07e38 Tweaked message and did missing french translations 2023-09-18 17:29:52 +02:00
a5e802f370 Improved the error message when trying to duplicate a Trust 2023-09-18 17:12:31 +02:00
540f3bc354 regenerated messages so locations are consistent with codebase 2023-09-02 00:04:54 +02:00
2d19457506 Add spanish translation for friendship 2023-09-01 17:35:52 +02:00
72786d0d2b Translated js strings, unified some case 2023-09-01 17:34:52 +02:00
f099cbc879 Linting 2023-09-01 17:32:29 +02:00
977eb7c0d4 Generated translation files, did french 2023-09-01 17:30:38 +02:00
d81b1f2710 Tweaked trust back display 2023-09-01 17:15:24 +02:00
6a69590a82 Added a 'trust back' button, front can be improved 2023-09-01 17:15:24 +02:00
7afc583282 Made trust adding widget resetable, corrected the unexpected empty field behavior and improved autocomplete's responsiveness 2023-09-01 17:15:24 +02:00
4fb0b7d736 First pass on a display of users trusting you, added a corresponding right 2023-09-01 17:15:13 +02:00
18a5b65a1c Merge branch 'VSS' into 'main'
anti VSS

See merge request bde/nk20!219
2023-08-31 15:58:52 +02:00
f545af4977 typo 2023-08-31 15:40:49 +02:00
103e2d0635 add GC anti-VSS 2023-08-31 15:25:44 +02:00
aedf0e87ba prez BDE can block note 2023-08-31 13:46:27 +02:00
dab45b5fd4 translation 2023-08-31 13:40:53 +02:00
b3353b563c add VSS checkbox on registration 2023-08-31 12:21:38 +02:00
6bc52be707 Merge branch 'WEI_with_questions' into 'main'
Wei with questions

See merge request bde/nk20!218
2023-08-31 12:01:39 +02:00
834d68fe35 typo 2023-08-31 11:45:17 +02:00
c6a2849d35 test 2023-08-30 16:16:29 +02:00
4ab22c92b3 After WEI registration validation, come back to unvalidate registration page 2023-08-30 09:52:17 +02:00
c328c1457c add register button at the end of WEI registration 2023-08-28 22:27:45 +02:00
96da7d01ae change on a field that everyone have (1A don't have bus) 2023-08-28 19:26:51 +02:00
d27f942339 typo 2023-08-28 10:13:28 +02:00
738d6c932d questions ! 2023-08-28 00:42:33 +02:00
1760196578 more tests 2023-08-27 23:11:40 +02:00
13b9b6edea tests 2023-08-27 18:09:46 +02:00
e06e3b2972 one question by page 2023-08-26 23:47:10 +02:00
9596aa7b8c base for questions instead of words 2023-08-26 17:52:48 +02:00
ba0d64f0d4 Merge branch 'new_default_year' into 'main'
new default year

See merge request bde/nk20!217
2023-08-23 23:53:45 +02:00
8d17801e28 new default year 2023-08-23 23:32:01 +02:00
609362c4f8 Merge branch 'update_permission' into 'main'
Update permission

See merge request bde/nk20!216
2023-08-23 22:50:24 +02:00
03d2d5f03e change -50€ to -20€ and doc 2023-08-22 21:51:02 +02:00
d2057a9f45 remove respo-info perm and change Prez BDE prem 2023-08-22 21:19:05 +02:00
b6e68eeebe Merge branch 'charliep-main-patch-47507' into 'main'
Update forms.py - Homogénéisation des cases

See merge request bde/nk20!215
2023-08-08 15:39:44 +02:00
6410542027 Update forms.py - Homogénéisation des cases 2023-08-08 15:38:29 +02:00
6b1cd3ba7a manage self aliases for BDE member instead of kfet 2023-07-24 12:42:44 +02:00
9f114b8ca2 fixtures activities 2023-07-24 12:26:34 +02:00
e0132b6dc8 migration permission 2023-07-24 12:20:16 +02:00
f1cc82fab3 Merge branch 'linters' into 'main'
Linters

See merge request bde/nk20!214
2023-07-17 09:27:22 +02:00
644cf14c4b missing brackets 2023-07-17 09:11:25 +02:00
f19a489313 linters (removing B019) 2023-07-17 08:50:10 +02:00
dedd6c69cc new commits in nk20-scripts 2023-07-17 06:58:01 +02:00
b42f5afeab Merge branch 'registration2023' into 'main'
Registration2023

See merge request bde/nk20!213
2023-07-16 17:12:33 +02:00
31e67ae3f6 typo 2023-07-09 16:06:30 +02:00
b08da7a727 help text on WEI emergency contact 2023-07-09 14:57:48 +02:00
451aa64f33 Unisexe clothing cut 2023-07-09 12:30:23 +02:00
3c99b0f3e9 do not change transactions date when validating/deleting credit-soge (and typo) 2023-07-09 11:23:33 +02:00
201a179947 linters 2023-07-09 10:36:36 +02:00
96784aee3b remove (comment) soge from registration 2023-07-07 21:44:18 +02:00
981c4d0300 fix update of club membership start/end date 2023-07-07 20:39:19 +02:00
11223430fd Merge branch 'WEI2023' into 'main'
Préparation WEI 2023

See merge request bde/nk20!212
2023-07-04 19:17:17 +02:00
7aeb977e72 Oubli dans le fichier test_wei_registration_.py d'un 2022 en 2023 2023-07-04 18:33:54 +02:00
52fef1df42 Préparation WEI 2023 2023-07-04 18:23:43 +02:00
16f8a60a3f possibilité de l'adhésion au BDA lors de l'inscription 2023-07-04 17:32:48 +02:00
2839d3de1e club facultatif pour un role lors du changement dans l'interface admin 2023-06-22 14:52:11 +02:00
30afa6da0a création d'une permission pour faire les crédits uniquement 2023-06-12 18:29:23 +02:00
84fc77696f see activities: BDE members instead of kfet 2023-06-05 19:04:19 +02:00
19fc620d1f see kfet members' note for respot 2023-06-05 17:26:49 +02:00
d5819ac562 Merge branch 'FAQ' into 'main'
Ajout d'un lien vers la FAQ de la note.

See merge request bde/nk20!209
2023-04-18 15:51:38 +02:00
a79df8f1f6 Merge branch 'invoice_bg_storlist' into 'main'
changement du fond des factures

See merge request bde/nk20!211
2023-04-14 19:29:26 +02:00
364b18e188 migrations 2023-04-14 16:52:46 +02:00
10a883b2e5 new treasury phone number 2023-04-14 16:00:48 +02:00
1410ab6c4f Almost on time, the SIRET number is now changed 2023-04-14 15:35:18 +02:00
623dd61be6 Remove phone number 2023-04-14 14:56:34 +02:00
48a0a87e7c changement du fond des factures 2023-04-14 00:25:26 +02:00
563f525b11 Merge branch 'cron' into 'main'
fréquence des mails de négatif aux trez : 1 mois -> 1 semaine, et les notes liées au BDE n'apparaissent plus

See merge request bde/nk20!210
2023-04-08 13:04:59 +02:00
63c1d74f1a Ignore notes containing '- BDE-' in the list of negative balances 2023-04-07 15:47:06 +02:00
c42fb380a6 frequence des mails de négatif aux trez : 1 mois -> 1 semiane 2023-04-06 09:04:27 +02:00
c636d52a73 traduction (allemand et espagnol probablement pas optimal) 2023-03-31 17:21:58 +02:00
6a9021ec14 Merge branch 'couleur_totalist_spies' into 'main'
Couleur totalist spies

See merge request bde/nk20!208
2023-03-31 12:37:24 +02:00
9c9149b53a Ajout d'un lien vers la FAQ de la note. 2023-03-31 12:34:14 +02:00
cb74311e7b Commit migration, j'étais triggered 2023-03-30 19:14:52 +02:00
9d7dd566c9 Ignore /tmp/ 2023-03-30 17:26:06 +02:00
6bceb394c5 prez BDE sould see invoice list 2023-03-29 20:43:54 +02:00
62cf8f9d84 forgetted coma 2023-03-28 20:41:53 +02:00
9944ebcaad changement des couleurs de la note vers les couleurs totalist spies 2023-03-25 02:13:16 +01:00
8537f043f7 changement des couleurs de la note vers les couleurs totalist spies 2023-03-25 00:57:19 +01:00
2dd1c3fb89 change mask for some perm 2023-03-20 22:35:51 +01:00
c8665c5798 change permissions for role 2023-03-20 22:21:18 +01:00
e9f1b6f52d change permanent permissions 2023-03-20 17:19:14 +01:00
1d95ae4810 sort perm by number 2023-03-20 16:16:32 +01:00
c89a95f8d2 Merge branch 'invoice-logo-totalist' into 'main'
changement du fond des factures

See merge request bde/nk20!207
2023-01-30 13:06:39 +01:00
73640b1dfa changement du fond des factures 2023-01-30 00:06:45 +01:00
84b16ab603 Merge branch 'SogeCreditDate' into 'main'
link SogeCredit to WEI by creation date instead of civil year

See merge request bde/nk20!206
2023-01-17 15:58:52 +01:00
6a1b51dbbf Merge branch 'api_pagination' into 'main'
Add custom pagination size as an API parameter

See merge request bde/nk20!205
2023-01-11 22:46:13 +01:00
c441a43a8b link SogeCredit to WEI by creation date instead of civil year 2023-01-10 21:40:03 +01:00
87f3b51b04 Add custom pagination size as an API parameter 2022-12-14 18:37:13 +01:00
0a853fd3e6 Merge branch 'permission_trez' into 'main'
fix trez perm

See merge request bde/nk20!204
2022-12-10 14:41:57 +01:00
c429734810 fix bug 2022-11-12 14:51:22 +01:00
5d759111b6 Merge branch 'weiWords' into 'main'
change wei words

See merge request bde/nk20!203
2022-09-05 13:24:24 +02:00
70baf7566c change wei words 2022-09-05 13:20:00 +02:00
eb355f547c Merge branch 'SogeNotForMembership' into 'main'
Soge not for membership

See merge request bde/nk20!202
2022-09-04 22:56:07 +02:00
7068170f18 fixing grammar in comments 2022-09-04 13:24:39 +02:00
45ee9a8941 Soge only payd WEI (not bde/kfet membership) 2022-09-04 12:52:40 +02:00
454ea19603 hide Soge during registration 2022-09-04 12:31:08 +02:00
117 changed files with 3309 additions and 2155 deletions

1
.gitignore vendored
View File

@ -42,6 +42,7 @@ map.json
backups/
/static/
/media/
/tmp/
# Virtualenv
env/

View File

@ -55,7 +55,7 @@ Bien que cela permette de créer une instance sur toutes les distributions,
(env)$ ./manage.py makemigrations
(env)$ ./manage.py migrate
(env)$ ./manage.py loaddata initial
(env)$ ./manage.py createsuperuser # Création d'un⋅e utilisateur⋅rice initial
(env)$ ./manage.py createsuperuser # Création d'un utilisateur initial
```
6. Enjoy :

View File

@ -6,7 +6,7 @@
"name": "Pot",
"manage_entries": true,
"can_invite": true,
"guest_entry_fee": 500
"guest_entry_fee": 1000
}
},
{
@ -28,5 +28,25 @@
"can_invite": false,
"guest_entry_fee": 0
}
},
{
"model": "activity.activitytype",
"pk": 5,
"fields": {
"name": "Soir\u00e9e avec entrées",
"manage_entries": true,
"can_invite": false,
"guest_entry_fee": 0
}
},
{
"model": "activity.activitytype",
"pk": 7,
"fields": {
"name": "Soir\u00e9e avec invitations",
"manage_entries": true,
"can_invite": true,
"guest_entry_fee": 0
}
}
]

View File

@ -26,7 +26,7 @@ class ActivityForm(forms.ModelForm):
clubs = list(Club.objects.filter(PermissionBackend
.filter_queryset(get_current_request(), Club, "view")).all())
shuffle(clubs)
self.fields["organizer"].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ","
self.fields["organizer"].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..."
def clean_organizer(self):
organizer = self.cleaned_data['organizer']
@ -53,7 +53,7 @@ class ActivityForm(forms.ModelForm):
model=Note,
attrs={
"api_url": "/api/note/note/",
'placeholder': 'Note de l\'événement sur laquelle envoyer les crédits d\'invitation'
'placeholder': 'Note de l\'événement sur laquelle envoyer les crédits d\'invitation ...'
},
),
"attendees_club": Autocomplete(
@ -115,7 +115,7 @@ class GuestForm(forms.ModelForm):
# We don't evaluate the content type at launch because the DB might be not initialized
'api_url_suffix':
lambda: '&polymorphic_ctype=' + str(ContentType.objects.get_for_model(NoteUser).pk),
'placeholder': 'Note',
'placeholder': 'Note ...',
},
),
}

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import os
@ -123,10 +123,18 @@ class Activity(models.Model):
verbose_name=_('open'),
)
class Meta:
verbose_name = _("activity")
verbose_name_plural = _("activities")
unique_together = ("name", "date_start", "date_end",)
def __str__(self):
return self.name
@transaction.atomic
def save(self, *args, **kwargs):
"""
Update the activity wiki page each time the activity is updated (validation, change description,)
Update the activity wiki page each time the activity is updated (validation, change description, ...)
"""
if self.date_end < self.date_start:
raise ValidationError(_("The end date must be after the start date."))
@ -144,14 +152,6 @@ class Activity(models.Model):
if settings.DATABASES["default"]["ENGINE"] == 'django.db.backends.postgresql' else refresh_activities()
return ret
def __str__(self):
return self.name
class Meta:
verbose_name = _("activity")
verbose_name_plural = _("activities")
unique_together = ("name", "date_start", "date_end",)
class Entry(models.Model):
"""
@ -252,14 +252,13 @@ class Guest(models.Model):
verbose_name=_("inviter"),
)
@property
def has_entry(self):
try:
if self.entry:
return True
return False
except AttributeError:
return False
class Meta:
verbose_name = _("guest")
verbose_name_plural = _("guests")
unique_together = ("activity", "last_name", "first_name", )
def __str__(self):
return self.first_name + " " + self.last_name
@transaction.atomic
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
@ -290,13 +289,14 @@ class Guest(models.Model):
return super().save(force_insert, force_update, using, update_fields)
def __str__(self):
return self.first_name + " " + self.last_name
class Meta:
verbose_name = _("guest")
verbose_name_plural = _("guests")
unique_together = ("activity", "last_name", "first_name", )
@property
def has_entry(self):
try:
if self.entry:
return True
return False
except AttributeError:
return False
class GuestTransaction(Transaction):

View File

@ -37,7 +37,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<button class="btn btn-light">{% trans "Return to activity page" %}</button>
</a>
<input id="alias" type="text" class="form-control" placeholder="Nom/note">
<input id="alias" type="text" class="form-control" placeholder="Nom/note ...">
<hr>

5
apps/api/pagination.py Normal file
View File

@ -0,0 +1,5 @@
from rest_framework.pagination import PageNumberPagination
class CustomPagination(PageNumberPagination):
page_size_query_param = 'page_size'

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
@ -76,9 +76,6 @@ class Changelog(models.Model):
verbose_name=_('timestamp'),
)
def delete(self, using=None, keep_parents=False):
raise ValidationError(_("Logs cannot be destroyed."))
class Meta:
verbose_name = _("changelog")
verbose_name_plural = _("changelogs")
@ -86,3 +83,6 @@ class Changelog(models.Model):
def __str__(self):
return _("Changelog of type \"{action}\" for model {model} at {timestamp}").format(
action=self.get_action_display(), model=str(self.model), timestamp=str(self.timestamp))
def delete(self, using=None, keep_parents=False):
raise ValidationError(_("Logs cannot be destroyed."))

View File

@ -56,13 +56,13 @@ def save_object(sender, instance, **kwargs):
# noinspection PyProtectedMember
previous = instance._previous
# Si un⋅e utilisateur⋅rice est connecté⋅e, on récupère l'utilisateur⋅rice courant⋅e ainsi que son adresse IP
# Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP
request = get_current_request()
if request is None:
# Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py`
# On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée
# IMPORTANT : l'utilisateur⋅rice dans la VM doit être un des alias note du respo info
# IMPORTANT : l'utilisateur dans la VM doit être un des alias note du respo info
ip = "127.0.0.1"
username = Alias.normalize(getpass.getuser())
note = NoteUser.objects.filter(alias__normalized_name=username)
@ -134,13 +134,13 @@ def delete_object(sender, instance, **kwargs):
if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_signal"):
return
# Si un⋅e utilisateur⋅rice est connecté⋅e, on récupère l'utilisateur⋅rice courant⋅e ainsi que son adresse IP
# Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP
request = get_current_request()
if request is None:
# Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py`
# On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée
# IMPORTANT : l'utilisateur⋅rice dans la VM doit être un des alias note du respo info
# IMPORTANT : l'utilisateur dans la VM doit être un des alias note du respo info
ip = "127.0.0.1"
username = Alias.normalize(getpass.getuser())
note = NoteUser.objects.filter(alias__normalized_name=username)

View File

@ -47,6 +47,13 @@ class ProfileForm(forms.ModelForm):
last_report = forms.DateTimeField(required=False, disabled=True, label=_("Last report date"))
VSS_charter_read = forms.BooleanField(
required=True,
label=_("Anti-VSS (<em>Violences Sexistes et Sexuelles</em>) charter read and approved"),
help_text=_("Tick after having read and accepted the anti-VSS charter \
<a href=https://perso.crans.org/club-bde/Charte-anti-VSS.pdf target=_blank> available here in pdf</a>")
)
def clean_promotion(self):
promotion = self.cleaned_data["promotion"]
if promotion > timezone.now().year:
@ -200,9 +207,9 @@ class MembershipForm(forms.ModelForm):
class Meta:
model = Membership
fields = ('user', 'date_start')
# Le champ d'utilisateur⋅rice est remplacé par un champ d'auto-complétion.
# Le champ d'utilisateur est remplacé par un champ d'auto-complétion.
# Quand des lettres sont tapées, une requête est envoyée sur l'API d'auto-complétion
# et récupère les noms d'utilisateur⋅rices valides
# et récupère les noms d'utilisateur valides
widgets = {
'user':
Autocomplete(
@ -210,7 +217,7 @@ class MembershipForm(forms.ModelForm):
attrs={
'api_url': '/api/user/',
'name_field': 'username',
'placeholder': 'Nom',
'placeholder': 'Nom ...',
},
),
'date_start': DatePickerInput(),
@ -227,7 +234,7 @@ class MembershipRolesForm(forms.ModelForm):
attrs={
'api_url': '/api/user/',
'name_field': 'username',
'placeholder': 'Nom',
'placeholder': 'Nom ...',
},
),
)

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.26 on 2022-09-04 21:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('member', '0008_auto_20211005_1544'),
]
operations = [
migrations.AlterField(
model_name='profile',
name='promotion',
field=models.PositiveSmallIntegerField(default=2022, help_text='Year of entry to the school (None if not ENS student)', null=True, verbose_name='promotion'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2023-08-23 21:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('member', '0009_auto_20220904_2325'),
]
operations = [
migrations.AlterField(
model_name='profile',
name='promotion',
field=models.PositiveSmallIntegerField(default=2023, help_text='Year of entry to the school (None if not ENS student)', null=True, verbose_name='promotion'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2023-08-31 09:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('member', '0010_new_default_year'),
]
operations = [
migrations.AddField(
model_name='profile',
name='VSS_charter_read',
field=models.BooleanField(default=False, verbose_name='VSS charter read'),
),
]

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import datetime
@ -28,7 +28,6 @@ class Profile(models.Model):
We do not want to patch the Django Contrib :model:`auth.User`model;
so this model add an user profile with additional information.
"""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
@ -134,6 +133,22 @@ class Profile(models.Model):
default=False,
)
VSS_charter_read = models.BooleanField(
verbose_name=_("VSS charter read"),
default=False
)
class Meta:
verbose_name = _('user profile')
verbose_name_plural = _('user profile')
indexes = [models.Index(fields=['user'])]
def __str__(self):
return str(self.user)
def get_absolute_url(self):
return reverse('member:user_detail', args=(self.user_id,))
@property
def ens_year(self):
"""
@ -158,17 +173,6 @@ class Profile(models.Model):
return SogeCredit.objects.filter(user=self.user, credit_transaction__isnull=False).exists()
return False
class Meta:
verbose_name = _('user profile')
verbose_name_plural = _('user profile')
indexes = [models.Index(fields=['user'])]
def get_absolute_url(self):
return reverse('member:user_detail', args=(self.user_id,))
def __str__(self):
return str(self.user)
def send_email_validation_link(self):
subject = "[Note Kfet] " + str(_("Activate your Note Kfet account"))
token = email_validation_token.make_token(self.user)
@ -200,9 +204,11 @@ class Club(models.Model):
max_length=255,
unique=True,
)
email = models.EmailField(
verbose_name=_('email'),
)
parent_club = models.ForeignKey(
'self',
null=True,
@ -253,25 +259,12 @@ class Club(models.Model):
help_text=_('Maximal date of a membership, after which members must renew it.'),
)
def update_membership_dates(self):
"""
This function is called each time the club detail view is displayed.
Update the year of the membership dates.
"""
if not self.membership_start or not self.membership_end:
return
class Meta:
verbose_name = _("club")
verbose_name_plural = _("clubs")
today = datetime.date.today()
if (today - self.membership_start).days >= 365:
if self.membership_start:
self.membership_start = datetime.date(self.membership_start.year + 1,
self.membership_start.month, self.membership_start.day)
if self.membership_end:
self.membership_end = datetime.date(self.membership_end.year + 1,
self.membership_end.month, self.membership_end.day)
self._force_save = True
self.save(force_update=True)
def __str__(self):
return self.name
@transaction.atomic
def save(self, force_insert=False, force_update=False, using=None,
@ -284,16 +277,29 @@ class Club(models.Model):
self.membership_end = None
super().save(force_insert, force_update, update_fields)
class Meta:
verbose_name = _("club")
verbose_name_plural = _("clubs")
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse_lazy('member:club_detail', args=(self.pk,))
def update_membership_dates(self):
"""
This function is called each time the club detail view is displayed.
Update the year of the membership dates.
"""
if not self.membership_start or not self.membership_end:
return
today = datetime.date.today()
while (today - self.membership_start).days >= 365:
if self.membership_start:
self.membership_start = datetime.date(self.membership_start.year + 1,
self.membership_start.month, self.membership_start.day)
if self.membership_end:
self.membership_end = datetime.date(self.membership_end.year + 1,
self.membership_end.month, self.membership_end.day)
self._force_save = True
self.save(force_update=True)
class Membership(models.Model):
"""
@ -333,6 +339,66 @@ class Membership(models.Model):
verbose_name=_('fee'),
)
class Meta:
verbose_name = _('membership')
verbose_name_plural = _('memberships')
indexes = [models.Index(fields=['user'])]
def __str__(self):
return _("Membership of {user} for the club {club}").format(user=self.user.username, club=self.club.name, )
@transaction.atomic
def save(self, *args, **kwargs):
"""
Calculate fee and end date before saving the membership and creating the transaction if needed.
"""
# Ensure that club membership dates are valid
old_membership_start = self.club.membership_start
self.club.update_membership_dates()
if self.club.membership_start != old_membership_start:
self.club.save()
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))
else:
if Membership.objects.filter(
user=self.user,
club=self.club,
date_start__lte=self.date_start,
date_end__gte=self.date_start,
).exists():
raise ValidationError(_('User is already a member of the club'))
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,
club=self.club.parent_club,
date_start__gte=self.club.parent_club.membership_start,
).exists():
if hasattr(self, '_force_renew_parent') and self._force_renew_parent:
self.renew_parent()
else:
raise ValidationError(_('User is not a member of the parent club')
+ ' ' + self.club.parent_club.name)
self.fee = self.club.membership_fee_paid if self.user.profile.paid else self.club.membership_fee_unpaid
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
super().save(*args, **kwargs)
self.make_transaction()
@property
def valid(self):
"""
@ -402,66 +468,14 @@ class Membership(models.Model):
if self.club.parent_club.name == "BDE":
parent_membership.roles.set(
Role.objects.filter(Q(name="Adhérent⋅e BDE") | Q(name="Membre de club")).all())
Role.objects.filter(Q(name="Adhérent BDE") | Q(name="Membre de club")).all())
elif self.club.parent_club.name == "Kfet":
parent_membership.roles.set(
Role.objects.filter(Q(name="Adhérent⋅e Kfet") | Q(name="Membre de club")).all())
Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all())
else:
parent_membership.roles.set(Role.objects.filter(name="Membre de club").all())
parent_membership.save()
@transaction.atomic
def save(self, *args, **kwargs):
"""
Calculate fee and end date before saving the membership and creating the transaction if needed.
"""
# Ensure that club membership dates are valid
old_membership_start = self.club.membership_start
self.club.update_membership_dates()
if self.club.membership_start != old_membership_start:
self.club.save()
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))
else:
if Membership.objects.filter(
user=self.user,
club=self.club,
date_start__lte=self.date_start,
date_end__gte=self.date_start,
).exists():
raise ValidationError(_('User is already a member of the club'))
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,
club=self.club.parent_club,
date_start__gte=self.club.parent_club.membership_start,
).exists():
if hasattr(self, '_force_renew_parent') and self._force_renew_parent:
self.renew_parent()
else:
raise ValidationError(_('User is not a member of the parent club')
+ ' ' + self.club.parent_club.name)
self.fee = self.club.membership_fee_paid if self.user.profile.paid else self.club.membership_fee_unpaid
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
super().save(*args, **kwargs)
self.make_transaction()
def make_transaction(self):
"""
Create Membership transaction associated to this membership.
@ -499,11 +513,3 @@ class Membership(models.Model):
soge_credit.save()
else:
transaction.save(force_insert=True)
def __str__(self):
return _("Membership of {user} for the club {club}").format(user=self.user.username, club=self.club.name, )
class Meta:
verbose_name = _('membership')
verbose_name_plural = _('memberships')
indexes = [models.Index(fields=['user'])]

View File

@ -1,7 +1,7 @@
/**
* On form submit, create a new friendship
*/
function create_trust (e) {
function form_create_trust (e) {
// Do not submit HTML form
e.preventDefault()
@ -14,25 +14,35 @@ function create_trust (e) {
addMsg(gettext("You can't add yourself as a friend"), "danger")
return
}
$.post('/api/note/trust/', {
csrfmiddlewaretoken: formData.get('csrfmiddlewaretoken'),
trusting: formData.get('trusting'),
trusted: trusted_alias.note
}).done(function () {
// Reload table
$('#trust_table').load(location.pathname + ' #trust_table')
addMsg(gettext('Friendship successfully added'), 'success')
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
create_trust(formData.get('trusting'), trusted_alias.note)
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
}
/**
* On click of "delete", delete the alias
* @param button_id:Integer Alias id to remove
* Create a trust between users
* @param trusting:Integer trusting note id
* @param trusted:Integer trusted note id
*/
function create_trust(trusting, trusted) {
$.post('/api/note/trust/', {
trusting: trusting,
trusted: trusted,
csrfmiddlewaretoken: CSRF_TOKEN
}).done(function () {
// Reload tables
$('#trust_table').load(location.pathname + ' #trust_table')
$('#trusted_table').load(location.pathname + ' #trusted_table')
addMsg(gettext('Friendship successfully added'), 'success')
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
}
/**
* On click of "delete", delete the trust
* @param button_id:Integer Trust id to remove
*/
function delete_button (button_id) {
$.ajax({
@ -42,6 +52,7 @@ function delete_button (button_id) {
}).done(function () {
addMsg(gettext('Friendship successfully deleted'), 'success')
$('#trust_table').load(location.pathname + ' #trust_table')
$('#trusted_table').load(location.pathname + ' #trusted_table')
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
@ -49,5 +60,5 @@ function delete_button (button_id) {
$(document).ready(function () {
// Attach event
document.getElementById('form_trust').addEventListener('submit', create_trust)
document.getElementById('form_trust').addEventListener('submit', form_create_trust)
})

View File

@ -7,7 +7,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% block profile_content %}
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "Note friendships" %}
{% trans "Add friends" %}
</h3>
<div class="card-body">
{% if can_create %}
@ -24,7 +24,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% render_table trusting %}
</div>
<div class="alert alert-warning card">
<div class="alert alert-warning card mb-3">
{% blocktrans trimmed %}
Adding someone as a friend enables them to initiate transactions coming
from your account (while keeping your balance positive). This is
@ -33,6 +33,13 @@ SPDX-License-Identifier: GPL-3.0-or-later
friends without needing additional rights among them.
{% endblocktrans %}
</div>
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "People having you as a friend" %}
</h3>
{% render_table trusted_by %}
</div>
{% endblock %}
{% block extrajavascript %}

View File

@ -183,7 +183,7 @@ class TestMemberships(TestCase):
club = Club.objects.get(name="Kfet")
else:
club = Club.objects.create(
name="Second club " + ("with BDE" if bde_parent else "without BDE"),
name="Second club without BDE",
parent_club=None,
email="newclub@example.com",
require_memberships=True,
@ -291,7 +291,7 @@ class TestMemberships(TestCase):
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ésorière de club") | Q(name="Bureau de club")).all()],
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()
@ -335,6 +335,7 @@ class TestMemberships(TestCase):
ml_sports_registration=True,
ml_art_registration=True,
report_frequency=7,
VSS_charter_read=True
))
self.assertRedirects(response, self.user.profile.get_absolute_url(), 302, 200)
self.assertTrue(User.objects.filter(username="toto changed").exists())

View File

@ -8,7 +8,6 @@ from django.contrib.auth import logout
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import User
from django.contrib.auth.views import LoginView
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.db.models import Q, F
from django.shortcuts import redirect
@ -21,7 +20,7 @@ from django_tables2.views import SingleTableView
from rest_framework.authtoken.models import Token
from note.models import Alias, NoteClub, NoteUser, Trust
from note.models.transactions import Transaction, SpecialTransaction
from note.tables import HistoryTable, AliasTable, TrustTable
from note.tables import HistoryTable, AliasTable, TrustTable, TrustedTable
from note_kfet.middlewares import _set_current_request
from permission.backends import PermissionBackend
from permission.models import Role
@ -258,17 +257,18 @@ class ProfileTrustView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
note = context['object'].note
context["trusting"] = TrustTable(
note.trusting.filter(PermissionBackend.filter_queryset(self.request, Trust, "view")).distinct().all())
context["trusted_by"] = TrustedTable(
note.trusted.filter(PermissionBackend.filter_queryset(self.request, Trust, "view")).distinct().all())
context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_trust", Trust(
trusting=context["object"].note,
trusted=context["object"].note
))
context["widget"] = {
"name": "trusted",
"resetable": True,
"attrs": {
"model_pk": ContentType.objects.get_for_model(Alias).pk,
"class": "autocomplete form-control",
"id": "trusted",
"resetable": True,
"api_url": "/api/note/alias/?note__polymorphic_ctype__model=noteuser",
"name_field": "name",
"placeholder": ""
@ -753,6 +753,10 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
club = old_membership.club
user = old_membership.user
# Update club membership date
if PermissionBackend.check_perm(self.request, "member.change_club_membership_start", club):
club.update_membership_dates()
form.instance.club = club
# Get form data
@ -820,8 +824,8 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
ret = super().form_valid(form)
member_role = Role.objects.filter(Q(name="Adhérent⋅e BDE") | Q(name="Membre de club")).all() \
if club.name == "BDE" else Role.objects.filter(Q(name="Adhérent⋅e Kfet") | Q(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()
# Set the same roles as before
if old_membership:
@ -857,7 +861,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
membership.refresh_from_db()
if old_membership.exists():
membership.roles.set(old_membership.get().roles.all())
membership.roles.set(Role.objects.filter(Q(name="Adhérent⋅e 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

View File

@ -7,7 +7,7 @@ from polymorphic.admin import PolymorphicChildModelAdmin, \
PolymorphicChildModelFilter, PolymorphicParentModelAdmin
from note_kfet.admin import admin_site
from .models.notes import Alias, Note, NoteClub, NoteSpecial, NoteUser
from .models.notes import Alias, Note, NoteClub, NoteSpecial, NoteUser, Trust
from .models.transactions import Transaction, TemplateCategory, TransactionTemplate, \
RecurrentTransaction, MembershipTransaction, SpecialTransaction
from .templatetags.pretty_money import pretty_money
@ -21,6 +21,16 @@ class AliasInlines(admin.TabularInline):
model = Alias
class TrustInlines(admin.TabularInline):
"""
Define trusts when editing the trusting note
"""
model = Trust
fk_name = "trusting"
extra = 0
readonly_fields = ("trusted",)
@admin.register(Note, site=admin_site)
class NoteAdmin(PolymorphicParentModelAdmin):
"""
@ -92,7 +102,7 @@ class NoteUserAdmin(PolymorphicChildModelAdmin):
"""
Child for an user note, see NoteAdmin
"""
inlines = (AliasInlines,)
inlines = (AliasInlines, TrustInlines)
# We can't change user after creation or the balance
readonly_fields = ('user', 'balance')

View File

@ -11,6 +11,7 @@ from member.models import Membership
from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend
from rest_framework.utils import model_meta
from rest_framework.validators import UniqueTogetherValidator
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias, Trust
from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory, \
@ -86,11 +87,9 @@ class TrustSerializer(serializers.ModelSerializer):
class Meta:
model = Trust
fields = '__all__'
def validate(self, attrs):
instance = Trust(**attrs)
instance.clean()
return attrs
validators = [UniqueTogetherValidator(
queryset=Trust.objects.all(), fields=('trusting', 'trusted'),
message=_("This friendship already exists"))]
class AliasSerializer(serializers.ModelSerializer):

View File

@ -26,7 +26,7 @@ class TransactionTemplateForm(forms.ModelForm):
# We don't evaluate the content type at launch because the DB might be not initialized
'api_url_suffix':
lambda: '&polymorphic_ctype=' + str(ContentType.objects.get_for_model(NoteClub).pk),
'placeholder': 'Note',
'placeholder': 'Note ...',
},
),
'amount': AmountInput(),
@ -43,7 +43,7 @@ class SearchTransactionForm(forms.Form):
resetable=True,
attrs={
'api_url': '/api/note/alias/',
'placeholder': 'Note',
'placeholder': 'Note ...',
},
),
)
@ -57,7 +57,7 @@ class SearchTransactionForm(forms.Form):
resetable=True,
attrs={
'api_url': '/api/note/alias/',
'placeholder': 'Note',
'placeholder': 'Note ...',
},
),
)

View File

@ -19,7 +19,7 @@ class Migration(migrations.Migration):
('trusting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trusting', to='note.Note', verbose_name='trusting')),
],
options={
'verbose_name': 'friendship',
'verbose_name': 'frienship',
'verbose_name_plural': 'friendships',
'unique_together': {('trusting', 'trusted')},
},

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import unicodedata
@ -240,7 +240,7 @@ class Trust(models.Model):
)
class Meta:
verbose_name = _("friendship")
verbose_name = _("frienship")
verbose_name_plural = _("friendships")
unique_together = ("trusting", "trusted")
@ -293,6 +293,11 @@ class Alias(models.Model):
def __str__(self):
return self.name
@transaction.atomic
def save(self, *args, **kwargs):
self.clean()
super().save(*args, **kwargs)
@staticmethod
def normalize(string):
"""
@ -321,11 +326,6 @@ class Alias(models.Model):
pass
self.normalized_name = normalized_name
@transaction.atomic
def save(self, *args, **kwargs):
self.clean()
super().save(*args, **kwargs)
def delete(self, using=None, keep_parents=False):
if self.name == str(self.note):
raise ValidationError(_("You can't delete your main alias."),

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.core.exceptions import ValidationError
@ -20,7 +20,7 @@ class TemplateCategory(models.Model):
"""
Defined a recurrent transaction category
Example: food, softs,
Example: food, softs, ...
"""
name = models.CharField(
verbose_name=_("name"),
@ -40,7 +40,7 @@ class TransactionTemplate(models.Model):
"""
Defined a recurrent transaction
associated to selling something (a burger, a beer,)
associated to selling something (a burger, a beer, ...)
"""
name = models.CharField(
verbose_name=_('name'),
@ -59,6 +59,7 @@ class TransactionTemplate(models.Model):
amount = models.PositiveIntegerField(
verbose_name=_('amount'),
)
category = models.ForeignKey(
TemplateCategory,
on_delete=models.PROTECT,
@ -87,12 +88,12 @@ class TransactionTemplate(models.Model):
verbose_name = _("transaction template")
verbose_name_plural = _("transaction templates")
def get_absolute_url(self):
return reverse('note:template_update', args=(self.pk,))
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('note:template_update', args=(self.pk,))
class Transaction(PolymorphicModel):
"""
@ -101,7 +102,6 @@ class Transaction(PolymorphicModel):
amount is store in centimes of currency, making it a positive integer
value. (from someone to someone else)
"""
source = models.ForeignKey(
Note,
on_delete=models.PROTECT,
@ -166,6 +166,50 @@ class Transaction(PolymorphicModel):
models.Index(fields=['destination']),
]
def __str__(self):
return self.__class__.__name__ + " from " + str(self.source) + " to " + str(self.destination) + " of "\
+ pretty_money(self.quantity * self.amount) + ("" if self.valid else " invalid")
@transaction.atomic
def save(self, *args, **kwargs):
"""
When saving, also transfer money between two notes
"""
if self.source.pk == self.destination.pk:
# When source == destination, no money is transferred and no transaction is created
return
self.source = Note.objects.select_for_update().get(pk=self.source_id)
self.destination = Note.objects.select_for_update().get(pk=self.destination_id)
# Check that the amounts stay between big integer bounds
diff_source, diff_dest = self.validate()
if not (hasattr(self, '_force_save') and self._force_save) \
and (not self.source.is_active or not self.destination.is_active):
raise ValidationError(_("The transaction can't be saved since the source note "
"or the destination note is not active."))
# If the aliases are not entered, we assume that the used alias is the name of the note
if not self.source_alias:
self.source_alias = str(self.source)
if not self.destination_alias:
self.destination_alias = str(self.destination)
# We save first the transaction, in case of the user has no right to transfer money
super().save(*args, **kwargs)
# Save notes
self.source.refresh_from_db()
self.source.balance += diff_source
self.source._force_save = True
self.source.save()
self.destination.refresh_from_db()
self.destination.balance += diff_dest
self.destination._force_save = True
self.destination.save()
def validate(self):
previous_source_balance = self.source.balance
previous_dest_balance = self.destination.balance
@ -208,46 +252,6 @@ class Transaction(PolymorphicModel):
return source_balance - previous_source_balance, dest_balance - previous_dest_balance
@transaction.atomic
def save(self, *args, **kwargs):
"""
When saving, also transfer money between two notes
"""
if self.source.pk == self.destination.pk:
# When source == destination, no money is transferred and no transaction is created
return
self.source = Note.objects.select_for_update().get(pk=self.source_id)
self.destination = Note.objects.select_for_update().get(pk=self.destination_id)
# Check that the amounts stay between big integer bounds
diff_source, diff_dest = self.validate()
if not (hasattr(self, '_force_save') and self._force_save) \
and (not self.source.is_active or not self.destination.is_active):
raise ValidationError(_("The transaction can't be saved since the source note "
"or the destination note is not active."))
# If the aliases are not entered, we assume that the used alias is the name of the note
if not self.source_alias:
self.source_alias = str(self.source)
if not self.destination_alias:
self.destination_alias = str(self.destination)
# We save first the transaction, in case of the user has no right to transfer money
super().save(*args, **kwargs)
# Save notes
self.source.refresh_from_db()
self.source.balance += diff_source
self.source._force_save = True
self.source.save()
self.destination.refresh_from_db()
self.destination.balance += diff_dest
self.destination._force_save = True
self.destination.save()
@property
def total(self):
return self.amount * self.quantity
@ -256,46 +260,40 @@ class Transaction(PolymorphicModel):
def type(self):
return _('Transfer')
def __str__(self):
return self.__class__.__name__ + " from " + str(self.source) + " to " + str(self.destination) + " of "\
+ pretty_money(self.quantity * self.amount) + ("" if self.valid else " invalid")
class RecurrentTransaction(Transaction):
"""
Special type of :model:`note.Transaction` associated to a :model:`note.TransactionTemplate`.
"""
template = models.ForeignKey(
TransactionTemplate,
on_delete=models.PROTECT,
)
class Meta:
verbose_name = _("recurrent transaction")
verbose_name_plural = _("recurrent transactions")
@transaction.atomic
def save(self, *args, **kwargs):
self.clean()
return super().save(*args, **kwargs)
def clean(self):
if self.template.destination != self.destination and not (hasattr(self, '_force_save') and self._force_save):
raise ValidationError(
_("The destination of this transaction must equal to the destination of the template."))
return super().clean()
@transaction.atomic
def save(self, *args, **kwargs):
self.clean()
return super().save(*args, **kwargs)
@property
def type(self):
return _('Template')
class Meta:
verbose_name = _("recurrent transaction")
verbose_name_plural = _("recurrent transactions")
class SpecialTransaction(Transaction):
"""
Special type of :model:`note.Transaction` associated to transactions with special notes
"""
last_name = models.CharField(
max_length=255,
verbose_name=_("name"),
@ -312,6 +310,15 @@ class SpecialTransaction(Transaction):
blank=True,
)
class Meta:
verbose_name = _("Special transaction")
verbose_name_plural = _("Special transactions")
@transaction.atomic
def save(self, *args, **kwargs):
self.clean()
super().save(*args, **kwargs)
@property
def type(self):
return _('Credit') if isinstance(self.source, NoteSpecial) else _("Debit")
@ -325,13 +332,8 @@ class SpecialTransaction(Transaction):
def clean(self):
# SpecialTransaction are only possible with NoteSpecial object
if self.is_credit() == self.is_debit():
raise(ValidationError(_("A special transaction is only possible between a"
" Note associated to a payment method and a User or a Club")))
@transaction.atomic
def save(self, *args, **kwargs):
self.clean()
super().save(*args, **kwargs)
raise ValidationError(_("A special transaction is only possible between a"
" Note associated to a payment method and a User or a Club"))
@staticmethod
def validate_payment_form(form):
@ -363,17 +365,11 @@ class SpecialTransaction(Transaction):
return not error
class Meta:
verbose_name = _("Special transaction")
verbose_name_plural = _("Special transactions")
class MembershipTransaction(Transaction):
"""
Special type of :model:`note.Transaction` associated to a :model:`member.Membership`.
"""
membership = models.OneToOneField(
'member.Membership',
on_delete=models.PROTECT,

View File

@ -221,7 +221,7 @@ function consume (source, source_alias, dest, quantity, amount, reason, type, ca
.done(function () {
if (!isNaN(source.balance)) {
const newBalance = source.balance - quantity * amount
if (newBalance <= -5000) {
if (newBalance <= -2000) {
addMsg(interpolate(gettext('Warning, the transaction from the note %s succeed, ' +
'but the emitter note %s is very negative.'), [source_alias, source_alias]), 'danger', 30000)
} else if (newBalance < 0) {
@ -258,3 +258,39 @@ function consume (source, source_alias, dest, quantity, amount, reason, type, ca
})
})
}
var searchbar = document.getElementById("search-input")
var search_results = document.getElementById("search-results")
var old_pattern = null;
var firstMatch = null;
/**
* Updates the button search tab
* @param force Forces the update even if the pattern didn't change
*/
function updateSearch(force = false) {
let pattern = searchbar.value
if (pattern === "")
firstMatch = null;
if ((pattern === old_pattern || pattern === "") && !force)
return;
firstMatch = null;
const re = new RegExp(pattern, "i");
Array.from(search_results.children).forEach(function(b) {
if (re.test(b.innerText)) {
b.hidden = false;
if (firstMatch === null) {
firstMatch = b;
}
} else
b.hidden = true;
});
}
searchbar.addEventListener("input", function (e) {
debounce(updateSearch)()
});
searchbar.addEventListener("keyup", function (e) {
if (firstMatch && e.key === "Enter")
firstMatch.click()
});

View File

@ -314,7 +314,7 @@ $('#btn_transfer').click(function () {
if (!isNaN(source.note.balance)) {
const newBalance = source.note.balance - source.quantity * dest.quantity * amount
if (newBalance <= -5000) {
if (newBalance <= -2000) {
addMsg(interpolate(gettext('Warning, the transaction of %s from the note %s to the note %s succeed, but the emitter note %s is very negative.'),
[pretty_money(source.quantity * dest.quantity * amount), source.name, dest.name, source.name]), 'danger', 10000)
reset()

View File

@ -159,11 +159,11 @@ class TrustTable(tables.Table):
template_name = 'django_tables2/bootstrap4.html'
show_header = False
trusted = tables.Column(attrs={'td': {'class': 'text_center'}})
trusted = tables.Column(attrs={'td': {'class': 'text-center'}})
delete_col = tables.TemplateColumn(
template_code=DELETE_TEMPLATE,
extra_context={"delete_trans": _('delete')},
extra_context={"delete_trans": _('Delete')},
attrs={
'td': {
'class': lambda record: 'col-sm-1'
@ -173,6 +173,46 @@ class TrustTable(tables.Table):
verbose_name=_("Delete"),)
class TrustedTable(tables.Table):
class Meta:
attrs = {
'class': 'table table condensed table-striped',
'id': 'trusted_table'
}
Model = Trust
fields = ("trusting",)
template_name = "django_tables2/bootstrap4.html"
show_header = False
trusting = tables.Column(attrs={
'td': {'class': 'text-center', 'width': '100%'}})
trust_back = tables.Column(
verbose_name=_("Trust back"),
accessor="pk",
attrs={
'td': {
'class': '',
'id': lambda record: "trust_back_" + str(record.pk),
}
},
)
def render_trust_back(self, record):
user_note = record.trusted
trusting_note = record.trusting
if Trust.objects.filter(trusted=trusting_note, trusting=user_note):
return ""
val = '<button id="'
val += str(record.pk)
val += '" class="btn btn-success btn-sm text-nowrap" \
onclick="create_trust(' + str(record.trusted.pk) + ',' + \
str(record.trusting.pk) + ')">'
val += str(_("Add back"))
val += '</button>'
return mark_safe(val)
class AliasTable(tables.Table):
class Meta:
attrs = {

View File

@ -40,7 +40,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{# User search with autocompletion #}
<div class="card-footer">
<input class="form-control mx-auto d-block"
placeholder="{% trans "Name or alias" %}" type="text" id="note" autofocus />
placeholder="{% trans "Name or alias..." %}" type="text" id="note" autofocus />
</div>
</div>
</div>
@ -103,6 +103,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
</a>
</li>
{% endfor %}
<li class="nav-item">
<a class="nav-link font-weight-bold" data-toggle="tab" href="#search">
{% trans "Search" %}
</a>
</li>
</ul>
</div>
@ -123,6 +128,20 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div>
</div>
{% endfor %}
<div class="tab-pane" id="search">
<input class="form-control mx-auto d-block mb-3"
placeholder="{% trans "Search button..." %}" type="search" id="search-input"/>
<div class="d-inline-flex flex-wrap justify-content-center" id="search-results">
{% for button in all_buttons %}
{% if button.display %}
<button class="btn btn-outline-dark rounded-0 flex-fill" hidden
id="search_button{{ button.id }}" name="button" value="{{ button.name }}">
{{ button.name }} ({{ button.amount | pretty_money }})
</button>
{% endif %}
{% endfor %}
</div>
</div>
</div>
</div>
@ -163,7 +182,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<script type="text/javascript">
{% for button in highlighted %}
{% if button.display %}
$("#highlighted_button{{ button.id }}").click(function() {
document.getElementById("highlighted_button{{ button.id }}").addEventListener("click", function() {
addConso({{ button.destination_id }}, {{ button.amount }},
{{ polymorphic_ctype }}, {{ button.category_id }}, "{{ button.category.name|escapejs }}",
{{ button.id }}, "{{ button.name|escapejs }}");
@ -174,7 +193,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% for category in categories %}
{% for button in category.templates_filtered %}
{% if button.display %}
$("#button{{ button.id }}").click(function() {
document.getElementById("button{{ button.id }}").addEventListener("click", function() {
addConso({{ button.destination_id }}, {{ button.amount }},
{{ polymorphic_ctype }}, {{ button.category_id }}, "{{ button.category.name|escapejs }}",
{{ button.id }}, "{{ button.name|escapejs }}");
@ -182,5 +201,15 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endif %}
{% endfor %}
{% endfor %}
{% for button in all_buttons %}
{% if button.display %}
document.getElementById("search_button{{ button.id }}").addEventListener("click", function() {
addConso({{ button.destination_id }}, {{ button.amount }},
{{ polymorphic_ctype }}, {{ button.category_id }}, "{{ button.category.name|escapejs }}",
{{ button.id }}, "{{ button.name|escapejs }}");
});
{% endif %}
{% endfor %}
</script>
{% endblock %}

View File

@ -22,7 +22,7 @@
</p>
<p>
Par ailleurs, le BDE ne sert pas d'alcool aux adhérent⋅es dont le solde
Par ailleurs, le BDE ne sert pas d'alcool aux adhérents dont le solde
est inférieur à 0 € depuis plus de 24h.
</p>

View File

@ -9,7 +9,7 @@ Ce mail t'a été envoyé parce que le solde de ta Note Kfet
Ton solde actuel est de {{ note.balance|pretty_money }}.
Par ailleurs, le BDE ne sert pas d'alcool aux adhérent⋅es dont le solde
Par ailleurs, le BDE ne sert pas d'alcool aux adhérents dont le solde
est inférieur à 0 € depuis plus de 24h.
Si tu ne comprends pas ton solde, tu peux consulter ton historique

View File

@ -66,7 +66,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<option value="{{ special_type.id }}">{{ special_type.special_type }}</option>
{% endfor %}
</select>
<input class="form-control mx-auto" type="text" id="source_note" placeholder="{% trans "Name or alias" %}" />
<input class="form-control mx-auto" type="text" id="source_note" placeholder="{% trans "Name or alias..." %}" />
<div id="source_me_div">
<hr>
<a class="btn-block btn btn-secondary" href="#" id="source_me" data-turbolinks="false">
@ -93,14 +93,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
<option value="{{ special_type.id }}">{{ special_type.special_type }}</option>
{% endfor %}
</select>
<input class="form-control mx-auto" type="text" id="dest_note" placeholder="{% trans "Name or alias" %}" />
<input class="form-control mx-auto" type="text" id="dest_note" placeholder="{% trans "Name or alias..." %}" />
<ul class="list-group list-group-flush" id="dest_alias_matched">
</ul>
</div>
</div>
</div>
{# Information on transaction (amount, reason, name,) #}
{# Information on transaction (amount, reason, name,...) #}
<div class="col-md" id="external_div">
<div class="card bg-light mb-4">
<div class="card-header">

View File

@ -10,7 +10,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div class="row justify-content-center mb-4">
<div class="col-md-10 text-center">
{# Search field , see js #}
<input class="form-control mx-auto w-25" type="text" id="search_field" placeholder="{% trans "Name of the button" %}" value="{{ request.GET.search }}">
<input class="form-control mx-auto w-25" type="text" id="search_field" placeholder="{% trans "Name of the button..." %}" value="{{ request.GET.search }}">
<hr>
<a class="btn btn-primary text-center my-1" href="{% url 'note:template_create' %}" data-turbolinks="false">{% trans "New button" %}</a>
</div>
@ -19,7 +19,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div class="col-md-12">
<div class="card card-border shadow">
<div class="card-header text-center">
<h5>{% trans "buttons listing"%}</h5>
<h5> {% trans "buttons listing "%}</h5>
</div>
<div class="card-body px-0 py-0" id="buttons_table">
{% render_table table %}
@ -70,11 +70,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
headers: {"X-CSRFTOKEN": CSRF_TOKEN}
})
.done(function() {
addMsg('{% trans "button successfully deleted"%}','success');
addMsg('{% trans "button successfully deleted "%}','success');
$("#buttons_table").load(location.pathname + "?search=" + $("#search_field").val().replace(" ", "%20") + " #buttons_table");
})
.fail(function() {
addMsg('{% trans "Unable to delete button"%} #' + button_id, 'danger')
addMsg('{% trans "Unable to delete button "%} #' + button_id, 'danger')
});
}

View File

@ -10,12 +10,12 @@ from django.core.exceptions import PermissionDenied
from django.db.models import Q, F
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, UpdateView, DetailView
from django_tables2 import SingleTableView
from django.urls import reverse_lazy
from django_tables2 import SingleTableView
from activity.models import Entry
from note_kfet.inputs import AmountInput
from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin
from note_kfet.inputs import AmountInput
from .forms import TransactionTemplateForm, SearchTransactionForm
from .models import TemplateCategory, Transaction, TransactionTemplate, RecurrentTransaction, NoteSpecial, Note
@ -190,6 +190,10 @@ class ConsoView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
).order_by('name').all()
context['polymorphic_ctype'] = ContentType.objects.get_for_model(RecurrentTransaction).pk
context['all_buttons'] = TransactionTemplate.objects.filter(
PermissionBackend.filter_queryset(self.request, TransactionTemplate, "view")
).filter(display=True).order_by('name').all()
return context

View File

@ -198,6 +198,41 @@ class PermissionBackend(ModelBackend):
def has_module_perms(self, user_obj, app_label):
return False
@staticmethod
@memoize
def has_model_perm(request, model, type):
"""
Check is the given user has the permission over a given model for a given action.
The result is then memoized.
:param request: The current request
:param model: The model that the permissions shoud apply
:param type: The type of the permissions: view, change, add or delete
For view action, it is consider possible if user can view or change the model
"""
# Requested by a shell
if request is None:
return False
user_obj = request.user
sess = request.session
if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'):
# OAuth2 Authentication
user_obj = request.auth.user
if user_obj is None or user_obj.is_anonymous:
return False
if user_obj.is_superuser and sess.get("permission_mask", -1) >= 42:
return True
ct = ContentType.objects.get_for_model(model)
if any(PermissionBackend.permissions(request, ct, type)):
return True
if type == "view" and any(PermissionBackend.permissions(request, ct, "change")):
return True
return False
def get_all_permissions(self, user_obj, obj=None):
ct = ContentType.objects.get_for_model(obj)
return list(self.permissions(get_current_request(), ct, "view"))

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2.28 on 2023-07-24 10:15
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('permission', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='role',
name='for_club',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.PROTECT, to='member.Club', verbose_name='for club'),
),
]

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import functools
@ -26,6 +26,15 @@ class InstancedPermission:
self.mask = mask
self.kwargs = kwargs
def __repr__(self):
if self.field:
return _("Can {type} {model}.{field} in {query}").format(type=self.type, model=self.model, field=self.field, query=self.query)
else:
return _("Can {type} {model} in {query}").format(type=self.type, model=self.model, query=self.query)
def __str__(self):
return self.__repr__()
def applies(self, obj, permission_type, field_name=None):
"""
Returns True if the permission applies to
@ -59,7 +68,7 @@ class InstancedPermission:
# Force insertion, no data verification, no trigger
obj._force_save = True
# We don't want to trigger any signal (log,)
# We don't want to trigger any signal (log, ...)
obj._no_signal = True
Model.save(obj, force_insert=True)
ret = self.model.model_class().objects.filter(self.query & Q(pk=0)).exists()
@ -84,21 +93,11 @@ class InstancedPermission:
# noinspection PyProtectedMember
self.query = Permission._about(self.raw_query, **self.kwargs)
def __repr__(self):
if self.field:
return _("Can {type} {model}.{field} in {query}").format(type=self.type, model=self.model, field=self.field, query=self.query)
else:
return _("Can {type} {model} in {query}").format(type=self.type, model=self.model, query=self.query)
def __str__(self):
return self.__repr__()
class PermissionMask(models.Model):
"""
Permissions that are hidden behind a mask
"""
rank = models.PositiveSmallIntegerField(
unique=True,
verbose_name=_('rank'),
@ -110,13 +109,13 @@ class PermissionMask(models.Model):
verbose_name=_('description'),
)
def __str__(self):
return self.description
class Meta:
verbose_name = _("permission mask")
verbose_name_plural = _("permission masks")
def __str__(self):
return self.description
class Permission(models.Model):
@ -194,16 +193,19 @@ class Permission(models.Model):
verbose_name = _("permission")
verbose_name_plural = _("permissions")
def clean(self):
self.query = json.dumps(json.loads(self.query))
if self.field and self.type not in {'view', 'change'}:
raise ValidationError(_("Specifying field applies only to view and change permission types."))
def __str__(self):
return self.description
@transaction.atomic
def save(self, **kwargs):
self.full_clean()
super().save()
def clean(self):
self.query = json.dumps(json.loads(self.query))
if self.field and self.type not in {'view', 'change'}:
raise ValidationError(_("Specifying field applies only to view and change permission types."))
@staticmethod
def compute_f(oper, **kwargs):
if isinstance(oper, list):
@ -227,7 +229,7 @@ class Permission(models.Model):
def compute_param(value, **kwargs):
"""
A parameter is given by a list. The first argument is the name of the parameter.
The parameters are the user, the club, and some classes (Note,)
The parameters are the user, the club, and some classes (Note, ...)
If there are more arguments in the list, then attributes are queried.
For example, ["user", "note", "balance"] will return the balance of the note of the user.
If an argument is a list, then this is interpreted with a function call:
@ -317,9 +319,6 @@ class Permission(models.Model):
# query = self._about(query, **kwargs)
return InstancedPermission(self.model, query, self.type, self.field, self.mask, **kwargs)
def __str__(self):
return self.description
class Role(models.Model):
"""
@ -339,13 +338,14 @@ class Role(models.Model):
"member.Club",
verbose_name=_("for club"),
on_delete=models.PROTECT,
blank=True,
null=True,
default=None,
)
def __str__(self):
return self.name
class Meta:
verbose_name = _("role permissions")
verbose_name_plural = _("role permissions")
def __str__(self):
return self.name

View File

@ -36,8 +36,8 @@ class RightsTable(tables.Table):
def render_roles(self, record):
# If the user has the right to manage the roles, display the link to manage them
roles = record.roles.filter((~(Q(name="Adhérent⋅e BDE")
| Q(name="Adhérent⋅e Kfet")
roles = record.roles.filter((~(Q(name="Adhérent BDE")
| Q(name="Adhérent Kfet")
| Q(name="Membre de club")
| Q(name="Bureau de club"))
& Q(weirole__isnull=True))).all()

View File

@ -58,7 +58,7 @@ class OAuth2TestCase(TestCase):
# Create membership to validate permissions
NoteUser.objects.create(user=self.user)
membership = Membership.objects.create(user=self.user, club_id=bde.pk)
membership.roles.add(Role.objects.get(name="Adhérent⋅e BDE"))
membership.roles.add(Role.objects.get(name="Adhérent BDE"))
membership.save()
# User is now a member and can now see its own user detail
@ -85,7 +85,7 @@ class OAuth2TestCase(TestCase):
bde = Club.objects.get(name="BDE")
NoteUser.objects.create(user=self.user)
membership = Membership.objects.create(user=self.user, club_id=bde.pk)
membership.roles.add(Role.objects.get(name="Adhérent⋅e BDE"))
membership.roles.add(Role.objects.get(name="Adhérent BDE"))
membership.save()
resp = self.client.get(reverse('permission:scopes'))

View File

@ -131,8 +131,8 @@ class RightsView(TemplateView):
special_memberships = Membership.objects.filter(
date_start__lte=date.today(),
date_end__gte=date.today(),
).filter(roles__in=Role.objects.filter((~(Q(name="Adhérent⋅e BDE")
| Q(name="Adhérent⋅e Kfet")
).filter(roles__in=Role.objects.filter((~(Q(name="Adhérent BDE")
| Q(name="Adhérent Kfet")
| Q(name="Membre de club")
| Q(name="Bureau de club"))
& Q(weirole__isnull=True))))\

View File

@ -5,6 +5,7 @@ from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User
from django.utils.translation import gettext_lazy as _
from member.models import Club
from note.models import NoteSpecial, Alias
from note_kfet.inputs import AmountInput
@ -44,14 +45,14 @@ class SignUpForm(UserCreationForm):
fields = ('first_name', 'last_name', 'username', 'email', )
class DeclareSogeAccountOpenedForm(forms.Form):
soge_account = forms.BooleanField(
label=_("I declare that I opened or I will open soon a bank account in the Société générale with the BDE "
"partnership."),
help_text=_("Warning: this engages you to open your bank account. If you finally decides to don't open your "
"account, you will have to pay the BDE membership."),
required=False,
)
# class DeclareSogeAccountOpenedForm(forms.Form):
# soge_account = forms.BooleanField(
# label=_("I declare that I opened or I will open soon a bank account in the Société générale with the BDE "
# "partnership."),
# help_text=_("Warning: this engages you to open your bank account. If you finally decides to don't open your "
# "account, you will have to pay the BDE membership."),
# required=False,
# )
class WEISignupForm(forms.Form):
@ -67,11 +68,11 @@ class ValidationForm(forms.Form):
"""
Validate the inscription of the new users and pay memberships.
"""
soge = forms.BooleanField(
label=_("Inscription paid by Société Générale"),
required=False,
help_text=_("Check this case if the Société Générale paid the inscription."),
)
# soge = forms.BooleanField(
# label=_("Inscription paid by Société Générale"),
# required=False,
# help_text=_("Check this case if the Société Générale paid the inscription."),
# )
credit_type = forms.ModelChoiceField(
queryset=NoteSpecial.objects,
@ -114,3 +115,12 @@ class ValidationForm(forms.Form):
required=False,
initial=True,
)
# If the bda exists
if Club.objects.filter(name__iexact="bda").exists():
# The user can join the bda club at the inscription
join_bda = forms.BooleanField(
label=_("Join BDA Club"),
required=False,
initial=True,
)

View File

@ -57,11 +57,13 @@ SPDX-License-Identifier: GPL-3.0-or-later
<h4> {% trans "Validate account" %}</h4>
</div>
{% comment "Soge not for membership (only WEI)" %}
{% if declare_soge_account %}
<div class="alert alert-info">
{% trans "The user declared that he/she opened a bank account in the Société générale." %}
</div>
{% endif %}
{% endcomment %}
<div class="card-body" id="profile_infos">
{% csrf_token %}
@ -76,6 +78,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div>
{% endblock %}
{% comment "Soge not for membership (only WEI)" %}
{% block extrajavascript %}
<script>
soge_field = $("#id_soge");
@ -118,3 +121,4 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endif %}
</script>
{% endblock %}
{% endcomment %}

View File

@ -48,6 +48,7 @@ class TestSignup(TestCase):
ml_events_registration="en",
ml_sport_registration=True,
ml_art_registration=True,
VSS_charter_read=True
))
self.assertRedirects(response, reverse("registration:email_validation_sent"), 302, 200)
self.assertTrue(User.objects.filter(username="toto").exists())
@ -105,6 +106,7 @@ class TestSignup(TestCase):
ml_events_registration="en",
ml_sport_registration=True,
ml_art_registration=True,
VSS_charter_read=True
))
self.assertTrue(response.status_code, 200)
@ -124,6 +126,7 @@ class TestSignup(TestCase):
ml_events_registration="en",
ml_sport_registration=True,
ml_art_registration=True,
VSS_charter_read=True
))
self.assertTrue(response.status_code, 200)
@ -143,6 +146,27 @@ class TestSignup(TestCase):
ml_events_registration="en",
ml_sport_registration=True,
ml_art_registration=True,
VSS_charter_read=True
))
self.assertTrue(response.status_code, 200)
# The VSS charter is not read
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="+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,
VSS_charter_read=False
))
self.assertTrue(response.status_code, 200)
@ -190,7 +214,7 @@ class TestValidateRegistration(TestCase):
# BDE Membership is mandatory
response = self.client.post(reverse("registration:future_user_detail", args=(self.user.pk,)), data=dict(
soge=False,
# soge=False,
credit_type=NoteSpecial.objects.get(special_type="Chèque").id,
credit_amount=4200,
last_name="TOTO",
@ -204,7 +228,7 @@ class TestValidateRegistration(TestCase):
# Same
response = self.client.post(reverse("registration:future_user_detail", args=(self.user.pk,)), data=dict(
soge=False,
# soge=False,
credit_type="",
credit_amount=0,
last_name="TOTO",
@ -218,7 +242,7 @@ class TestValidateRegistration(TestCase):
# The BDE membership is not free
response = self.client.post(reverse("registration:future_user_detail", args=(self.user.pk,)), data=dict(
soge=False,
# soge=False,
credit_type=NoteSpecial.objects.get(special_type="Espèces").id,
credit_amount=0,
last_name="TOTO",
@ -232,7 +256,7 @@ class TestValidateRegistration(TestCase):
# 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,
# soge=False,
credit_type=NoteSpecial.objects.get(special_type="Chèque").id,
credit_amount=4000,
last_name="",
@ -249,7 +273,7 @@ class TestValidateRegistration(TestCase):
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,
# soge=False,
credit_type=NoteSpecial.objects.get(special_type="Chèque").id,
credit_amount=500,
last_name="TOTO",
@ -275,7 +299,7 @@ class TestValidateRegistration(TestCase):
self.user.profile.save()
response = self.client.post(reverse("registration:future_user_detail", args=(self.user.pk,)), data=dict(
soge=False,
# soge=False,
credit_type=NoteSpecial.objects.get(special_type="Chèque").id,
credit_amount=500,
last_name="TOTO",
@ -290,6 +314,7 @@ class TestValidateRegistration(TestCase):
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(Membership.objects.filter(club__name__iexact="BDA", 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)
@ -311,7 +336,7 @@ class TestValidateRegistration(TestCase):
self.user.profile.save()
response = self.client.post(reverse("registration:future_user_detail", args=(self.user.pk,)), data=dict(
soge=False,
# soge=False,
credit_type=NoteSpecial.objects.get(special_type="Espèces").id,
credit_amount=4000,
last_name="TOTO",
@ -326,6 +351,7 @@ class TestValidateRegistration(TestCase):
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(Membership.objects.filter(club__name__iexact="BDA", 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)
@ -333,42 +359,43 @@ class TestValidateRegistration(TestCase):
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(), 3)
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_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.assertFalse(Membership.objects.filter(club__name__iexact="BDA", 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(), 3)
# 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):
"""

View File

@ -24,7 +24,8 @@ from permission.models import Role
from permission.views import ProtectQuerysetMixin
from treasury.models import SogeCredit
from .forms import SignUpForm, ValidationForm, DeclareSogeAccountOpenedForm
# from .forms import SignUpForm, ValidationForm, DeclareSogeAccountOpenedForm
from .forms import SignUpForm, ValidationForm
from .tables import FutureUserTable
from .tokens import email_validation_token
@ -42,7 +43,7 @@ class UserCreateView(CreateView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["profile_form"] = self.second_form(self.request.POST if self.request.POST else None)
context["soge_form"] = DeclareSogeAccountOpenedForm(self.request.POST if self.request.POST else None)
# context["soge_form"] = DeclareSogeAccountOpenedForm(self.request.POST if self.request.POST else None)
del context["profile_form"].fields["section"]
del context["profile_form"].fields["report_frequency"]
del context["profile_form"].fields["last_report"]
@ -75,12 +76,12 @@ class UserCreateView(CreateView):
user.profile.send_email_validation_link()
soge_form = DeclareSogeAccountOpenedForm(self.request.POST)
if "soge_account" in soge_form.data and soge_form.data["soge_account"]:
# If the user declares that a bank account got opened, prepare the soge credit to warn treasurers
soge_credit = SogeCredit(user=user)
soge_credit._force_save = True
soge_credit.save()
# soge_form = DeclareSogeAccountOpenedForm(self.request.POST)
# if "soge_account" in soge_form.data and soge_form.data["soge_account"]:
# # If the user declares that a bank account got opened, prepare the soge credit to warn treasurers
# soge_credit = SogeCredit(user=user)
# soge_credit._force_save = True
# soge_credit.save()
return super().form_valid(form)
@ -237,9 +238,12 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
fee += bde.membership_fee_paid if user.profile.paid else bde.membership_fee_unpaid
kfet = Club.objects.get(name="Kfet")
fee += kfet.membership_fee_paid if user.profile.paid else kfet.membership_fee_unpaid
if Club.objects.filter(name__iexact="BDA").exists():
bda = Club.objects.get(name__iexact="BDA")
fee += bda.membership_fee_paid if user.profile.paid else bda.membership_fee_unpaid
ctx["total_fee"] = "{:.02f}".format(fee / 100, )
ctx["declare_soge_account"] = SogeCredit.objects.filter(user=user).exists()
# ctx["declare_soge_account"] = SogeCredit.objects.filter(user=user).exists()
return ctx
@ -262,8 +266,13 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
form.add_error(None, _("An alias with a similar name already exists."))
return self.form_invalid(form)
# Check if BDA exist to propose membership at regisration
bda_exists = False
if Club.objects.filter(name__iexact="BDA").exists():
bda_exists = True
# Get form data
soge = form.cleaned_data["soge"]
# soge = form.cleaned_data["soge"]
credit_type = form.cleaned_data["credit_type"]
credit_amount = form.cleaned_data["credit_amount"]
last_name = form.cleaned_data["last_name"]
@ -271,11 +280,13 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
bank = form.cleaned_data["bank"]
join_bde = form.cleaned_data["join_bde"]
join_kfet = form.cleaned_data["join_kfet"]
if bda_exists:
join_bda = form.cleaned_data["join_bda"]
if soge:
# If Société Générale pays the inscription, the user automatically joins the two clubs.
join_bde = True
join_kfet = True
# if soge:
# # If Société Générale pays the inscription, the user automatically joins the two clubs.
# join_bde = True
# join_kfet = True
if not join_bde:
# This software belongs to the BDE.
@ -292,15 +303,21 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
kfet_fee = kfet.membership_fee_paid if user.profile.paid else kfet.membership_fee_unpaid
# Add extra fee for the full membership
fee += kfet_fee if join_kfet else 0
if bda_exists:
bda = Club.objects.get(name__iexact="BDA")
bda_fee = bda.membership_fee_paid if user.profile.paid else bda.membership_fee_unpaid
# Add extra fee for the bda membership
fee += bda_fee if join_bda else 0
# If the bank pays, then we don't credit now. Treasurers will validate the transaction
# and credit the note later.
credit_type = None if soge else credit_type
# # If the bank pays, then we don't credit now. Treasurers will validate the transaction
# # and credit the note later.
# credit_type = None if soge else credit_type
# If the user does not select any payment method, then no credit will be performed.
credit_amount = 0 if credit_type is None else credit_amount
if fee > credit_amount and not soge:
# if fee > credit_amount and not soge:
if fee > credit_amount:
# Check if the user credits enough money
form.add_error('credit_type',
_("The entered amount is not enough for the memberships, should be at least {}")
@ -320,12 +337,12 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
user.profile.save()
user.refresh_from_db()
if not soge and SogeCredit.objects.filter(user=user).exists():
# If the user declared that a bank account was opened but in the validation form the SoGé case was
# unchecked, delete the associated credit
soge_credit = SogeCredit.objects.get(user=user)
soge_credit._force_delete = True
soge_credit.delete()
# if not soge and SogeCredit.objects.filter(user=user).exists():
# # If the user declared that a bank account was opened but in the validation form the SoGé case was
# # unchecked, delete the associated credit
# soge_credit = SogeCredit.objects.get(user=user)
# soge_credit._force_delete = True
# soge_credit.delete()
if credit_type is not None and credit_amount > 0:
# Credit the note
@ -334,7 +351,8 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
destination=user.note,
quantity=1,
amount=credit_amount,
reason="Crédit " + ("Société générale" if soge else credit_type.special_type) + " (Inscription)",
reason="Crédit " + credit_type.special_type + " (Inscription)",
# reason="Crédit " + ("Société générale" if soge else credit_type.special_type) + " (Inscription)",
last_name=last_name,
first_name=first_name,
bank=bank,
@ -348,11 +366,11 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
user=user,
fee=bde_fee,
)
if soge:
membership._soge = True
# if soge:
# membership._soge = True
membership.save()
membership.refresh_from_db()
membership.roles.add(Role.objects.get(name="Adhérent⋅e BDE"))
membership.roles.add(Role.objects.get(name="Adhérent BDE"))
membership.save()
if join_kfet:
@ -362,17 +380,29 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
user=user,
fee=kfet_fee,
)
if soge:
membership._soge = True
# if soge:
# membership._soge = True
membership.save()
membership.refresh_from_db()
membership.roles.add(Role.objects.get(name="Adhérent⋅e Kfet"))
membership.roles.add(Role.objects.get(name="Adhérent Kfet"))
membership.save()
if soge:
soge_credit = SogeCredit.objects.get(user=user)
# Update the credit transaction amount
soge_credit.save()
if bda_exists and join_bda:
# Create membership for the user to the BDA starting today
membership = Membership(
club=bda,
user=user,
fee=bda_fee,
)
membership.save()
membership.refresh_from_db()
membership.roles.add(Role.objects.get(name="Membre de club"))
membership.save()
# if soge:
# soge_credit = SogeCredit.objects.get(user=user)
# # Update the credit transaction amount
# soge_credit.save()
return ret

View File

@ -174,7 +174,7 @@ class SogeCreditForm(forms.ModelForm):
attrs={
'api_url': '/api/user/',
'name_field': 'username',
'placeholder': 'Nom',
'placeholder': 'Nom ...',
},
),
}

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2023-01-29 22:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('treasury', '0004_auto_20211005_1544'),
]
operations = [
migrations.AlterField(
model_name='invoice',
name='bde',
field=models.CharField(choices=[('TotalistSpies', 'Tota[list]Spies'), ('Saperlistpopette', 'Saper[list]popette'), ('Finalist', 'Fina[list]'), ('Listorique', '[List]orique'), ('Satellist', 'Satel[list]'), ('Monopolist', 'Monopo[list]'), ('Kataclist', 'Katac[list]')], default='TotalistSpies', max_length=32, verbose_name='BDE'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2023-04-14 14:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('treasury', '0005_auto_20230129_2348'),
]
operations = [
migrations.AlterField(
model_name='invoice',
name='bde',
field=models.CharField(choices=[('SecretStorlist', 'SecretStor[list]'), ('TotalistSpies', 'Tota[list]Spies'), ('Saperlistpopette', 'Saper[list]popette'), ('Finalist', 'Fina[list]'), ('Listorique', '[List]orique'), ('Satellist', 'Satel[list]'), ('Monopolist', 'Monopo[list]'), ('Kataclist', 'Katac[list]')], default='SecretStorlist', max_length=32, verbose_name='BDE'),
),
]

View File

@ -1,6 +1,5 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import datetime
from datetime import date
from django.conf import settings
@ -12,7 +11,8 @@ from django.db.models import Q
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from member.models import Club, Membership
# from member.models import Club, Membership # Club unused because of disabled soge
from member.models import Membership
from note.models import NoteSpecial, SpecialTransaction, MembershipTransaction, NoteUser
@ -20,7 +20,6 @@ class Invoice(models.Model):
"""
An invoice model that can generates a true invoice.
"""
id = models.PositiveIntegerField(
primary_key=True,
verbose_name=_("Invoice identifier"),
@ -28,8 +27,10 @@ class Invoice(models.Model):
bde = models.CharField(
max_length=32,
default='Saperlistpopette',
default='SecretStorlist',
choices=(
('SecretStorlist', 'SecretStor[list]'),
('TotalistSpies', 'Tota[list]Spies'),
('Saperlistpopette', 'Saper[list]popette'),
('Finalist', 'Fina[list]'),
('Listorique', '[List]orique'),
@ -79,6 +80,13 @@ class Invoice(models.Model):
verbose_name=_("tex source"),
)
class Meta:
verbose_name = _("invoice")
verbose_name_plural = _("invoices")
def __str__(self):
return _("Invoice #{id}").format(id=self.id)
@transaction.atomic
def save(self, *args, **kwargs):
"""
@ -95,7 +103,7 @@ class Invoice(models.Model):
products = self.products.all()
self.place = "Gif-sur-Yvette"
self.my_name = "BDE ENS Cachan"
self.my_name = "BDE ENS Paris Saclay"
self.my_address_street = "4 avenue des Sciences"
self.my_city = "91190 Gif-sur-Yvette"
self.bank_code = 30003
@ -109,19 +117,11 @@ class Invoice(models.Model):
return super().save(*args, **kwargs)
class Meta:
verbose_name = _("invoice")
verbose_name_plural = _("invoices")
def __str__(self):
return _("Invoice #{id}").format(id=self.id)
class Product(models.Model):
"""
Product that appears on an invoice.
"""
invoice = models.ForeignKey(
Invoice,
on_delete=models.CASCADE,
@ -145,6 +145,13 @@ class Product(models.Model):
verbose_name=_("Unit price"),
)
class Meta:
verbose_name = _("product")
verbose_name_plural = _("products")
def __str__(self):
return f"{self.designation} ({self.invoice})"
@property
def amount_euros(self):
return "{:.2f}".format(self.amount / 100)
@ -157,37 +164,28 @@ class Product(models.Model):
def total_euros(self):
return "{:.2f}".format(self.total / 100)
class Meta:
verbose_name = _("product")
verbose_name_plural = _("products")
def __str__(self):
return f"{self.designation} ({self.invoice})"
class RemittanceType(models.Model):
"""
Store what kind of remittances can be stored.
"""
note = models.OneToOneField(
NoteSpecial,
on_delete=models.CASCADE,
)
def __str__(self):
return str(self.note)
class Meta:
verbose_name = _("remittance type")
verbose_name_plural = _("remittance types")
def __str__(self):
return str(self.note)
class Remittance(models.Model):
"""
Treasurers want to regroup checks or bank transfers in bank remittances.
"""
date = models.DateTimeField(
default=timezone.now,
verbose_name=_("Date"),
@ -213,6 +211,17 @@ class Remittance(models.Model):
verbose_name = _("remittance")
verbose_name_plural = _("remittances")
def __str__(self):
return _("Remittance #{:d}: {}").format(self.id, self.comment, )
@transaction.atomic
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.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)
@property
def transactions(self):
"""
@ -235,17 +244,6 @@ class Remittance(models.Model):
"""
return sum(transaction.total for transaction in self.transactions.all())
@transaction.atomic
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.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)
def __str__(self):
return _("Remittance #{:d}: {}").format(self.id, self.comment, )
class SpecialTransactionProxy(models.Model):
"""
@ -253,7 +251,6 @@ class SpecialTransactionProxy(models.Model):
That's why we create a proxy in this app, to link special transactions and remittances.
If it isn't very clean, it does what we want.
"""
transaction = models.OneToOneField(
SpecialTransaction,
on_delete=models.CASCADE,
@ -299,94 +296,12 @@ class SogeCredit(models.Model):
null=True,
)
@property
def valid(self):
return self.credit_transaction and self.credit_transaction.valid
class Meta:
verbose_name = _("Credit from the Société générale")
verbose_name_plural = _("Credits from the Société générale")
@property
def amount(self):
if self.valid:
return self.credit_transaction.total
amount = sum(transaction.total for transaction in self.transactions.all())
if 'wei' in settings.INSTALLED_APPS:
from wei.models import WEIMembership
if not WEIMembership.objects.filter(club__weiclub__year=datetime.date.today().year, user=self.user)\
.exists():
# 80 € for people that don't go to WEI
amount += 8000
return amount
def update_transactions(self):
"""
The Sogé credit may be created after the user already paid its memberships.
We query transactions and update the credit, if it is unvalid.
"""
if self.valid or not self.pk:
return
bde = Club.objects.get(name="BDE")
kfet = Club.objects.get(name="Kfet")
bde_qs = Membership.objects.filter(user=self.user, club=bde, date_start__gte=bde.membership_start)
kfet_qs = Membership.objects.filter(user=self.user, club=kfet, date_start__gte=kfet.membership_start)
if bde_qs.exists():
m = bde_qs.get()
if MembershipTransaction.objects.filter(membership=m).exists(): # non-free membership
if m.transaction not in self.transactions.all():
self.transactions.add(m.transaction)
if kfet_qs.exists():
m = kfet_qs.get()
if MembershipTransaction.objects.filter(membership=m).exists(): # non-free membership
if m.transaction not in self.transactions.all():
self.transactions.add(m.transaction)
if 'wei' in settings.INSTALLED_APPS:
from wei.models import WEIClub
wei = WEIClub.objects.order_by('-year').first()
wei_qs = Membership.objects.filter(user=self.user, club=wei, date_start__gte=wei.membership_start)
if wei_qs.exists():
m = wei_qs.get()
if MembershipTransaction.objects.filter(membership=m).exists(): # non-free membership
if m.transaction not in self.transactions.all():
self.transactions.add(m.transaction)
for tr in self.transactions.all():
tr.valid = False
tr.save()
def invalidate(self):
"""
Invalidating a Société générale delete the transaction of the bank if it was already created.
Treasurers must know what they do, With Great Power Comes Great Responsibility…
"""
if self.valid:
self.credit_transaction.valid = False
self.credit_transaction.save()
for tr in self.transactions.all():
tr.valid = False
tr._force_save = True
tr.save()
def validate(self, force=False):
if self.valid and not force:
# The credit is already done
return
# First invalidate all transaction and delete the credit if already did (and force mode)
self.invalidate()
# Refresh credit amount
self.save()
self.credit_transaction.valid = True
self.credit_transaction._force_save = True
self.credit_transaction.save()
self.save()
for tr in self.transactions.all():
tr.valid = True
tr._force_save = True
tr.created_at = timezone.now()
tr.save()
def __str__(self):
return _("Soge credit for {user}").format(user=str(self.user))
@transaction.atomic
def save(self, *args, **kwargs):
@ -418,11 +333,100 @@ class SogeCredit(models.Model):
return super().save(*args, **kwargs)
@property
def valid(self):
return self.credit_transaction and self.credit_transaction.valid
@property
def amount(self):
if self.valid:
return self.credit_transaction.total
amount = sum(transaction.total for transaction in self.transactions.all())
if 'wei' in settings.INSTALLED_APPS:
from wei.models import WEIMembership
if not WEIMembership.objects\
.filter(club__weiclub__year=self.credit_transaction.created_at.year, user=self.user).exists():
# 80 € for people that don't go to WEI
amount += 8000
return amount
def update_transactions(self):
"""
The Sogé credit may be created after the user already paid its memberships.
We query transactions and update the credit, if it is unvalid.
"""
if self.valid or not self.pk:
return
# Soge do not pay BDE and kfet memberships since 2022
# bde = Club.objects.get(name="BDE")
# kfet = Club.objects.get(name="Kfet")
# bde_qs = Membership.objects.filter(user=self.user, club=bde, date_start__gte=bde.membership_start)
# kfet_qs = Membership.objects.filter(user=self.user, club=kfet, date_start__gte=kfet.membership_start)
# if bde_qs.exists():
# m = bde_qs.get()
# if MembershipTransaction.objects.filter(membership=m).exists(): # non-free membership
# if m.transaction not in self.transactions.all():
# self.transactions.add(m.transaction)
#
# if kfet_qs.exists():
# m = kfet_qs.get()
# if MembershipTransaction.objects.filter(membership=m).exists(): # non-free membership
# if m.transaction not in self.transactions.all():
# self.transactions.add(m.transaction)
if 'wei' in settings.INSTALLED_APPS:
from wei.models import WEIClub
wei = WEIClub.objects.order_by('-year').first()
wei_qs = Membership.objects.filter(user=self.user, club=wei, date_start__gte=wei.membership_start)
if wei_qs.exists():
m = wei_qs.get()
if MembershipTransaction.objects.filter(membership=m).exists(): # non-free membership
if m.transaction not in self.transactions.all():
self.transactions.add(m.transaction)
for tr in self.transactions.all():
tr.valid = False
tr.save()
def invalidate(self):
"""
Invalidating a Société générale delete the transaction of the bank if it was already created.
Treasurers must know what they do, With Great Power Comes Great Responsibility...
"""
if self.valid:
self.credit_transaction.valid = False
self.credit_transaction.save()
for tr in self.transactions.all():
tr.valid = False
tr._force_save = True
tr.save()
def validate(self, force=False):
if self.valid and not force:
# The credit is already done
return
# First invalidate all transaction and delete the credit if already did (and force mode)
self.invalidate()
# Refresh credit amount
self.save()
self.credit_transaction.valid = True
self.credit_transaction._force_save = True
self.credit_transaction.save()
self.save()
for tr in self.transactions.all():
tr.valid = True
tr._force_save = True
tr.save()
def delete(self, **kwargs):
"""
Deleting a SogeCredit is equivalent to say that the Société générale didn't pay.
Treasurers must know what they do, this is difficult to undo this operation.
With Great Power Comes Great Responsibility
With Great Power Comes Great Responsibility...
"""
total_fee = sum(transaction.total for transaction in self.transactions.all() if not transaction.valid)
@ -434,22 +438,14 @@ class SogeCredit(models.Model):
for tr in self.transactions.all():
tr._force_save = True
tr.valid = True
tr.created_at = timezone.now()
tr.save()
if self.credit_transaction:
# If the soge credit is deleted while the user is not validated yet,
# there is not credit transaction.
# There is a credit transaction iff the user declares that no bank account
# There is a credit transaction if the user declares that no bank account
# was opened after the validation of the account.
self.credit_transaction.valid = False
self.credit_transaction.reason += " (invalide)"
self.credit_transaction._force_save = True
self.credit_transaction.save()
super().delete(**kwargs)
class Meta:
verbose_name = _("Credit from the Société générale")
verbose_name_plural = _("Credits from the Société générale")
def __str__(self):
return _("Soge credit for {user}").format(user=str(self.user))

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@ -105,8 +105,8 @@
\renewcommand{\headrulewidth}{0pt}
\cfoot{
\small{\MonNom ~--~ \MonAdresseRue ~ \MonAdresseVille ~--~ Téléphone : +33(0)6 89 88 56 50\newline
Site web : bde.ens-cachan.fr ~--~ E-mail : tresorerie.bde@lists.crans.org \newline Numéro SIRET : 399 485 838 00011
\small{\MonNom ~--~ \MonAdresseRue ~ \MonAdresseVille ~--~ Téléphone : +33(0)7 78 17 22 34\newline
Site web : bde.ens-cachan.fr ~--~ E-mail : tresorerie.bde@lists.crans.org \newline Numéro SIRET : 399 485 838 00029
}
}

View File

@ -29,7 +29,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
</h3>
<div class="card-body">
<div class="input-group">
<input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note">
<input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note ...">
<div class="input-group-append">
<button id="add_sogecredit" class="btn btn-success" data-toggle="modal" data-target="#add-sogecredit-modal">{% trans "Add" %}</button>
</div>

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django import template

View File

@ -385,8 +385,7 @@ class TestSogeCredits(TestCase):
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.assertRedirects(response, reverse("treasury:soge_credits"), 302, 200)
self.assertFalse(SogeCredit.objects.filter(pk=soge_credit.pk))
self.user.note.refresh_from_db()
self.assertEqual(self.user.note.balance, 0)

View File

@ -101,14 +101,7 @@ class InvoiceListView(LoginRequiredMixin, SingleTableView):
if not request.user.is_authenticated:
return self.handle_no_permission()
sample_invoice = Invoice(
id=0,
object="",
description="",
name="",
address="",
)
if not PermissionBackend.check_perm(self.request, "treasury.add_invoice", sample_invoice):
if not PermissionBackend.has_model_perm(self.request, Invoice(), "view"):
raise PermissionDenied(_("You are not able to see the treasury interface."))
return super().dispatch(request, *args, **kwargs)
@ -278,11 +271,7 @@ class RemittanceListView(LoginRequiredMixin, TemplateView):
if not request.user.is_authenticated:
return self.handle_no_permission()
sample_remittance = Remittance(
remittance_type_id=1,
comment="",
)
if not PermissionBackend.check_perm(self.request, "treasury.add_remittance", sample_remittance):
if not PermissionBackend.has_model_perm(self.request, Remittance(), "view"):
raise PermissionDenied(_("You are not able to see the treasury interface."))
return super().dispatch(request, *args, **kwargs)
@ -408,7 +397,7 @@ class SogeCreditListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableVi
if not request.user.is_authenticated:
return self.handle_no_permission()
if not super().get_queryset().exists():
if not PermissionBackend.has_model_perm(self.request, SogeCredit(), "view"):
raise PermissionDenied(_("You are not able to see the treasury interface."))
return super().dispatch(request, *args, **kwargs)

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2023 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django import forms
@ -38,14 +38,14 @@ class WEIRegistrationForm(forms.ModelForm):
class Meta:
model = WEIRegistration
exclude = ('wei', )
exclude = ('wei', 'clothing_cut')
widgets = {
"user": Autocomplete(
User,
attrs={
'api_url': '/api/user/',
'name_field': 'username',
'placeholder': 'Nom ',
'placeholder': 'Nom ...',
},
),
"birth_date": DatePickerInput(options={'minDate': '1900-01-01',
@ -74,7 +74,7 @@ class WEIChooseBusForm(forms.Form):
queryset=WEIRole.objects.filter(~Q(name="1A")),
label=_("WEI Roles"),
help_text=_("Select the roles that you are interested in."),
initial=WEIRole.objects.filter(name="Adhérent⋅e WEI").all(),
initial=WEIRole.objects.filter(name="Adhérent WEI").all(),
widget=CheckboxSelectMultiple(),
)
@ -130,14 +130,14 @@ class WEIMembershipForm(forms.ModelForm):
Bus,
attrs={
'api_url': '/api/wei/bus/',
'placeholder': 'Bus',
'placeholder': 'Bus ...',
}
),
"team": Autocomplete(
BusTeam,
attrs={
'api_url': '/api/wei/team/',
'placeholder': 'Équipe',
'placeholder': 'Équipe ...',
},
resetable=True,
),
@ -167,7 +167,7 @@ class BusForm(forms.ModelForm):
WEIClub,
attrs={
'api_url': '/api/wei/club/',
'placeholder': 'WEI',
'placeholder': 'WEI ...',
},
),
}
@ -182,7 +182,7 @@ class BusTeamForm(forms.ModelForm):
Bus,
attrs={
'api_url': '/api/wei/bus/',
'placeholder': 'Bus',
'placeholder': 'Bus ...',
},
),
"color": ColorWidget(),

View File

@ -2,11 +2,11 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm
from .wei2022 import WEISurvey2022
from .wei2023 import WEISurvey2023
__all__ = [
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
]
CurrentSurvey = WEISurvey2022
CurrentSurvey = WEISurvey2023

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2022 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2023 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import time
@ -14,14 +14,17 @@ from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, WEIBusInf
from ...models import WEIMembership
WORDS = [
'13 organisé', '3ième mi temps', 'Années 2000', 'Apéro', 'BBQ', 'BP', 'Beauf', 'Binge drinking', 'Bon enfant',
'Cartouche', 'Catacombes', 'Chansons paillardes', 'Chansons populaires', 'Chanteur', 'Chartreuse', 'Chill',
'Core', 'DJ', 'Dancefloor', 'Danse', 'David Guetta', 'Disco', 'Eau de vie', 'Électro', 'Escalade', 'Familial',
'Fanfare', 'Fracassage', 'Féria', 'Hard rock', 'Hoeggarden', 'House', 'Huit-six', 'IPA', 'Inclusif', 'Inferno',
'Introverti', 'Jager bomb', 'Jazz', 'Jeux d\'alcool', 'Jeux de rôles', 'Jeux vidéo', 'Jul', 'Jus de fruit',
'Karaoké', 'LGBTQI+', 'Lady Gaga', 'Loup garou', 'Morning beer', 'Métal', 'Nuit blanche', 'Ovalie', 'Psychedelic',
'Pétanque', 'Rave', 'Reggae', 'Rhum', 'Ricard', 'Rock', 'Rosé', 'Rétro', 'Séducteur', 'Techno', 'Thérapie taxi',
'Théâtre', 'Trap', 'Turn up', 'Underground', 'Volley', 'Wati B', 'Zinédine Zidane',
'ABBA', 'After', 'Alcoolique anonyme', 'Ambiance festive', 'Années 2000', 'Apéro', 'Art',
'Baby foot billard biere pong', 'BBQ', 'Before', 'Bière pong', 'Bon enfant', 'Calme', 'Canapé',
'Chanson paillarde', 'Chanson populaire', 'Chartreuse', 'Cheerleader', 'Chill', 'Choré',
'Cinéma', 'Cocktail', 'Comédie musicle', 'Commercial', 'Copaing', 'Danse', 'Dancefloor',
'Electro', 'Fanfare', 'Gin tonic', 'Inclusif', 'Jazz', "Jeux d'alcool", 'Jeux de carte',
'Jeux de rôle', 'Jeux de société', 'JUL', 'Jus de fruit', 'Kfet', 'Kleptomanie assurée',
'LGBTQ+', 'Livre', 'Morning beer', 'Musique', 'NAPS', 'Paillettes', 'Pastis', 'Paté Hénaff',
'Peluche', 'Pena baiona', "Peu d'alcool", 'Pilier de bar', 'PMU', 'Poulpe', 'Punch', 'Rap',
'Réveil', 'Rock', 'Rugby', 'Sandwich', 'Serge', 'Shot', 'Sociable', 'Spectacle', 'Techno',
'Techno house', 'Thérapie Taxi', 'Tradition kchanaises', 'Troisième mi-temps', 'Turn up',
'Vodka', 'Vodka pomme', 'Volley', 'Vomi stratégique'
]

View File

@ -0,0 +1,391 @@
# Copyright (C) 2018-2023 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from functools import lru_cache
from django import forms
from django.db import transaction
from django.db.models import Q
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, WEIBusInformation
from ...models import WEIMembership
WORDS = {
"ambiance": ["Ambiance de bus :", {
1: "Ambiance calme et posée",
2: "Ambiance rigolage entre copaing",
3: "Ambiance danse de camping autour d'une piscine inexistante",
4: "Grosse soirée avec de la musique qui fait bouger",
5: "On retourne le camping et le bus (dans le respect et le savoir vivre)"
}],
"musique": ["Musique :", {
1: "Musique tranquille",
2: "Musique commerciale",
3: "Chansons paillardes",
4: "Musique de Colonie de vacances",
5: "Grosse techno"
}],
"boisson": ["Boissons :", {
1: "Boisson soft",
2: "Des cocktails de temps en temps",
3: "Des coktails fancy de pétasse (parce que c'est les meilleurs)",
4: "Bière !",
5: "L'alcool c'est dans les céréales"
}],
"beauferie": ["Échelle de la beauferie :", {
1: "Je suis toujours classe",
2: "Je rote de temps en temps",
3: "Claquette chaussette, c'est confortable",
4: "L'aviron bayonnais est dans ma plyaylist",
5: "Je suis champion⋅ne de concours de rots et d'éclatage de gobelet sur mon front"
}],
"sommeil": ["Échelle de ton sommeil pendant le WEI :", {
1: "Dormir, c'est pour les faibles",
2: "5h maximum",
3: "10h",
4: "15h",
5: "Deux bonnes nuits de sommeil, c'est important pour être en forme pour les activités proposées par nos supers GC WEI"
}],
"vacances": ["Tes vacances de rêve :", {
1: "Dans ma chambre",
2: "Retourner chez popa et moman pour pouvoir enfin arrêter de manger des pasta box",
3: "Être une grosse larve sous le soleil des troopiiiiiiiiques",
4: "Faire un road trip camping sauvage, manger des racines et boire son pipi",
5: "Le crime ne prend pas de vacances"
}],
"activite": ["T'as une heure de trou pendant ton WEI, que fais-tu ?", {
1: "Je cherche des copaines pour faire un petit jeu de société",
2: "Je cherche un moyen de me dépenser, n'importe quel ballon ferait l'affaire",
3: "Je cherche un endroit où il y a de la musique pour bouger sur le dancefloor",
4: "Petit apéro, petite pétanque avec les collègues autour d'un bon pastaga",
5: "Je cherche une connerie à faire (mais pas trop méchante, pour ne pas embêter mes GC WEI préférés)"
}],
"hygiene": ["Échelle de ton hygiène :", {
1: "La douche, c'eest tous les jours",
2: "La règle des 2 jours, c'est un droit et un devoir",
3: "Je ne me lave qu'après le sport",
4: "« Ne vous inquiétez pas, je pue pas »",
5: "Y a que les sales qui se lavent"
}],
"animal": ["Tu décrirais ton animal totem plutôt comme :", {
1: "Un dragon qui raserait des villes entières d'un seul souffle",
2: "Une mouette qui pique des frites aux dunkerquois",
3: "Un poulpe tout meunion",
4: "Un pitbull qui au fond cache un petit cœur en sucre",
5: "Un canard en plastique au bord d'une baignoire qui n'a pas servi depuis 10 ans"
}],
"fensfoire": ["Quel est ton rapport à la F[ENS]foire ?", {
1: "Je réveille les autres à 6h avec mon instrument",
2: "Je la suis partout",
3: "J'aime bien l'écouter de temps en temps",
4: "Je mets des bouchons d'oreilles pour ne pas l'entendre",
5: "La quoi ?"
}],
"kokarde": ["Qu'est-ce que le mot Kokarde t'évoque ?", {
1: "Vraiment pas mon truc les soirées…",
2: "Bof, je viens pour manger et je repars aussitôt",
3: "Je kiffe, good vibes",
4: "Perso, je ne m'arrêterai pas de danser sur la piste !",
5: "J'resterai jusqu'à 3h ou rien"
}],
"copain": ["Qu'est-ce que tu fais avec un⋅e «copain⋅ine» ?", {
1: "Je l'insulte de sale merde",
2: "J'lui fais faire des trucs cons et je l'affiche !",
3: "On parlerait ensemble et on se marrerait",
4: "On aurait des vrais gros délires",
5: "Je meurs pour lui/elle"
}],
"vie": ["Selon toi, qu'est-ce que la vie ?", {
1: "La vie, cette sale race !",
2: "Un moment paisible avant la mort",
3: "C'est difficile à définir...",
4: "En vrai, c'est cool !",
5: "Une gigantestque tranche de kiff ! Et tous les jours, j'en mange un morceau"
}],
"jeux": ["Quel est ton rapport avec les jeux de société ?", {
1: "éloigné",
2: "nonchalant",
3: "timide",
4: "assumé",
5: "sexuel"
}],
"calin": ["Qu'est-ce que tu penses des câlins ?", {
1: "Jamais je n'en fais et jamais je n'en ferai !",
2: "J'en fais mais ça ne me plaît pas",
3: "J'en fais rarement mais c'est toujours cool",
4: "J'en fais tous les jours avec mes ami⋅es !",
5: "Je pourrais en faire à n'importe qui. Pourquoi ne pas créer le club Câl[ENS] ?"
}],
"vomi": ["Quel est ton rapport au vomi ?", {
1: "C'est compliqué…",
2: "Jamais je ne vomis mais je nettoie quand mes potes vomissent",
3: "Jamais je ne vomis et jamais je ne nettoie celui de quelqu'un d'autre",
4: "Je vomis quelquefois, ça arrive, faites pas cette tête, mais je fins toujours par nettoyer !",
5: "Je vomis à chaque soirée et ce n'est jamais moi qui nettoie"
}],
"kfet": ["Qu'est ce que la Kfet t'évoque ?", {
1: "La Kfet, quel lieu de dépravé⋅es sérieux…",
2: "C'est un endroit à l'hygiène plus que douteuse…",
3: "Téma les prix des boissons et des snacks, c'est aberrant !",
4: "En vrai, c'est cool, petit billard, petit canapé, chill !",
5: "Banger, j'y reste jusqu'à la fin de mes jours"
}],
"fatigue": ["Comment combattre la fatigue lors de ton WEI ?", {
1: "Le sport en journée, ça réveille",
2: "Le sucre du coca, ça réveille",
3: "La taurine du Red Bull, ça réveille",
4: "L'alcool dans le sang, ça réveille",
5: "L'écocup sur le front, ça réveille"
}],
"duree trajet": ["Quelle serait ta durée de trajet préférée ?", {
1: "Trajet instantané, pas le temps de niaiser",
2: "1h, histoire de faire connaissance avec quelques personnes avant de se jeter sur les boissons",
3: "3h, on peut vraiment parler et apprendre à connaître nos voisin⋅es",
4: "6h, histoire d'avoir le temps de faire des conneries dans le bus pour bien se marrer !",
5: "12h, il faut bien trouver un moment pour dormir, ce seront deux gros dodos dans un bus"
}],
"scolarite": ["Comment tu vois ton cursus à l'ENS ?", {
1: "La tranquillité et le travail",
2: "On va s'amuser tout en bossant",
3: "Ça va profiter et réviser au dernier moment pour les exams…",
4: "Nous festoierons sans songer aux conséquences",
5: "Je ne vois qu'une seule issue : la débauche"
}]
}
class WEISurveyForm2023(forms.Form):
"""
Survey form for the year 2023.
Members answer 20 questions, from which we calculate the best associated bus.
"""
def set_registration(self, registration):
"""
Filter the bus selector with the buses of the current WEI.
"""
information = WEISurveyInformation2023(registration)
question = information.questions[information.step]
self.fields[question] = forms.ChoiceField(
label=WORDS[question][0],
widget=forms.RadioSelect(),
)
answers = [(answer, WORDS[question][1][answer]) for answer in WORDS[question][1]]
self.fields[question].choices = answers
class WEIBusInformation2023(WEIBusInformation):
"""
For each question, the bus has ordered answers
"""
scores: dict
def __init__(self, bus):
self.scores = {}
for question in WORDS:
self.scores[question] = []
super().__init__(bus)
class WEISurveyInformation2023(WEISurveyInformation):
"""
We store the id of the selected bus. We store only the name, but is not used in the selection:
that's only for humans that try to read data.
"""
step = 0
questions = list(WORDS.keys())
def __init__(self, registration):
for question in WORDS:
setattr(self, str(question), None)
super().__init__(registration)
class WEISurvey2023(WEISurvey):
"""
Survey for the year 2023.
"""
@classmethod
def get_year(cls):
return 2023
@classmethod
def get_survey_information_class(cls):
return WEISurveyInformation2023
def get_form_class(self):
return WEISurveyForm2023
def update_form(self, form):
"""
Filter the bus selector with the buses of the WEI.
"""
form.set_registration(self.registration)
@transaction.atomic
def form_valid(self, form):
self.information.step += 1
for question in WORDS:
if question in form.cleaned_data:
answer = form.cleaned_data[question]
setattr(self.information, question, answer)
self.save()
@classmethod
def get_algorithm_class(cls):
return WEISurveyAlgorithm2023
def is_complete(self) -> bool:
"""
The survey is complete once the bus is chosen.
"""
for question in WORDS:
if not getattr(self.information, question):
return False
return True
@lru_cache()
def score(self, bus):
if not self.is_complete():
raise ValueError("Survey is not ended, can't calculate score")
bus_info = self.get_algorithm_class().get_bus_information(bus)
# Score is the given score by the bus subtracted to the mid-score of the buses.
s = 0
for question in WORDS:
s += bus_info.scores[question][str(getattr(self.information, question))]
return s
@lru_cache()
def scores_per_bus(self):
return {bus: self.score(bus) for bus in self.get_algorithm_class().get_buses()}
@lru_cache()
def ordered_buses(self):
values = list(self.scores_per_bus().items())
values.sort(key=lambda item: -item[1])
return values
@classmethod
def clear_cache(cls):
return super().clear_cache()
class WEISurveyAlgorithm2023(WEISurveyAlgorithm):
"""
The algorithm class for the year 2023.
We use Gale-Shapley algorithm to attribute 1y students into buses.
"""
@classmethod
def get_survey_class(cls):
return WEISurvey2023
@classmethod
def get_bus_information_class(cls):
return WEIBusInformation2023
def run_algorithm(self, display_tqdm=False):
"""
Gale-Shapley algorithm implementation.
We modify it to allow buses to have multiple "weddings".
"""
surveys = list(self.get_survey_class()(r) for r in self.get_registrations()) # All surveys
surveys = [s for s in surveys if s.is_complete()] # Don't consider invalid surveys
# Don't manage hardcoded people
surveys = [s for s in surveys if not hasattr(s.information, 'hardcoded') or not s.information.hardcoded]
# Reset previous algorithm run
for survey in surveys:
survey.free()
survey.save()
non_men = [s for s in surveys if s.registration.gender != 'male']
men = [s for s in surveys if s.registration.gender == 'male']
quotas = {}
registrations = self.get_registrations()
non_men_total = registrations.filter(~Q(gender='male')).count()
for bus in self.get_buses():
free_seats = bus.size - WEIMembership.objects.filter(bus=bus, registration__first_year=False).count()
# Remove hardcoded people
free_seats -= WEIMembership.objects.filter(bus=bus, registration__first_year=True,
registration__information_json__icontains="hardcoded").count()
quotas[bus] = 4 + int(non_men_total / registrations.count() * free_seats)
tqdm_obj = None
if display_tqdm:
from tqdm import tqdm
tqdm_obj = tqdm(total=len(non_men), desc="Non-hommes")
# Repartition for non men people first
self.make_repartition(non_men, quotas, tqdm_obj=tqdm_obj)
quotas = {}
for bus in self.get_buses():
free_seats = bus.size - WEIMembership.objects.filter(bus=bus, registration__first_year=False).count()
free_seats -= sum(1 for s in non_men if s.information.selected_bus_pk == bus.pk)
# Remove hardcoded people
free_seats -= WEIMembership.objects.filter(bus=bus, registration__first_year=True,
registration__information_json__icontains="hardcoded").count()
quotas[bus] = free_seats
if display_tqdm:
tqdm_obj.close()
from tqdm import tqdm
tqdm_obj = tqdm(total=len(men), desc="Hommes")
self.make_repartition(men, quotas, tqdm_obj=tqdm_obj)
if display_tqdm:
tqdm_obj.close()
# Clear cache information after running algorithm
WEISurvey2023.clear_cache()
def make_repartition(self, surveys, quotas=None, tqdm_obj=None):
free_surveys = surveys.copy() # Remaining surveys
while free_surveys: # Some students are not affected
survey = free_surveys[0]
buses = survey.ordered_buses() # Preferences of the student
for bus, current_score in buses:
if self.get_bus_information(bus).has_free_seats(surveys, quotas):
# Selected bus has free places. Put student in the bus
survey.select_bus(bus)
survey.save()
free_surveys.remove(survey)
break
else:
# Current bus has not enough places. Remove the least preferred student from the bus if existing
least_preferred_survey = None
least_score = -1
# Find the least student in the bus that has a lower score than the current student
for survey2 in surveys:
if not survey2.information.valid or survey2.information.get_selected_bus() != bus:
continue
score2 = survey2.score(bus)
if current_score <= score2: # Ignore better students
continue
if least_preferred_survey is None or score2 < least_score:
least_preferred_survey = survey2
least_score = score2
if least_preferred_survey is not None:
# Remove the least student from the bus and put the current student in.
# If it does not exist, choose the next bus.
least_preferred_survey.free()
least_preferred_survey.save()
free_surveys.append(least_preferred_survey)
survey.select_bus(bus)
survey.save()
free_surveys.remove(survey)
break
else:
raise ValueError(f"User {survey.registration.user} has no free seat")
if tqdm_obj is not None:
tqdm_obj.n = len(surveys) - len(free_surveys)
tqdm_obj.refresh()

View File

@ -84,5 +84,5 @@ class Command(BaseCommand):
s += sep + user.profile.section_generated
s += sep + bus.name
s += sep + (team.name if team else "--")
s += sep + ", ".join(role.name for role in membership.roles.filter(~Q(name="Adhérent⋅e WEI")).all())
s += sep + ", ".join(role.name for role in membership.roles.filter(~Q(name="Adhérent WEI")).all())
self.stdout.write(s)

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.26 on 2022-09-04 21:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wei', '0003_bus_size'),
]
operations = [
migrations.AlterField(
model_name='weiclub',
name='year',
field=models.PositiveIntegerField(default=2022, unique=True, verbose_name='year'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2023-01-28 17:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wei', '0004_auto_20220904_2325'),
]
operations = [
migrations.AlterField(
model_name='weiclub',
name='year',
field=models.PositiveIntegerField(default=2023, unique=True, verbose_name='year'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2023-07-09 09:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wei', '0005_auto_20230128_1850'),
]
operations = [
migrations.AlterField(
model_name='weiregistration',
name='clothing_cut',
field=models.CharField(choices=[('male', 'Male'), ('female', 'Female'), ('unisex', 'Unisex')], default='unisex', max_length=16, verbose_name='clothing cut'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2023-07-09 12:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wei', '0006_unisex_clothing_cut'),
]
operations = [
migrations.AlterField(
model_name='weiregistration',
name='emergency_contact_name',
field=models.CharField(help_text='The emergency contact must not be a WEI participant', max_length=255, verbose_name='emergency contact name'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2024-01-11 14:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wei', '0007_help_text_emergency_contact'),
]
operations = [
migrations.AlterField(
model_name='weiclub',
name='year',
field=models.PositiveIntegerField(default=2024, unique=True, verbose_name='year'),
),
]

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import json
@ -33,6 +33,10 @@ class WEIClub(Club):
verbose_name=_("date end"),
)
class Meta:
verbose_name = _("WEI")
verbose_name_plural = _("WEI")
@property
def is_current_wei(self):
"""
@ -46,10 +50,6 @@ class WEIClub(Club):
"""
return
class Meta:
verbose_name = _("WEI")
verbose_name_plural = _("WEI")
class Bus(models.Model):
"""
@ -84,6 +84,14 @@ class Bus(models.Model):
help_text=_("Information about the survey for new members, encoded in JSON"),
)
class Meta:
verbose_name = _("Bus")
verbose_name_plural = _("Buses")
unique_together = ('wei', 'name',)
def __str__(self):
return self.name
@property
def information(self):
"""
@ -106,14 +114,6 @@ class Bus(models.Model):
registrations = [r for r in registrations if 'selected_bus_pk' in r.information]
return sum(1 for r in registrations if r.information['selected_bus_pk'] == self.pk)
def __str__(self):
return self.name
class Meta:
verbose_name = _("Bus")
verbose_name_plural = _("Buses")
unique_together = ('wei', 'name',)
class BusTeam(models.Model):
"""
@ -142,20 +142,19 @@ class BusTeam(models.Model):
verbose_name=_("description"),
)
def __str__(self):
return self.name + " (" + str(self.bus) + ")"
class Meta:
unique_together = ('bus', 'name',)
verbose_name = _("Bus team")
verbose_name_plural = _("Bus teams")
def __str__(self):
return self.name + " (" + str(self.bus) + ")"
class WEIRole(Role):
"""
A Role for the WEI can be bus chief, team chief, free electron,
A Role for the WEI can be bus chief, team chief, free electron, ...
"""
class Meta:
verbose_name = _("WEI Role")
verbose_name_plural = _("WEI Roles")
@ -165,7 +164,6 @@ class WEIRegistration(models.Model):
"""
Store personal data that can be useful for the WEI.
"""
user = models.ForeignKey(
User,
on_delete=models.PROTECT,
@ -209,7 +207,9 @@ class WEIRegistration(models.Model):
choices=(
('male', _("Male")),
('female', _("Female")),
('unisex', _("Unisex")),
),
default='unisex',
verbose_name=_("clothing cut"),
)
@ -235,6 +235,7 @@ class WEIRegistration(models.Model):
emergency_contact_name = models.CharField(
max_length=255,
verbose_name=_("emergency contact name"),
help_text=_("The emergency contact must not be a WEI participant")
)
emergency_contact_phone = PhoneNumberField(
@ -255,10 +256,18 @@ class WEIRegistration(models.Model):
"encoded in JSON"),
)
class Meta:
unique_together = ('user', 'wei',)
verbose_name = _("WEI User")
verbose_name_plural = _("WEI Users")
def __str__(self):
return str(self.user)
@property
def information(self):
"""
The information about the registration (the survey for the new members, the bus for the older members,)
The information about the registration (the survey for the new members, the bus for the older members, ...)
are stored in a dictionary that can evolve following the years. The dictionary is stored as a JSON string.
"""
return json.loads(self.information_json)
@ -304,14 +313,6 @@ class WEIRegistration(models.Model):
except AttributeError:
return False
def __str__(self):
return str(self.user)
class Meta:
unique_together = ('user', 'wei',)
verbose_name = _("WEI User")
verbose_name_plural = _("WEI Users")
class WEIMembership(Membership):
bus = models.ForeignKey(

View File

@ -56,7 +56,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<dd class="col-xl-6">{{ registration.get_gender_display }}</dd>
<dt class="col-xl-6">{% trans 'clothing cut'|capfirst %}</dt>
<dd class="col-xl-6">{{ registration.clothing_cut }}</dd>
<dd class="col-xl-6">{{ registration.get_clothing_cut_display }}</dd>
<dt class="col-xl-6">{% trans 'clothing size'|capfirst %}</dt>
<dd class="col-xl-6">{{ registration.clothing_size }}</dd>

View File

@ -8,7 +8,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% block profile_content %}
<div class="card">
<div class="card-body">
<input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note/bus/équipe">
<input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note/bus/équipe ...">
<hr>
<div id="memberships_table">
@ -24,7 +24,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div class="card-footer text-center">
<a href="{% url 'wei:wei_registrations' pk=club.pk %}">
<button class="btn btn-block btn-info">{% trans "View unvalidated registrations" %}</button>
<button class="btn btn-block btn-info">{% trans "View unvalidated registrations..." %}</button>
</a>
<hr>
<a href="{% url 'wei:wei_memberships_pdf' wei_pk=club.pk %}" data-turbolinks="false">

View File

@ -8,7 +8,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% block profile_content %}
<div class="card">
<div class="card-body">
<input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note">
<input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note ...">
<hr>
<div id="registrations_table">
@ -24,7 +24,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div class="card-footer text-center">
<a href="{% url 'wei:wei_memberships' pk=club.pk %}">
<button class="btn btn-block btn-info">{% trans "View validated memberships" %}</button>
<button class="btn btn-block btn-info">{% trans "View validated memberships..." %}</button>
</a>
</div>
</div>

View File

@ -0,0 +1,170 @@
# Copyright (C) 2018-2023 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import random
from datetime import date, timedelta
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from note.models import NoteUser
from ..forms.surveys.wei2023 import WEIBusInformation2023, WEISurvey2023, WORDS, WEISurveyInformation2023
from ..models import Bus, WEIClub, WEIRegistration
class TestWEIAlgorithm(TestCase):
"""
Run some tests to ensure that the WEI algorithm is working well.
"""
fixtures = ('initial',)
def setUp(self):
"""
Create some test data, with one WEI and 10 buses with random score attributions.
"""
self.user = User.objects.create_superuser(
username="weiadmin",
password="admin",
email="admin@example.com",
)
self.user.save()
self.client.force_login(self.user)
sess = self.client.session
sess["permission_mask"] = 42
sess.save()
self.wei = WEIClub.objects.create(
name="WEI 2023",
email="wei2023@example.com",
parent_club_id=2,
membership_fee_paid=12500,
membership_fee_unpaid=5500,
membership_start='2023-01-01',
membership_end='2023-12-31',
date_start=date.today() + timedelta(days=2),
date_end='2023-12-31',
year=2023,
)
self.buses = []
for i in range(10):
bus = Bus.objects.create(wei=self.wei, name=f"Bus {i}", size=10)
self.buses.append(bus)
information = WEIBusInformation2023(bus)
for question in WORDS:
information.scores[question] = {answer: random.randint(1, 5) for answer in WORDS[question][1]}
information.save()
bus.save()
def test_survey_algorithm_small(self):
"""
There are only a few people in each bus, ensure that each person has its best bus
"""
# Add a few users
for i in range(10):
user = User.objects.create(username=f"user{i}")
registration = WEIRegistration.objects.create(
user=user,
wei=self.wei,
first_year=True,
birth_date='2000-01-01',
)
information = WEISurveyInformation2023(registration)
for question in WORDS:
setattr(information, question, random.randint(1, 5))
information.step = 20
information.save(registration)
registration.save()
# Run algorithm
WEISurvey2023.get_algorithm_class()().run_algorithm()
# Ensure that everyone has its first choice
for r in WEIRegistration.objects.filter(wei=self.wei).all():
survey = WEISurvey2023(r)
preferred_bus = survey.ordered_buses()[0][0]
chosen_bus = survey.information.get_selected_bus()
self.assertEqual(preferred_bus, chosen_bus)
def test_survey_algorithm_full(self):
"""
Buses are full of first year people, ensure that they are happy
"""
# Add a lot of users
for i in range(95):
user = User.objects.create(username=f"user{i}")
registration = WEIRegistration.objects.create(
user=user,
wei=self.wei,
first_year=True,
birth_date='2000-01-01',
)
information = WEISurveyInformation2023(registration)
for question in WORDS:
setattr(information, question, random.randint(1, 5))
information.step = 20
information.save(registration)
registration.save()
# Run algorithm
WEISurvey2023.get_algorithm_class()().run_algorithm()
penalty = 0
# Ensure that everyone seems to be happy
# We attribute a penalty for each user that didn't have its first choice
# The penalty is the square of the distance between the score of the preferred bus
# and the score of the attributed bus
# We consider it acceptable if the mean of this distance is lower than 5 %
for r in WEIRegistration.objects.filter(wei=self.wei).all():
survey = WEISurvey2023(r)
chosen_bus = survey.information.get_selected_bus()
buses = survey.ordered_buses()
score = min(v for bus, v in buses if bus == chosen_bus)
max_score = buses[0][1]
penalty += (max_score - score) ** 2
self.assertLessEqual(max_score - score, 25) # Always less than 25 % of tolerance
self.assertLessEqual(penalty / 100, 25) # Tolerance of 5 %
def test_register_1a(self):
"""
Test register a first year member to the WEI and complete the survey
"""
response = self.client.get(reverse("wei:wei_register_1A", kwargs=dict(wei_pk=self.wei.pk)))
self.assertEqual(response.status_code, 200)
user = User.objects.create(username="toto", email="toto@example.com")
NoteUser.objects.create(user=user)
response = self.client.post(reverse("wei:wei_register_1A", kwargs=dict(wei_pk=self.wei.pk)), dict(
user=user.id,
soge_credit=True,
birth_date=date(2000, 1, 1),
gender='nonbinary',
clothing_cut='female',
clothing_size='XS',
health_issues='I am a bot',
emergency_contact_name='NoteKfet2020',
emergency_contact_phone='+33123456789',
))
qs = WEIRegistration.objects.filter(user_id=user.id)
self.assertTrue(qs.exists())
registration = qs.get()
self.assertRedirects(response, reverse("wei:wei_survey", kwargs=dict(pk=registration.pk)), 302, 200)
for question in WORDS:
# Fill 1A Survey, 20 pages
# be careful if questionnary form change (number of page, type of answer...)
response = self.client.post(reverse("wei:wei_survey", kwargs=dict(pk=registration.pk)), {
question: "1"
})
registration.refresh_from_db()
survey = WEISurvey2023(registration)
self.assertRedirects(response, reverse("wei:wei_survey", kwargs=dict(pk=registration.pk)), 302,
302 if survey.is_complete() else 200)
self.assertIsNotNone(getattr(survey.information, question), "Survey page " + question + " failed")
survey = WEISurvey2023(registration)
self.assertTrue(survey.is_complete())
survey.select_bus(self.buses[0])
survey.save()
self.assertIsNotNone(survey.information.get_selected_bus())

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
# Copyright (C) 2018-2023 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import subprocess
@ -380,7 +380,7 @@ class TestWEIRegistration(TestCase):
def test_register_1a(self):
"""
Test register a first year member to the WEI and complete the survey.
Test register a first year member to the WEI.
"""
response = self.client.get(reverse("wei:wei_register_1A", kwargs=dict(wei_pk=self.wei.pk)))
self.assertEqual(response.status_code, 200)
@ -402,21 +402,6 @@ class TestWEIRegistration(TestCase):
self.assertTrue(qs.exists())
registration = qs.get()
self.assertRedirects(response, reverse("wei:wei_survey", kwargs=dict(pk=registration.pk)), 302, 200)
for i in range(1, 21):
# Fill 1A Survey, 20 pages
response = self.client.post(reverse("wei:wei_survey", kwargs=dict(pk=registration.pk)), dict(
word="Jus de fruit",
))
registration.refresh_from_db()
survey = CurrentSurvey(registration)
self.assertRedirects(response, reverse("wei:wei_survey", kwargs=dict(pk=registration.pk)), 302,
302 if survey.is_complete() else 200)
self.assertIsNotNone(getattr(survey.information, "word" + str(i)), "Survey page #" + str(i) + " failed")
survey = CurrentSurvey(registration)
self.assertTrue(survey.is_complete())
survey.select_bus(self.bus)
survey.save()
self.assertIsNotNone(survey.information.get_selected_bus())
# Check that the user can't be registered twice
response = self.client.post(reverse("wei:wei_register_1A", kwargs=dict(wei_pk=self.wei.pk)), dict(
@ -519,7 +504,7 @@ class TestWEIRegistration(TestCase):
emergency_contact_phone='+33600000000',
bus=[self.bus.id],
team=[self.team.id],
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent⋅e WEI").all()],
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent WEI").all()],
information_json=self.registration.information_json,
)
)
@ -573,7 +558,7 @@ class TestWEIRegistration(TestCase):
emergency_contact_phone='+33600000000',
bus=[self.bus.id],
team=[self.team.id],
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent⋅e WEI").all()],
roles=[role.id for role in WEIRole.objects.filter(name="Adhérent WEI").all()],
information_json=self.registration.information_json,
)
)
@ -662,7 +647,7 @@ class TestWEIRegistration(TestCase):
first_name="admin",
bank="Société générale",
))
self.assertRedirects(response, reverse("wei:wei_detail", kwargs=dict(pk=self.registration.wei.pk)), 302, 200)
self.assertRedirects(response, reverse("wei:wei_registrations", kwargs=dict(pk=self.registration.wei.pk)), 302, 200)
# Check if the membership is successfully created
membership = WEIMembership.objects.filter(user_id=self.user.id, club=self.wei)
self.assertTrue(membership.exists())
@ -782,7 +767,7 @@ class TestDefaultWEISurvey(TestCase):
WEISurvey.update_form(None, None)
self.assertEqual(CurrentSurvey.get_algorithm_class().get_survey_class(), CurrentSurvey)
self.assertEqual(CurrentSurvey.get_year(), 2022)
self.assertEqual(CurrentSurvey.get_year(), 2023)
class TestWeiAPI(TestAPI):

View File

@ -916,7 +916,7 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
form["team"].initial = BusTeam.objects.get(pk=information["preferred_team_pk"][0])
if "preferred_roles_pk" in information:
form["roles"].initial = WEIRole.objects.filter(
Q(pk__in=information["preferred_roles_pk"]) | Q(name="Adhérent⋅e WEI")
Q(pk__in=information["preferred_roles_pk"]) | Q(name="Adhérent WEI")
).all()
return form
@ -969,7 +969,7 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
if not registration.soge_credit and user.note.balance + credit_amount < fee:
# Users must have money before registering to the WEI.
form.add_error('bus',
form.add_error('credit_type',
_("This user don't have enough money to join this club, and can't have a negative balance."))
return super().form_invalid(form)
@ -1008,13 +1008,13 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
membership.save()
membership.refresh_from_db()
membership.roles.add(WEIRole.objects.get(name="Adhérent⋅e WEI"))
membership.roles.add(WEIRole.objects.get(name="Adhérent WEI"))
return super().form_valid(form)
def get_success_url(self):
self.object.refresh_from_db()
return reverse_lazy("wei:wei_detail", kwargs={"pk": self.object.club.pk})
return reverse_lazy("wei:wei_registrations", kwargs={"pk": self.object.club.pk})
class WEISurveyView(LoginRequiredMixin, BaseFormView, DetailView):
@ -1084,7 +1084,44 @@ class WEISurveyEndView(LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["club"] = WEIRegistration.objects.get(pk=self.kwargs["pk"]).wei
club = WEIRegistration.objects.get(pk=self.kwargs["pk"]).wei
context["club"] = club
random_user = User.objects.filter(~Q(wei__wei__in=[club])).first()
if random_user is None:
# This case occurs when all users are registered to the WEI.
# Don't worry, Pikachu never went to the WEI.
# This bug can arrive only in dev mode.
context["can_add_first_year_member"] = True
context["can_add_any_member"] = True
else:
# Check if the user has the right to create a registration of a random first year member.
empty_fy_registration = WEIRegistration(
wei=club,
user=random_user,
first_year=True,
birth_date="1970-01-01",
gender="No",
emergency_contact_name="No",
emergency_contact_phone="No",
)
context["can_add_first_year_member"] = PermissionBackend \
.check_perm(self.request, "wei.add_weiregistration", empty_fy_registration)
# Check if the user has the right to create a registration of a random old member.
empty_old_registration = WEIRegistration(
wei=club,
user=User.objects.filter(~Q(wei__wei__in=[club])).first(),
first_year=False,
birth_date="1970-01-01",
gender="No",
emergency_contact_name="No",
emergency_contact_phone="No",
)
context["can_add_any_member"] = PermissionBackend \
.check_perm(self.request, "wei.add_weiregistration", empty_old_registration)
return context
@ -1235,7 +1272,7 @@ class WEIAttributeBus1ANextView(LoginRequiredMixin, RedirectView):
raise Http404
wei = wei.get()
qs = WEIRegistration.objects.filter(wei=wei, membership__isnull=False, membership__bus__isnull=True)
qs = qs.filter(information_json__contains='selected_bus_pk') # not perfect, but works
qs = qs.filter(information_json__contains='selected_bus_pk') # not perfect, but works...
if qs.exists():
return reverse_lazy('wei:wei_bus_1A', args=(qs.first().pk, ))
return reverse_lazy('wei:wei_1A_list', args=(wei.pk, ))

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 143 KiB

View File

@ -23,18 +23,18 @@ Pages de l'API
Il suffit d'ajouter le préfixe ``/api/`` pour arriver sur ces pages.
* `models <basic#type-de-contenu>`_ : liste des différents modèles enregistrés en base de données
* `user <basic#utilisateur>`_ : liste des différent⋅es utilisateur⋅rices enregistrés
* `user <basic#utilisateur>`_ : liste des différents utilisateurs enregistrés
* `members/profile <member#profil-utilisateur>`_ : liste des différents profils associés à des utilisateurs
* `members/club <member#club>`_ : liste des différents clubs enregistrés
* `members/membership <member#adhesion>`_ : liste des adhésions enregistrées
* `activity/activity <activity#activite>`_ : liste des activités recensées
* `activity/type <activity#type-d-activite>`_ : liste des différents types d'activités : pots, soirées de club,
* `activity/type <activity#type-d-activite>`_ : liste des différents types d'activités : pots, soirées de club, ...
* `activity/guest <activity#invite>`_ : liste des personnes invitées lors d'une activité
* `activity/entry <activity#entree>`_ : liste des entrées effectuées lors des activités
* `note/note <note#note>`_ : liste des notes enregistrées
* `note/alias <note#alias>`_ : liste des alias enregistrés
* `note/consumer <note#consommateur>`_ : liste des alias enregistrés avec leur note associée
* `note/transaction/category <note#categorie-de-transaction>`_ : liste des différentes catégories de boutons : soft, alcool,
* `note/transaction/category <note#categorie-de-transaction>`_ : liste des différentes catégories de boutons : soft, alcool, ...
* `note/transaction/transaction <note#transaction>`_ : liste des transactions effectuées
* `note/transaction/template <note#modele-de-transaction>`_ : liste des boutons enregistrés
* `treasury/invoice <treasury#facture>`_ : liste des factures générées
@ -69,7 +69,7 @@ S'authentifier
L'authentification peut se faire soit par session en se connectant via la page de connexion classique,
soit via un jeton d'authentification. Le jeton peut se récupérer via la page de son propre compte, en cliquant
sur le bouton « `Accès API <https://note.crans.org/accounts/manage-auth-token/>`_ ». Il peut être révoqué et régénéré
sur le bouton « `Accès API <https://note.crans.org/accounts/manage-auth-token/>`_ ». Il peut être révoqué et regénéré
en un clic.
Pour s'authentifier via ce jeton, il faut ajouter l'en-tête ``Authorization: Token <TOKEN>`` aux paramètres HTTP.
@ -111,7 +111,7 @@ Trois types de filtres sont implémentés :
Les filtres disponibles sont indiqués sur chacune des pages de documentation.
Le résultat est déjà par défaut filtré par droits : seuls les éléments que l'utilisateur⋅rice a le droit de voir sont affichés.
Le résultat est déjà par défaut filtré par droits : seuls les éléments que l'utilisateur à le droit de voir sont affichés.
Cela est possible grâce à la structure des permissions, générant justement des filtres de requêtes de base de données.
Une requête à l'adresse ``/api/<model>/<pk>/`` affiche directement les informations du modèle demandé au format JSON.
@ -120,15 +120,14 @@ POST
~~~~
Une requête POST permet d'ajouter des éléments. Cette requête n'est possible que sur la page ``/api/<model>/``,
la requête POST n'est pas supportée sur les pages de détails (car cette requête permet l'ajout).
la requête POST n'est pas supportée sur les pages de détails (car cette requête permet ... l'ajout).
Des exceptions sont faites sur certaines pages : les pages de logs et de contenttypes sont en lecture uniquement.
Les formats supportés sont multiples : ``application/json``, ``application/x-www-url-encoded``, ``multipart/form-data``.
Cela facilite l'envoi de requêtes. Le module construit ensuite l'instance du modèle et le sauvegarde dans la base de
données. L'application ``permission`` s'assure que l'utilisateur⋅rice a le droit de faire ce type de modification.
La réponse renvoyée est l'objet enregistré au format JSON si l'ajout s'est bien déroulé, sinon un message d'erreur au
format JSON.
données. L'application ``permission`` s'assure que l'utilisateur à le droit de faire ce type de modification. La réponse
renvoyée est l'objet enregistré au format JSON si l'ajout s'est bien déroulé, sinon un message d'erreur au format JSON.
PATCH
~~~~~
@ -206,10 +205,10 @@ Une reqête OPTIONS affiche l'ensemble des opérations possibles sur un modèle
* ``<METHOD>`` est le type de requête HTTP supporté (pour modification, inclus dans {``POST``, ``PUT``, ``PATCH``}).
* ``<FIELD_NAME>`` est le nom du champ dans le modèle concerné (exemple : ``id``)
* ``<TYPE>`` représente le type de données : ``integer``, ``string``, ``date``, ``choice``, ``field`` (pour les clés étrangères),
* ``<TYPE>`` représente le type de données : ``integer``, ``string``, ``date``, ``choice``, ``field`` (pour les clés étrangères), ...
* ``<REQUIRED>`` est un booléen indiquant si le champ est requis dans le modèle ou s'il peut être nul/vide.
* ``<READ_ONLY>`` est un booléen indiquant si le champ est accessible en lecture uniquement.
* ``<LABEL>`` représente le label du champ, son nom traduit, qui s'affiche dans le formulaire accessible sur l'API Web.
Des contraintes peuvent s'ajouter à cela selon les champs : taille maximale de chaînes de caractères, valeurs minimales
et maximales pour les entiers
et maximales pour les entiers ...

View File

@ -135,7 +135,7 @@ Options
"required": false,
"read_only": false,
"label": "Pay\u00e9",
"help_text": "Indique si l'utilisateur⋅rice per\u00e7oit un salaire."
"help_text": "Indique si l'utilisateur per\u00e7oit un salaire."
},
"ml_events_registration": {
"type": "choice",

View File

@ -448,6 +448,10 @@ Options
"value": "female",
"display_name": "Femme"
}
{
"value": "unisex",
"display_name": "Unisexe"
},
]
},
"clothing_size": {
@ -507,7 +511,7 @@ Options
"required": false,
"read_only": false,
"label": "Premi\u00e8re ann\u00e9e",
"help_text": "Indique si l'utilisateur⋅rice est nouvelleau dans l'\u00e9cole."
"help_text": "Indique si l'utilisateur est nouveau dans l'\u00e9cole."
},
"information_json": {
"type": "string",
@ -520,7 +524,7 @@ Options
"type": "field",
"required": true,
"read_only": false,
"label": "Utilisateur⋅rice"
"label": "Utilisateur"
},
"wei": {
"type": "field",

View File

@ -3,20 +3,20 @@ Application Activités
L'application activités gère les différentes activités liées au BDE. Elle permet entre autres de créer des activités qui
peuvent être diffusées via des calendriers ou la mailing list d'événements. Elle permet aussi de réguler l'accès aux
événements, en s'assurant que leur note est positive. Elle permet enfin de gérer les invité⋅es.
événements, en s'assurant que leur note est positive. Elle permet enfin de gérer les invités.
Modèles
-------
L'application comporte 5 modèles : activités, types d'activité, invité⋅es, entrées et transactions d'invitation.
L'application comporte 5 modèles : activités, types d'activité, invités, entrées et transactions d'invitation.
Types d'activité
~~~~~~~~~~~~~~~~
Les activités sont triées par type (pots, soirées de club,), et chaque type regroupe diverses informations :
Les activités sont triées par type (pots, soirées de club, ...), et chaque type regroupe diverses informations :
* Nom du type
* Possibilité d'inviter des non-adhérent⋅es (booléen)
* Possibilité d'inviter des non-adhérents (booléen)
* Prix d'invitation (entier, centimes à débiter sur la note de l'hôte)
Activités
@ -26,7 +26,7 @@ Le modèle d'activité regroupe les informations liées à l'activité même :
* Nom de l'activité
* Description de l'activité
* Créateur⋅rice, personne qui a proposé l'activité
* Créateur, personne qui a proposé l'activité
* Club ayant organisé l'activité
* Note sur laquelle verser les crédits d'invitation (peut être nul si non concerné)
* Club invité (généralement le club Kfet)
@ -38,19 +38,19 @@ Le modèle d'activité regroupe les informations liées à l'activité même :
Entrées
~~~~~~~
Une instance de ce modèle est créé dès que quelqu'un⋅e est inscrit⋅e à l'activité. Sont stockées les informations suivantes :
Une instance de ce modèle est créé dès que quelqu'un est inscrit à l'activité. Sont stockées les informations suivantes :
* Activité concernée (clé étrangère)
* Heure d'entrée
* Note de la personne entrée, ou hôte s'il s'agit d'un⋅e invité⋅e (clé étrangère vers ``NoteUser``)
* Invité⋅e (``OneToOneField`` vers ``Guest``, ``None`` si c'est la personne elle-même qui rentre et non saon invité⋅e)
* Note de la personne entrée, ou hôte s'il s'agit d'un invité (clé étrangère vers ``NoteUser``)
* Invité (``OneToOneField`` vers ``Guest``, ``None`` si c'est la personne elle-même qui rentre et non son invité)
Il n'est pas possible de créer une entrée si la note est en négatif.
Invité⋅es
~~~~~~~~~
Invités
~~~~~~~
Les adhérent⋅es ont la possibilité d'inviter des ami⋅es. Pour cela, les différentes informations sont enregistrées :
Les adhérents ont la possibilité d'inviter des amis. Pour cela, les différentes informations sont enregistrées :
* Activité concernée (clé étrangère)
* Nom de famille
@ -60,7 +60,7 @@ Les adhérent⋅es ont la possibilité d'inviter des ami⋅es. Pour cela, les di
Certaines contraintes s'appliquent :
* Une personne ne peut pas être invitée plus de 5 fois par an (coupe nom/prénom)
* Un⋅e adhérent⋅e ne peut pas inviter plus de 3 personnes par activité.
* Un adhérent ne peut pas inviter plus de 3 personnes par activité.
Transactions d'invitation
~~~~~~~~~~~~~~~~~~~~~~~~~
@ -83,15 +83,15 @@ UI
Création d'activités
~~~~~~~~~~~~~~~~~~~~
N'importe quel⋅le adhérent⋅e Kfet peut suggérer l'ajout d'une activité via un formulaire.
N'importe quel adhérent Kfet peut suggérer l'ajout d'une activité via un formulaire.
Gestion des activités
~~~~~~~~~~~~~~~~~~~~~
Les ayant-droit (Res[pot] et respos infos) peuvent valider les activités proposées. Ils peuvent également la modifier
si besoin. Iels peuvent enfin la déclarer ouverte pour lancer l'accès aux entrées.
si besoin. Ils peuvent enfin la déclarer ouvertes pour lancer l'accès aux entrées.
N'importe qui peut inviter des ami⋅es non adhérent⋅es, tant que les contraintes de nombre (un⋅e adhérent⋅e n'invite pas plus de
N'importe qui peut inviter des amis non adhérents, tant que les contraintes de nombre (un adhérent n'invite pas plus de
trois personnes par activité et une personne ne peut pas être invitée plus de 5 fois par an). L'invitation est
facturée à l'entrée.
@ -99,12 +99,12 @@ Entrées aux soirées
~~~~~~~~~~~~~~~~~~~
L'interface d'entrées est simple et ergonomique. Elle contient un champ de texte. À chaque fois que le champ est
modifié, un tableau est affiché comprenant la liste des invité⋅es et des adhérent⋅es dont le prénom, le nom ou un alias
modifié, un tableau est affiché comprenant la liste des invités et des adhérents dont le prénom, le nom ou un alias
de la note est acceptée par le texte entré.
En cliquant sur la ligne de la personne qui souhaite rentrer, s'il s'agit d'un⋅e adhérent⋅e, alors la personne est comptée
comme entrée à l'activité, sous réserve que sa note soit positive. S'il s'agit d'un⋅e invité⋅e, alors 3 boutons
En cliquant sur la ligne de la personne qui souhaite rentrée, s'il s'agit d'un adhérent, alors la personne est comptée
comme entrée à l'activité, sous réserve que sa note soit positive. S'il s'agit d'un invité, alors 3 boutons
apparaîssent, afin de régler la taxe d'invitation : l'un prélève directement depuis la note de l'hôte, les deux autres
permettent un paiement par espèces ou par carte bancaire. En réalité, les deux derniers boutons enregistrent
automatiquement un crédit sur la note de l'hôte, puis une transaction (de type ``GuestTransaction``) est faite depuis
la note de l'hôte vers la note du club organisateur de l'événement.
la note de l'hôte vers la note de l'organisateur de l'événement.

View File

@ -1,5 +1,5 @@
Applications de la Note Kfet 2020
=================================
Applications de la NoteKfet2020
===============================
.. toctree::
:maxdepth: 2
@ -15,26 +15,27 @@ Applications de la Note Kfet 2020
treasury
wei
La Note Kfet 2020 est un projet Django, décomposé en applications.
Certaines applications sont développées uniquement pour ce projet, et sont indispensables,
d'autres sont packagées et sont installées comme dépendances.
Enfin, des fonctionnalités annexes ont été rajoutées, mais ne sont pas essentielles au déploiement de la Note Kfet 2020. Leur usage est cependant recommandé.
La NoteKfet est un projet Django, décomposé en applications.
Certaines Applications sont développées uniquement pour ce projet, et sont indispensables,
d'autres sont packagesé et sont installées comme dépendances.
Enfin des fonctionnalités annexes ont été rajouté, mais ne sont pas essentiel au déploiement de la NoteKfet;
leur usage est cependant recommandé.
L'affichage Web utilise le framework Bootstrap4 et quelques morceaux de JavaScript personnalisés.
Le front utilise le framework Bootstrap4 et quelques morceaux de javascript custom.
Applications indispensables
---------------------------
* ``note_kfet`` :
Application "projet" de django, c'est ici que la configuration de la note est gérée.
Application "projet" de django, c'est ici que la config de la note est gérée.
* `Member <member>`_ :
Gestion des profils d'utilisateur⋅rices, des clubs et de leur membres.
Gestion des profils d'utilisateurs, des clubs et de leur membres.
* `Note <note>`_ :
Les notes associées à des utilisateur⋅rices ou des clubs.
Les notes associés a des utilisateurs ou des clubs.
* `Activity <activity>`_ :
La gestion des activités (créations, gestion, entrées,…)
La gestion des Activités (créations, gestion, entrée...)
* `Permission <permission>`_ :
Backend de droits, limites les pouvoirs des utilisateur⋅rices
Backend de droits, limites les pouvoirs des utilisateurs
* `API <../api>`_ :
API REST de la note, est notamment utilisée pour rendre la note dynamique
(notamment la page de conso)
@ -51,9 +52,9 @@ Applications packagées
`<https://django-polymorphic.readthedocs.io/en/stable/>`_
* ``crispy_forms``
Utiliser pour générer des formulairess avec Bootstrap4
Utiliser pour générer des forms avec bootstrap4
* ``django_tables2``
utiliser pour afficher des tables de données et les formater, en Python plutôt qu'en HTML.
utiliser pour afficher des tables de données et les formater, en python plutôt qu'en HTML.
* ``restframework``
Base de l'`API <../api>`_.
@ -62,11 +63,11 @@ Applications facultatives
* `Logs <logs>`_
Enregistre toute les modifications effectuées en base de donnée.
* ``cas-server``
Serveur central d'authentification, permet d'utiliser son compte de la NoteKfet2020 pour se connecter à d'autre application ayant intégrer un client.
* `Scripts <https://gitlab.crans.org/bde/nk20-scripts>`_
Ensemble de commande `./manage.py` pour la gestion de la note: import de données, verification d'intégrité, etc
Serveur central d'authenfication, permet d'utiliser son compte de la NoteKfet2020 pour se connecter à d'autre application ayant intégrer un client.
* `Script <https://gitlab.crans.org/bde/nk20-scripts>`_
Ensemble de commande `./manage.py` pour la gestion de la note: import de données, verification d'intégrité, etc ...
* `Treasury <treasury>`_ :
Interface de gestion pour les trésorièr⋅es, émission de factures, remises de chèque, statistiques
Interface de gestion pour les trésoriers, émission de facture, remise de chèque, statistiques ...
* `WEI <wei>`_ :
Interface de gestion du WEI.

View File

@ -6,22 +6,22 @@ Chaque modification effectuée sur un modèle est enregistrée dans la base dans
Dès qu'un modèle veut être sauvegardé, deux signaux sont envoyés dans ``logs.signals`` : un avant et un après
la sauvegarde.
En pré-sauvegarde, on récupère l'ancienne version du modèle, si elle existe.
En post-sauvegarde, on récupère l'utilisateur⋅rice et l'IP courant⋅es (voir ci-dessous), on convertit les modèles en JSON
En post-sauvegarde, on récupère l'utilisateur et l'IP courants (voir ci-dessous), on convertit les modèles en JSON
et on enregistre une entrée ``Changelog`` dans la base de données.
Pour récupérer l'utilisateur⋅rice et son IP, le middleware ``logs.middlewares.LogsMiddlewares`` récupère à chaque requête
l'utilisateur⋅rice et l'adresse IP, et les stocke dans le processus courant, afin qu'ils puissent être
Pour récupérer l'utilisateur et son IP, le middleware ``logs.middlewares.LogsMiddlewares`` récupère à chaque requête
l'utilisateur et l'adresse IP, et les stocke dans le processus courant, afin qu'ils puissent être
récupérés par les signaux.
Si jamais la modification ne provient pas d'une requête Web, on suppose qu'elle vient d'une instruction
lancée avec ``manage.py``.
On récupère alors le nom de l'utilisateur⋅rice dans l'interface de commandes, et si une note est associée à cet alias,
On récupère alors le nom de l'utilisateur dans l'interface de commandes, et si une note est associée à cet alias,
alors on considère que c'est le détenteur de la note qui a effectué cette modification, sur l'adresse IP ``127.0.0.1``.
Sinon, le champ est laissé à ``None``.
Une entrée de ``Changelog`` contient les informations suivantes :
* Utilisateur⋅rice (``ForeignKey`` vers ``User``, nullable)
* Utilisateur (``ForeignKey`` vers ``User``, nullable)
* Adresse IP (``GenericIPAddressField``)
* Type de modèle enregistré (``ForeignKey`` vers ``Model``)
* Identifiant ``pk`` de l'instance enregistrée (``CharField``)
@ -54,4 +54,4 @@ Graphe
~~~~~~
.. image:: ../_static/img/graphs/logs.svg
:alt: Logs graph
:alt: Logs graphe

View File

@ -1,65 +1,65 @@
Application Member
==================
L'application ``member`` s'occcupe de la gestion des utilisateur⋅rices enregistré⋅es.
L'application ``member`` s'occcupe de la gestion des utilisateurs enregistrés.
Le model d'utilisateur⋅rice ``django.contrib.auth.model.User`` est complété par un ``Profile`` utilisateur⋅rice.
Le model d'utilisateur ``django.contrib.auth.model.User`` est complété par un ``Profile`` utilisateur.
Toustes les utilisateur⋅rices peuvent être membre de ``Club``. Cela se traduit par une adhésion ``Membership``, dont les
Tous les utilisateurs peuvent être membre de ``Club``. Cela se traduit par une adhésion ``Membership``, dont les
caractéristiques sont propres à chaque club.
En pratique, la Note Kfet possède au minimum deux clubs : **Bde** et **Kfet** (instanciés
via les fixtures). Et toutes les personnes à jour de cotisation sont membre à minima de
BDE. Être adhérent⋅e du club Kfet permet d'utiliser sa note pour consommer.
En pratique, la NoteKfet possède au minimum deux Club: **Bde** et **Kfet** (instanciés via les fixtures). Et tous
les personnes à jour de cotisation sont membre à minima de Bde.
Être adhérent du club Kfet permet d'utiliser sa note pour consommer.
Modèles
-------
Utilisateur⋅rice
~~~~~~~~~~~~~~~~
Utilisateur
~~~~~~~~~~~
Le modèle ``User`` est directement implémenté dans Django et n'appartient pas à l'application ``member``, mais il est
bon de rappeler à quoi ressemble ce modèle.
* ``date_joined`` : ``DateTimeField``, date à laquelle l'utilisateur⋅rice a été inscrit (*inutilisé dans la Note*)
* ``email`` : ``EmailField``, adresse e-mail de l'utilisateur⋅rice.
* ``first_name`` : ``CharField``, prénom de l'utilisateur⋅rice.
* ``date_joined`` : ``DateTimeField``, date à laquelle l'utilisateur a été inscrit (*inutilisé dans la Note*)
* ``email`` : ``EmailField``, adresse e-mail de l'utilisateur.
* ``first_name`` : ``CharField``, prénom de l'utilisateur.
* ``is_active`` : ``BooleanField``, indique si le compte est actif et peut se connecter.
* ``is_staff`` : ``BooleanField``, indique si l'utilisateur⋅rice peut se connecter à l'interface Django-admin.
* ``is_superuser`` : ``BooleanField``, indique si l'utilisateur⋅rice dispose de droits super-utilisateur⋅rices, permettant n'importe quelle action en base de donnée (lecture, ajout, modification, suppression).
* ``is_staff`` : ``BooleanField``, indique si l'utilisateur peut se connecter à l'interface Django-admin.
* ``is_superuser`` : ``BooleanField``, indique si l'utilisateur dispose de droits super-utilisateurs, permettant n'importe quelle action en base de donnée (lecture, ajout, modification, suppression).
* ``last_login`` : ``DateTimeField``, date et heure de dernière connexion.
* ``last_name`` : ``CharField``, nom de famille de l'utilisateur⋅rice.
* ``password`` : ``CharField``, contient le hash du mot de passe de l'utilisateur⋅rice. L'algorithme utilisé est celui par défaut de Django : PBKDF2 + HMAC + SHA256 avec 150000 itérations.
* ``username`` : ``CharField`` (unique), pseudo de l'utilisateur⋅rice.
* ``last_name`` : ``CharField``, nom de famille de l'utilisateur.
* ``password`` : ``CharField``, contient le hash du mot de passe de l'utilisateur. L'algorithme utilisé est celui par défaut de Django : PBKDF2 + HMAC + SHA256 avec 150000 itérations.
* ``username`` : ``CharField`` (unique), pseudo de l'utilisateur.
Profil
~~~~~~
Le modèle ``Profile`` contient un champ ``user`` de type ``OneToOneField``, ce qui permet de voir ce modèle comme une
extension du modèle ``User``, sans avoir à le réécrire. Il contient diverses informations personnelles sur
l'utilisateur⋅rice, utiles pour l'adhésion au BDE :
l'utilisateur, utiles pour l'adhésion au BDE :
* ``user`` : ``OneToOneField(User)``, utilisateur⋅rice lié à ce profil
* ``address`` : ``CharField``, adresse physique de l'utilisateur⋅rice
* ``paid`` : ``BooleanField``, indique si l'utilisateur⋅rice normalien⋅ne est rémunéré⋅e ou non (utile pour différencier les montants d'adhésion aux clubs)
* ``phone_number`` : ``CharField``, numéro de téléphone de l'utilisateur⋅rice
* ``section`` : ``CharField``, section de l'ENS à laquelle appartient l'utilisateur⋅rice (exemple : 1A0,)
* ``user`` : ``OneToOneField(User)``, utilisateur lié à ce profil
* ``address`` : ``CharField``, adresse physique de l'utilisateur
* ``paid`` : ``BooleanField``, indique si l'utilisateur normalien est rémunéré ou non (utile pour différencier les montants d'adhésion aux clubs)
* ``phone_number`` : ``CharField``, numéro de téléphone de l'utilisateur
* ``section`` : ``CharField``, section de l'ENS à laquelle apartient l'utilisateur (exemple : 1A0, ...)
Clubs
~~~~~
La gestion des clubs est une différence majeure avec la Note Kfet 2015. La Note gère ainsi les adhésions des
utilisateur⋅rices aux différents clubs.
utilisateurs aux différents clubs.
* ``parent_club`` : ``ForeignKey(Club)``. La présence d'un club parent force l'adhésion au club parent avant de pouvoir adhérer au dit club. Tout club qui n'est pas le club BDE doit avoir le club BDE dans son arborescence.
* ``email`` : ``EmailField``, adresse e-mail sur laquelle contacter le bureau du club.
* ``membership_start`` : ``DateField``, date à partir de laquelle il est possible d'adhérer à un club pour l'année suivante (si adhésions à l'année), en ignorant l'année. Par exemple, l'adhésion BDE est possible à partir du 01/08 par défaut, et c'est à cette date que les adhésions pour l'année future est possible.
* ``membership_start`` : ``DateField``, date à partir de laquelle il est possible d'adhérer à un club pour l'année suivante (si adhésions à l'année), en ignorant l'année. Par exemple, l'adhésion BDE est possible à partir du 31/08 par défaut, et c'est à cette date que les adhésions pour l'année future est possible.
* ``membership_end`` : ``DateField``, date maximale de fin d'adhésion. Pour le club BDE, il s'agit du 30/09 de l'année suivante. Si cette valeur vaut ``null``, la fin d'adhésion n'est pas limitée.
* ``membership_duration`` : ``PositiveIntegerField``, durée (en jours) maximale d'adhésion. Par exemple, le club BDE permet des adhésions maximales de 13 mois, soit 396 jours.
* ``membership_fee_paid`` : ``PositiveIntegerField``, montant de la cotisation (en centimes) pour qu'un⋅e élève normalien⋅ne (donc rémunéré⋅e) puisse adhérer.
* ``membership_fee_unpaid`` : ``PositiveIntegerField``, montant de la cotisation (en centimes) pour qu'un⋅e étudiant⋅e normalien⋅ne (donc non rémunéré) puisse adhérer.
* ``membership_fee_paid`` : ``PositiveIntegerField``, montant de la cotisation (en centimes) pour qu'un élève normalien (donc rémunéré) puisse adhérer.
* ``membership_fee_unpaid`` : ``PositiveIntegerField``, montant de la cotisation (en centimes) pour qu'un étudiant normalien (donc non rémunéré) puisse adhérer.
* ``name`` : ``CharField``, nom du club.
* ``require_memberships`` : ``BooleanField``, indique si le club est un vrai club BDE qui nécessite des adhésions de club, ou s'il s'agit d'une note "pot commun" (organisation d'une activité, note de département,)
* ``require_memberships`` : ``BooleanField``, indique si le club est un vrai club BDE qui nécessite des adhésions de club, ou s'il s'agit d'une note "pot commun" (organisation d'une activité, note de département, ...)
Adhésions
~~~~~~~~~
@ -67,16 +67,16 @@ Adhésions
Comme indiqué précédemment, la note gère les adhésions.
* ``club`` : ``ForeignKey(Club)``, club lié à l'adhésion.
* ``user`` : ``ForeignKey(User)``, utilisateur⋅rice qui a adhéré.
* ``user`` : ``ForeignKey(User)``, utilisateur adhéré.
* ``date_start`` : ``DateField``, date de début d'adhésion.
* ``date_end`` : ``DateField``, date de fin d'adhésion.
* ``fee`` : ``PositiveIntegerField``, montant de la cotisation payée.
* ``roles`` : ``ManyToManyField(Role)``, liste des rôles endossés par l'adhérent⋅e.
* ``roles`` : ``ManyToManyField(Role)``, liste des rôles endossés par l'adhérent.
Rôles
~~~~~
Comme indiqué le modèle des adhésions, les adhésions octroient des rôles aux adhérent⋅es, qui offrent des permissions
Comme indiqué le modèle des adhésions, les adhésions octroient des rôles aux adhérents, qui offrent des permissions
(cf ``RolesPermissions`` dans la page des permissions). Le modèle ``RolesPermissions`` possède un
``OneToOneField(Role)``, qui implémente les permissions des rôles. Le modèle ``Role`` à proprement parler ne contient
que le champ de son nom (``CharField``).
@ -88,7 +88,7 @@ Si le modèle ``MembershipTransaction`` appartient à l'application ``note``, il
Le modèle ``MembershipTransaction`` est une extension du modèle ``Transaction`` (application ``note``) qui est de type
polymorphique, et contient en plus des informations de base de la transaction un champ ``OneToOneField(Membership)``
faisant le lien entre l'adhésion et la transaction liée. Une adhésion club, si elle n'est pas gratuite,
génère en effet automatiquement une transaction de l'utilisateur⋅rice vers le club (voir section adhésions).
génère en effet automatiquement une transaction de l'utilisateur vers le club (voir section adhésions).
Graphe
------
@ -100,28 +100,28 @@ Adhésions
---------
La Note Kfet offre la possibilité aux clubs de gérer l'adhésion de leurs membres. En plus de réguler les cotisations
des adhérent⋅es, des permissions sont octroyées sur la note en fonction des rôles au sein des clubs. Un rôle est une
fonction occupée au sein d'un club (Trésorièr⋅e de club, président⋅e de club, GC Kfet, Res[pot], respo info,).
Une adhésion attribue à un⋅e adhérent⋅e ses rôles. Les rôles fournissent les permissions. Par exemple, læ trésorièr⋅e d'un
des adhérents, des permissions sont octroyées sur la note en fonction des rôles au sein des clubs. Un rôle est une
fonction occupée au sein d'un club (Trésorier de club, président de club, GCKfet, Res[pot], respo info, ...).
Une adhésion attribue à un adhérent ses rôles. Les rôles fournissent les permissions. Par exemple, le trésorier d'un
club a le droit de faire des transferts de et vers la note du club, tant que la source reste au-dessus de -50 €.
Une adhésion est considérée comme valide si la date du jour est comprise (au sens large) entre les dates de début et
de fin d'adhésion.
On peut ajouter une adhésion à un⋅e utilisateur⋅rice dans un club à tout⋅e non adhérent⋅e de ce club. La personne en charge
d'adhérer quelqu'un choisit l'utilisateur⋅rice, les rôles au sein du club et la date de début d'adhésion. Cette date de
On peut ajouter une adhésion à un utilisateur dans un club à tout non adhérent de ce club. La personne en charge
d'adhérer quelqu'un choisit l'utilisateur, les rôles au sein du club et la date de début d'adhésion. Cette date de
début d'adhésion doit se situer entre les champs ``club.membership_start`` et ``club.membership_end``,
si ces champs sont non nuls. Si ``club.parent_club`` n'est pas nul, l'utilisateur⋅rice doit être membre de ce club.
Le montant de la cotisation est fixé en fonction du statut normalien de l'utilisateur⋅rice (``club.membership_fee_paid``
centimes pour les élèves et ``club.membership_fee_unpaid`` centimes pour les étudiant⋅es). La date de fin est calculée
si ces champs sont non nuls. Si ``club.parent_club`` n'est pas nul, l'utilisateur doit être membre de ce club.
Le montant de la cotisation est fixé en fonction du statut normalien de l'utilisateur (``club.membership_fee_paid``
centimes pour les élèves et ``club.membership_fee_unpaid`` centimes pour les étudiants). La date de fin est calculée
comme ce qui suit :
* Si ``club.membership_duration`` est non nul, alors ``date_end`` = ``date_start`` + ``club.membership_duration``
* Sinon ``club``, ``date_end`` = ``date_start`` + 424242 jours (suffisant pour tenir au moins une vie)
* Si ``club.membership_end`` est non nul, alors ``date_end`` = min(``date_end``, ``club.membership_end``)
Si l'utilisateur⋅rice n'est pas membre du club ``Kfet``, l'adhésion n'est pas possible si le solde disponible sur sa note est
Si l'utilisateur n'est pas membre du club ``Kfet``, l'adhésion n'est pas possible si le solde disponible sur sa note est
insuffisant. Une fois toute ces contraintes vérifiées, l'adhésion est créée. Une transaction de type
``MembershipTransaction`` est automatiquement créée de la note de l'utilisateur⋅rice vers la note du club, finalisant l'adhésion.
``MembershipTransaction`` est automatiquement créée de la note de l'utilisateur vers la note du club, finalisant l'adhésion.
Réadhésions
~~~~~~~~~~~
@ -137,7 +137,7 @@ Il est possible de réadhérer si :
* Il n'y a pas encore de réadhésion (pas d'adhésion au même club vérifiant ``new_membership.date_start`` >= ``club.membership_start``)
Un bouton ``Réadhérer`` apparaît dans la liste des adhésions si le droit est permis et si ces contraintes sont vérifiées.
En réadhérant, une nouvelle adhésion est créée pour l'utilisateur⋅rice avec les mêmes rôles, commençant le lendemain de la
En réadhérant, une nouvelle adhésion est créée pour l'utilisateur avec les mêmes rôles, commençant le lendemain de la
date d'expiration de la précédente adhésion. Si on réadhère le 16 août pour une adhésion finissant le 30 septembre,
la nouvelle adhésion commencera le 1er octobre).

View File

@ -7,23 +7,23 @@ Affichage
La page de consommations est principalement une communication entre l'`API <../api>`_ et la page en JavaScript.
Elle est disponible à l'adresse ``/note/consos/``, et l'onglet n'est visible que pour ceux ayant le droit de voir au
moins un bouton. L'affichage, comme tout le reste de la page, est géré avec Boostrap 4.
Les boutons que l'utilisateur⋅rice a le droit de voir sont triés par catégorie.
Les boutons que l'utilisateur a le droit de voir sont triés par catégorie.
Sélection des consommations
---------------------------
Lorsque l'utilisateur⋅rice commence à taper un nom de note, un appel à l'API sur la page ``/api/note/alias`` est fait,
récupérant les 20 premiers aliases en accord avec la requête. Quand l'utilisateur⋅rice survole un alias, un appel à la page
Lorsque l'utilisateur commence à taper un nom de note, un appel à l'API sur la page ``/api/note/alias`` est fait,
récupérant les 20 premiers aliases en accord avec la requête. Quand l'utilisateur survole un alias, un appel à la page
``/api/note/note/<NOTE_ID>/`` est fait pour récupérer plus d'infos sur la note telles que le solde, le vrai nom de la
note et la photo, si toutefois l'utilisateur⋅rice a le droit de voir ceci.
note et la photo, si toutefois l'utilisateur a le droit de voir ceci.
L'utilisateur⋅rice peut cliquer sur des aliases pour ajouter des émetteur⋅rices, et sur des boutons pour ajouter des consommations.
Cliquer dans la liste des émetteur⋅rices supprime l'élément sélectionné.
L'utilisateur peut cliquer sur des aliases pour ajouter des émetteurs, et sur des boutons pour ajouter des consommations.
Cliquer dans la liste des émetteurs supprime l'élément sélectionné.
Il ya deux possibilités pour faire consommer des adhérent⋅es :
- En mode **consommation simple** (mode par défaut), les consommations sont débitées dès que émetteur⋅rices et consommations
Il ya deux possibilités pour faire consommer des adhérents :
- En mode **consommation simple** (mode par défaut), les consommations sont débitées dès que émetteurs et consommations
sont renseignées.
- En mode **consommation double**, l'utilisateur⋅rice doit cliquer sur « **Consommer !** »" pour débiter toutes les consommations.
- En mode **consommation double**, l'utilisateur doit cliquer sur "Consommer !" pour débiter toutes les consommations.
Débit des consommations
-----------------------
@ -71,7 +71,7 @@ des types. Il vaut `42` lors de la rédaction de cette documentation, mais pourr
Si une erreur survient lors de la requête (droits insuffisants), un message apparaîtra en haut de page.
Dans tous les cas, tous les champs sont réinitialisés.
L'historique et le solde de l'utilisateur⋅rice sont ensuite mis à jour via jQuery, qui permet de recharger une partie de page Web.
L'historique et la balance de l'utilisateur sont ensuite mis à jour via jQuery, qui permet de recharger une partie de page Web.
Validation/dévalidation des transactions
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -86,4 +86,4 @@ une requête PATCH est faite à l'API sur l'adresse ``/api/note/transaction/tran
"valid": false
}
L'historique et le solde sont ensuite rafraîchis. Si une erreur survient, un message apparaîtra.
L'historique et la balance sont ensuite rafraîchis. Si une erreur survient, un message apparaîtra.

View File

@ -1,12 +1,12 @@
Application Note
================
L'application ``note`` gère tout ce qui est en lien avec les flux d'argent et les notes (soldes) des utilisateur⋅rices.
L'application ``note`` gère tout ce qui est en lien avec les flux d'argent et les notes (balances) des utilisateurs.
La gestion des consommations s'effectue principalement via la page dédiée, dont le fonctionnement est expliqué
dans la page `Consommations <consumptions>`_.
Le fonctionnement des crédit/débit de note (avec le « monde extérieur »» donc avec de l'argent réel) ainsi que les
Le fonctionnnemnent des crédit/débit de note (avec le "monde extérieur" donc avec de l'argent réel) ainsi que les
transferts/dons entre notes est détaillé sur la page `Transferts <transactions>`_.
.. toctree::

View File

@ -6,7 +6,7 @@ Affichage
L'interface de la page de transferts est semblable à celles des consommations, et l'auto-complétion de note est géré de
la même manière. La page se trouve à l'adresse ``/note/transfer/``. La liste des 20 transactions les plus récentes que
l'utilisateur⋅rice a le droit de voir est également présente.
l'utilisateur a le droit de voir est également présente.
Des boutons ``Don``, ``Transfert``, ``Crédit``, ``Retrait`` sont présents, représentant les différents modes de
transfert. Pour chaque transfert, un montant et une description sont attendus.
@ -23,7 +23,7 @@ Onglets Crédit et retrait
Ces onglets ne sont visibles que par ceux qui ont le droit de voir les ``SpecialNote``.
Une boîte supplémentaire apparaît, demandant en plus de la note, du montant et de la raison le nom, le prénom et
la banque de la personne à recharger/retirer. Lorsqu'une note est sélectionnée, les champs « nom » et « prénom » sont
la banque de la personne à recharger/retirer. Lorsqu'une note est sélectionnée, les champs "nom" et "prénom" sont
remplis automatiquement. Par ailleurs, seule une note peut être choisie.
Transfert

View File

@ -1,8 +1,8 @@
Droits
======
Le système de droit par défaut de Django n'est pas suffisamment granulaire pour les besoins de la Note Kfet 2020.
Un système personnalisé a donc été développé.
Le système de droit par défault de django n'est pas suffisament granulaire pour les besoins de la NoteKfet2020.
Un système custom a donc été développé.
Il permet la création de Permission, qui autorise ou non a faire une action précise sur un ou des objets
de la base de données.
@ -22,12 +22,12 @@ Une permission est un Model Django dont les principaux attributs sont :
* ``query`` : Requête sur la cible, encodé en JSON, traduit en un Q object (cf `Query <#compilation-de-la-query>`_)
* ``field`` : le champ cible qui pourra être modifié. (tous les champs si vide)
Pour savoir si un⋅e utilisateur⋅rice a le droit sur un modèle ou non, la requête est compilée (voir ci-dessous) en un filtre
de requête dans la base de données, un objet de la classe ``Q`` (En SQL l'objet Q s'interprète comme tout ce qui suit
Pour savoir si un utilisateur a le droit sur un modèle ou non, la requête est compilée (voir ci-dessous) en un filtre
de requête dans la base de données, un objet de la classe ``Q`` (En SQL l'objet Q s'interprete comme tout ce qui suit
un ``WHERE ...`` Ils peuvent être combiné à l'aide d'opérateurs logiques. Plus d'information sur les Q object dans la
`documentation officielle <https://docs.djangoproject.com/fr/2.2/topics/db/queries/#complex-lookups-with-q-objects>`_.
Ce Q object sera donc utilisé pour savoir si l'instance que l'on veut modifier est concernée par notre permission.
Ce Q object sera donc utilisé pour savoir si l'instance que l'on veux modifier est concernée par notre permission.
Exception faite sur l'ajout d'objets : l'objet n'existant pas encore en base de données, il est ajouté puis supprimé
à la volée, en prenant soin de désactiver les signaux.
@ -36,7 +36,7 @@ Compilation de la query
-----------------------
La query est enregistrée sous un format JSON, puis est traduite en requête ``Q`` récursivement en appliquant certains paramètres.
Le fonctionnemente de base des permission peux être décris avec les différents opérations :
Le fonctionnemente de base des permission peux être décris avec les differents opérations :
+----------------+-----------------------------+-------------------------------------+
| opérations | JSON | Q object |
@ -64,7 +64,7 @@ Exemples
{"is_superuser": true}
| si l'utilisateur⋅rice cible est un⋅e super utilisateur⋅rice.
| si l'utilisateur cible est un super utilisateur.
* sur le model ``Note`` :
@ -74,7 +74,7 @@ Exemples
["user","note", "pk"]
}
| si l'identifiant de la note cible est l'identifiant de l'utilisateur⋅rice dont on regarde la permission.
| si l'identifiant de la note cible est l'identifiant de l'utilisateur dont on regarde la permission.
* sur le model ``Transaction``:
@ -87,7 +87,7 @@ Exemples
["user", "note", "balance"]}
]
| si la source est la note de l'utilisateur⋅rice et si le montant est inférieur à son solde.
| si la source est la note de l'utilisateur et si le montant est inférieur à son solde.
* Sur le model ``Alias``
@ -106,7 +106,7 @@ Exemples
}
]
| si l'alias appartient à une note de club ou s'il appartient à la note d'un⋅e utilisateur⋅rice membre du club Kfet.
| si l'alias appartient à une note de club ou s'il appartient à la note d'un utilisateur membre du club Kfet.
* sur le model ``Transaction``
@ -118,31 +118,31 @@ Exemples
{"F": [
"ADD",
["F", "source__balance"],
5000]
2000]
}
}
]
| si la destination est la note du club dont on est membre et si le montant est inférieur au solde de la source + 50 €,
autrement dit le solde final est au-dessus de -50 €.
| si la destination est la note du club dont on est membre et si le montant est inférieur au solde de la source + 20 €,
autrement dit le solde final est au-dessus de -20 €.
Masques de permissions
----------------------
Chaque permission est associée à un masque. À la connexion, l'utilisateur⋅rice choisit le masque de droits avec lequel il
souhaite se connecter. Les masques sont ordonnés totalement, et l'utilisateur⋅rice aura effectivement une permission s'il est
Chaque permission est associée à un masque. À la connexion, l'utilisateur choisit le masque de droits avec lequel il
souhaite se connecter. Les masques sont ordonnés totalement, et l'utilisateur aura effectivement une permission s'il est
en droit d'avoir la permission et si son masque est suffisamment haut.
Par exemple, si la permission de voir toutes les transactions est associée au masque « Droits note uniquement »,
se connecter avec le masque « Droits basiques » n'octroiera pas cette permission tandis que le masque « Tous mes droits » oui.
Par exemple, si la permission de voir toutes les transactions est associée au masque "Droits note uniquement",
se connecter avec le masque "Droits basiques" n'octroiera pas cette permission tandis que le masque "Tous mes droits" oui.
Signaux
-------
À chaque fois qu'un modèle est modifié, ajouté ou supprimé, les droits sont contrôlés. Si les droits ne sont pas
suffisants, une erreur est lancée. Pour ce qui est de la modification, on ne contrôle que les champs réellement
modifiés en comparant l'ancienne et la nouvelle instance.
modifiés en comparant l'ancienne et la nouvele instance.
Graphe des modèles
------------------

View File

@ -4,7 +4,7 @@ Inscriptions
L'inscription a la note se fait via une application dédiée, sans toutefois avoir de modèle en base de données.
Un formulaire d'inscription est disponible sur la page ``/registration/signup``, accessible depuis n'importe qui,
authentifié⋅e ou non. Les informations suivantes sont demandées :
authentifié ou non. Les informations suivantes sont demandées :
* Prénom
* Nom de famille
@ -15,7 +15,7 @@ authentifié⋅e ou non. Les informations suivantes sont demandées :
* Département d'études
* Promotion, année d'entrée à l'ENS
* Adresse (optionnel)
* Payé⋅e (si la personne perçoit un salaire)
* Payé (si la personne perçoit un salaire)
Le mot de passe doit vérifier des contraintes de longueur, de complexité et d'éloignement des autres informations
personnelles.
@ -34,28 +34,28 @@ le compte sera enfin actif.
Pour récapituler : compte actif = adresse e-mail validée + inscription validée par le BDE.
Lors de la validation de l'inscription, le BDE peut (et doit même) faire un crédit initial sur la future note de
l'utilisateur⋅rice. Il peut spécifier le type de crédit (carte bancaire/espèces/chèque/virement bancaire), le prénom,
l'utilisateur. Il peut spécifier le type de crédit (carte bancaire/espèces/chèque/virement bancaire), le prénom,
le nom et la banque comme un crédit normal. Cependant, il peut aussi cocher une case "Société générale", si le nouveau
membre indique avoir ouvert un compte à la Société générale via le partenariat Société générale - BDE de
l'ÉNS Paris-Saclay. Dans ce cas, tous les champs sont grisés.
Une fois l'inscription validée, détail de ce qu'il se passe :
* Si crédit de la société générale, on mémorise que le fait que la personne ait demandé ce crédit (voir
* Si crédit de la socitété générale, on mémorise que le fait que la personne ait demandé ce crédit (voir
`Trésorerie <treasury>`_ section crédits de la société générale). Nécessairement, le club Kfet doit être rejoint.
* Sinon, on crédite la note du montant demandé par læ nouvelleau membre (avec comme description "Crédit TYPE (Inscription)"
* Sinon, on crédite la note du montant demandé par le nouveau membre (avec comme description "Crédit TYPE (Inscription)"
où TYPE est le type de crédit), après avoir vérifié que le crédit est suffisant (on n'ouvre pas une note négative)
* On adhère la personne au BDE, l'adhésion commence aujourd'hui. Iel dispose d'un unique rôle : « Adhérent⋅e BDE »,
* On adhère la personne au BDE, l'adhésion commence aujourd'hui. Il dispose d'un unique rôle : "Adhérent BDE",
lui octroyant un faible nombre de permissions de base, telles que la visualisation de son compte.
* On adhère la personne au club Kfet si cela est demandé, l'adhésion commence aujourd'hui. Iel dispose d'un unique rôle :
« Adhérent⋅e Kfet» , lui octroyant un nombre un peu plus conséquent de permissions basiques, telles que la possibilité de
faire des transactions, d'accéder aux activités, au WEI,
* Si læ nouvelleau membre a indiqué avoir ouvert un compte à la société générale, alors les transactions sont invalidées,
* On adhère la personne au club Kfet si cela est demandé, l'adhésion commence aujourd'hui. Il dispose d'un unique rôle :
"Adhérent Kfet", lui octroyant un nombre un peu plus conséquent de permissions basiques, telles que la possibilité de
faire des transactions, d'accéder aux activités, au WEI, ...
* Si le nouveau membre a indiqué avoir ouvert un compte à la société générale, alors les transactions sont invalidées,
la note n'est pas débitée (commence alors à 0 €).
Par ailleurs, le BDE peut supprimer la demande d'inscription sans problème via un bouton dédié. Cette opération
n'est pas réversible.
L'utilisateur⋅rice a enfin accès a sa note et peut faire des bêtises :)
L'utilisateur a enfin accès a sa note et peut faire des bêtises :)
L'inscription au BDE et à la Kfet est indépendante de l'inscription au WEI. Voir `WEI <wei>`_ pour l'inscription WEI.

View File

@ -1,7 +1,7 @@
Application Trésorerie
======================
L'application de Trésorerie facilite la vie des trésorièr⋅es, et sert d'interface de création de facture.
L'application de Trésorerie facilite la vie des trésorier, et sert d'interface de création de facture.
Elle permet également le suivi des remises de chèques reçus par le BDE et des crédits de la Société générale.
Factures
@ -33,7 +33,7 @@ Produits
* ``invoice`` : ``ForeignKey`` vers la facture associée au produit
* ``designation`` : Désignation du produit
* ``quantity`` : Quantité achetée
* ``amount`` : Prix unitaire (HT) du produit (peut être négatif si jamais il s'agit d'un rabais, d'un solde prépayé,)
* ``amount`` : Prix unitaire (HT) du produit (peut être négatif si jamais il s'agit d'un rabais, d'un solde prépayé, ...)
Pour ajouter des produits à une facture, cela se passe sur le même formulaire d'ajout/de modification de factures.
Pour cela, on utilise un ``FormSet``, qui permet de gérer un nombre arbitraire de formulaires
@ -90,7 +90,7 @@ présent à l'adresse suivante :
On le remplit avec les données de la facture et les données du BDE, hard-codées. On copie le template rempli dans un
ficher tex dans un dossier temporaire. On fait ensuite 2 appels à ``pdflatex`` pour générer la facture au format PDF.
Les deux appels sont nécessaires, il y a besoin d'un double rendu. Ensuite, le PDF est envoyé à l'utilisateur⋅rice et on
Les deux appels sont nécessaires, il y a besoin d'un double rendu. Ensuite, le PDF est envoyé à l'utilisateur et on
supprime les données temporaires.
On remarque que les PDF sont générés à la volée et ne sont pas sauvegardés. Niveau performances, cela prend du temps
@ -155,7 +155,7 @@ Relations
~~~~~~~~~
* Toute transaction qui n'est pas attachée à une remise d'un bon type peut être attachée à une remise. Cela se passe
par le biais d'un formulaire, où læ trésorièr⋅e peut vérifier et corriger au besoin nom, prénom, banque émettrice et montant.
par le biais d'un formulaire, où le trésorier peut vérifier et corriger au besoin nom, prénom, banque émettrice et montant.
* Toute transaction attachée à une remise encore ouverte peut être retirée.
* Pour clore une remise, il faut au moins 1 transaction associée.
@ -174,41 +174,41 @@ Modèle
Cette sous-application dispose d'un unique modèle "SogeCredit" avec les champs suivant :
* ``user`` : ``OneToOneField`` vers ``User``, utilisateur⋅rice associé à ce crédit (relation ``OneToOne`` car chaque
utilisateur⋅rice ne peut bénéficier qu'une seule fois d'un crédit de la Société générale)
* ``user`` : ``OneToOneField`` vers ``User``, utilisateur associé à ce crédit (relation ``OneToOne`` car chaque
utilisateur ne peut bénéficier qu'une seule fois d'un crédit de la Société générale)
* ``transactions`` : ``ManyToManyField`` vers ``MembershipTransaction``, liste des transactions d'adhésion associées
à ce crédit, généralement adhésion BDE+Kfet+WEI même si cela n'est pas restreint
* ``credit_transaction`` : ``OneToOneField`` vers ``SpecialTransaction``, peut être nulle, transaction de crédit de la
Société générale vers la note de l'utilisateur⋅rice si celui-ci a été validé. C'est d'ailleurs le témoin
Société générale vers la note de l'utilisateur si celui-ci a été validé. C'est d'ailleurs le témoin
de validation du crédit.
On sait qu'un⋅e utilisateur⋅rice a déjà demandé un crédit de la Société générale s'il existe un crédit associé à cet⋅te
utilisateur⋅rice avec une transaction associée. Par ailleurs, le modèle ``Profile`` contient une propriété ``soge`` qui
On sait qu'un utilisateur a déjà demandé un crédit de la Société générale s'il existe un crédit associé à cet
utilisateur avec une transaction associée. Par ailleurs, le modèle ``Profile`` contient une propriété ``soge`` qui
traduit exactement ceci, et qui vaut ``False`` si jamais l'application Trésorerie n'est pas chargée.
Si jamais l'utilisateur⋅rice n'a pas encore demandé de crédit de la Société générale (ou que celui-ci n'est pas encore validé),
l'utilisateur⋅rice peut demander un tel crédit lors de son adhésion BDE, de sa réadhésion BDE ou de son inscription au WEI.
Dans les deux premiers cas, iel est invité⋅e à jumeler avec une nouvelle adhésion Kfet (merci de d'abord se réadhérer au
Si jamais l'utilisateur n'a pas encore demandé de crédit de la Société générale (ou que celui-ci n'est pas encore validé),
l'utilisateur peut demander un tel crédit lors de son adhésion BDE, de sa réadhésion BDE ou de son inscription au WEI.
Dans les deux premiers cas, il est invité à jumeler avec une nouvelle adhésion Kfet (merci de d'abord se réadhérer au
BDE avant la Kfet dans ce cas).
Lorsqu'une telle demande est faite, l'adhésion est créée avec une transaction d'adhésion invalide. Cela implique que
la note source n'est pas débitée et la note destination n'est pas créditée.
Sur son interface, læ trésorièr⋅e peut récupérer les crédits de Société générale invalides. Deux options s'offrent à ellui :
Sur son interface, le trésorier peut récupérer les crédits de Société générale invalides. Deux options s'offrent à lui :
* Supprimer la demande. Dans ce cas, les transactions vont être validées, la note de l'utilisateur⋅rice sera débité, les
clubs seront crédités. Puisque la demande sera supprimée, l'utilisateur⋅rice pourra à nouveau à l'avenir déclarer avoir
ouvert un compte à la Société générale. Cette option est utile dans le cas où l'utilisateur⋅rice est un boulet (ou pas,
* Supprimer la demande. Dans ce cas, les transactions vont être validées, la note de l'utilisateur sera débité, les
clubs seront crédités. Puisque la demande sera supprimée, l'utilisateur pourra à nouveau à l'avenir déclarer avoir
ouvert un compte à la Société générale. Cette option est utile dans le cas où l'utilisateur est un boulet (ou pas,
pour d'autres raisons) et a déclaré vouloir ouvrir un compte à la Société générale sans ne rien faire.
Cette action est irréversible, et n'est pas possible si la note de l'utilisateur⋅rice n'a pas un solde suffisant.
Cette action est irréversible, et n'est pas possible si la note de l'utilisateur n'a pas un solde suffisant.
* Valider la demande. Dans ce cas, un crédit de la note "Virements bancaires" vers la note de l'utilisateur⋅rice sera créé,
* Valider la demande. Dans ce cas, un crédit de la note "Virements bancaires" vers la note de l'utilisateur sera créé,
la transaction sera liée à la demande via le champ ``credit_note`` (et donc la demande déclarée valide), et toutes les
transactions d'adhésion seront déclarées valides.
* Demander à un⋅e respo info s'il y a un problème pour le régler avant de faire des bêtises. Je l'admets, ça fait trois options.
* Demander à un respo info s'il y a un problème pour le régler avant de faire des bêtises. Je l'admets, ça fait trois options.
La validité d'une transaction d'adhésion n'a aucune influence sur l'adhésion elle-même. Toutefois, cela se remarque rapidement
La validité d'une transaction d'adhésion n'a aucune influence sur l'adhésion elle-même. Toutefois, cela se remarque rapidement ...
.. image:: /_static/img/treasury_validate_sogecredit.png

View File

@ -23,10 +23,10 @@ Champs hérités de ``Club`` de l'application ``member`` :
* ``membership_start`` : ``DateField``, date à partir de laquelle il est possible de s'inscrire au WEI.
* ``membership_end`` : ``DateField``, date de fin d'adhésion possible au WEI.
* ``membership_duration`` : ``PositiveIntegerField``, inutilisé dans le cas d'un WEI, vaut ``None``.
* ``membership_fee_paid`` : ``PositiveIntegerField``, montant de la cotisation (en centimes) pour qu'un⋅e élève normalien⋅ne
(donc rémunéré⋅e) puisse adhérer.
* ``membership_fee_unpaid`` : ``PositiveIntegerField``, montant de la cotisation (en centimes) pour qu'un⋅e étudiant⋅e
normalien⋅ne (donc non rémunéré⋅e) puisse adhérer.
* ``membership_fee_paid`` : ``PositiveIntegerField``, montant de la cotisation (en centimes) pour qu'un élève normalien
(donc rémunéré) puisse adhérer.
* ``membership_fee_unpaid`` : ``PositiveIntegerField``, montant de la cotisation (en centimes) pour qu'un étudiant
normalien (donc non rémunéré) puisse adhérer.
* ``name`` : ``CharField``, nom du WEI.
* ``require_memberships`` : ``BooleanField``, vaut toujours ``True`` pour le WEI.
@ -65,27 +65,27 @@ que de dissocier les rôles propres au WEI des rôles s'appliquant pour n'import
WEIRegistration
~~~~~~~~~~~~~~~
Inscription au WEI, contenant les informations avant validation. Ce modèle est créé dès lors que quelqu'un⋅e se pré-inscrit au WEI.
Inscription au WEI, contenant les informations avant validation. Ce modèle est créé dès lors que quelqu'un se pré-inscrit au WEI.
* ``user`` : ``ForeignKey(User)``, utilisateur⋅rice qui s'est pré-inscrit⋅e. Ce champ est unique avec ``wei``.
* ``wei`` : ``ForeignKey(WEIClub)``, le WEI auquel l'utilisateur⋅rice s'est pré-inscrit⋅e. Ce champ est unique avec ``user``.
* ``soge_credit`` : ``BooleanField``, indique si l'utilisateur⋅rice a déclaré vouloir ouvrir un compte à la Société générale.
* ``caution_check`` : ``BooleanField``, indique si l'utilisateur⋅rice (en 2ème année ou plus) a bien remis son chèque de
* ``user`` : ``ForeignKey(User)``, utilisateur qui s'est pré-inscrit. Ce champ est unique avec ``wei``.
* ``wei`` : ``ForeignKey(WEIClub)``, le WEI auquel l'utilisateur s'est pré-inscrit. Ce champ est unique avec ``user``.
* ``soge_credit`` : ``BooleanField``, indique si l'utilisateur a déclaré vouloir ouvrir un compte à la Société générale.
* ``caution_check`` : ``BooleanField``, indique si l'utilisateur (en 2ème année ou plus) a bien remis son chèque de
caution auprès de la trésorerie.
* ``birth_date`` : ``DateField``, date de naissance de l'utilisateur⋅rice.
* ``birth_date`` : ``DateField``, date de naissance de l'utilisateur.
* ``gender`` : ``CharField`` parmi ``male`` (Homme), ``female`` (Femme), ``non binary`` (Non binaire), genre de la personne.
* ``health_issues`` : ``TextField``, problèmes de santé déclarés par l'utilisateur⋅rice.
* ``health_issues`` : ``TextField``, problèmes de santé déclarés par l'utilisateur.
* ``emergency_contact_name`` : ``CharField``, nom du contact en cas d'urgence.
* ``emergency_contact_phone`` : ``CharField``, numéro de téléphone du contact en cas d'urgence.
* ``ml_events_registration`` : ``BooleanField``, déclare si l'utilisateur⋅rice veut s'inscrire à la liste de diffusion des
* ``ml_events_registration`` : ``BooleanField``, déclare si l'utilisateur veut s'inscrire à la liste de diffusion des
événements du BDE (1A uniquement)
* ``ml_art_registration`` : ``BooleanField``, déclare si l'utilisateur⋅rice veut s'inscrire à la liste de diffusion des
* ``ml_art_registration`` : ``BooleanField``, déclare si l'utilisateur veut s'inscrire à la liste de diffusion des
actualités du BDA (1A uniquement)
* ``ml_sport_registration`` : ``BooleanField``, déclare si l'utilisateur⋅rice veut s'inscrire à la liste de diffusion des
* ``ml_sport_registration`` : ``BooleanField``, déclare si l'utilisateur veut s'inscrire à la liste de diffusion des
actualités du BDS (1A uniquement)
* ``first_year`` : ``BooleanField``, indique si l'inscription est d'un⋅e 1A ou non. Non modifiable par n'importe qui.
* ``first_year`` : ``BooleanField``, indique si l'inscription est d'un 1A ou non. Non modifiable par n'importe qui.
* ``information_json`` : ``TextField`` non modifiable manuellement par n'importe qui stockant les informations du
questionnaire d'inscription au WEI pour les 1A, et stocke les demandes faites par un⋅e 2A+ concernant bus, équipes et rôles.
questionnaire d'inscription au WEI pour les 1A, et stocke les demandes faites par un 2A+ concerant bus, équipes et rôles.
On utilise un ``TextField`` contenant des données au format JSON pour permettre de la modularité au fil des années,
sans avoir à tout casser à chaque fois.
@ -94,19 +94,19 @@ WEIMembership
Ce modèle hérite de ``Membership`` et contient les informations d'une adhésion au WEI.
* ``bus`` : ``ForeignKey(Bus)``, bus dans lequel se trouve l'utilisateur⋅rice.
* ``bus`` : ``ForeignKey(Bus)``, bus dans lequel se trouve l'utilisateur.
* ``team`` : ``ForeignKey(BusTeam)`` pouvant être nulle (pour les chefs de bus et électrons libres), équipe dans laquelle
se trouve l'utilisateur⋅rice.
se trouve l'utilisateur.
* ``registration`` : ``OneToOneField(WEIRegistration)``, informations de la pré-inscription.
Champs hérités du modèle ``Membership`` :
* ``club`` : ``ForeignKey(Club)``, club lié à l'adhésion. Doit être un ``WEIClub``.
* ``user`` : ``ForeignKey(User)``, utilisateur⋅rice qui a adhéré.
* ``user`` : ``ForeignKey(User)``, utilisateur adhéré.
* ``date_start`` : ``DateField``, date de début d'adhésion.
* ``date_end`` : ``DateField``, date de fin d'adhésion.
* ``fee`` : ``PositiveIntegerField``, montant de la cotisation payée.
* ``roles`` : ``ManyToManyField(Role)``, liste des rôles endossés par l'adhérent⋅e. Les rôles doivent être des ``WEIRole``.
* ``roles`` : ``ManyToManyField(Role)``, liste des rôles endossés par l'adhérent. Les rôles doivent être des ``WEIRole``.
Graphe des modèles
~~~~~~~~~~~~~~~~~~
@ -123,32 +123,32 @@ Fonctionnement
Création d'un WEI
~~~~~~~~~~~~~~~~~
Seul un⋅e respo info peut créer un WEI. Pour cela, se rendre dans l'onglet WEI, puis « Liste des WEI » et enfin
« Créer un WEI ». Diverses informations sont demandées, comme le nom du WEI, l'adresse mail de contact, l'année du WEI
Seul un respo info peut créer un WEI. Pour cela, se rendre dans l'onglet WEI, puis "Liste des WEI" et enfin
"Créer un WEI". Diverses informations sont demandées, comme le nom du WEI, l'adresse mail de contact, l'année du WEI
(doit être unique), les dates de début et de fin, et les dates pendant lesquelles les utilisateurs peuvent s'inscrire.
Don des droits à un GC WEI
~~~~~~~~~~~~~~~~~~~~~~~~~~
Læ GC WEI peut gérer tout ce qui a un rapport avec le WEI. Iel ne peut cependant pas créer le WEI, ce privilège est
réservé aux respos info. Pour avoir ses droits, læ GC WEI doit s'inscrire au WEI avec le rôle GC WEI, et donc payer
en premièr⋅e sa cotisation. C'est donc aux respos info de créer l'adhésion du GC WEI. Voir ci-dessous pour l'inscription au WEI.
Le GC WEI peut gérer tout ce qui a un rapport avec le WEI. Il ne peut cependant pas créer le WEI, ce privilège est
réservé au respo info. Pour avoir ses droits, le GC WEI doit s'inscrire au WEI avec le rôle GC WEI, et donc payer
en premier sa cotisation. C'est donc au respo info de créer l'adhésion du GC WEI. Voir ci-dessous pour l'inscription au WEI.
S'inscrire au WEI
~~~~~~~~~~~~~~~~~
N'importe quel⋅le utilisateur⋅rice peut s'auto-inscrire au WEI, lorsque les dates d'adhésion le permettent. Celleux qui se sont
déjà inscrit⋅es peuvent également inscrire un⋅e 1A. Seul⋅es les GC WEI et les respos info peuvent inscrire un⋅e autre 2A+.
N'importe quel utilisateur peut s'auto-inscrire au WEI, lorsque les dates d'adhésion le permettent. Ceux qui se sont
déjà inscrits peuvent également inscrire un 1A. Seuls les GC WEI et les respo info peuvent inscrire un autre 2A+.
À tout moment, tant que le WEI n'est pas passé, l'inscription peut être modifiée, même après validation.
Inscription d'un⋅e 2A+
^^^^^^^^^^^^^^^^^^^^^^
Inscription d'un 2A+
^^^^^^^^^^^^^^^^^^^^
Comme indiqué, les 2A+ sont assez autonomes dans leur inscription au WEI. Iels remplissent le questionnaire et sont
ensuite pré-inscrit⋅es. Le questionnaire se compose de plusieurs champs (voir WEIRegistration) :
Comme indiqué, les 2A+ sont assez autonomes dans leur inscription au WEI. Ils remplissent le questionnaire et sont
ensuite pré-inscrits. Le questionnaire se compose de plusieurs champs (voir WEIRegistration) :
* Est-ce que l'utilisateur⋅rice a déclaré avoir ouvert un compte à la Société générale ? (Option disponible uniquemement
* Est-ce que l'utilisateur a déclaré avoir ouvert un compte à la Société générale ? (Option disponible uniquemement
si cela n'a pas été fait une année avant)
* Date de naissance
* Genre (Homme/Femme/Non-binaire)
@ -159,17 +159,17 @@ ensuite pré-inscrit⋅es. Le questionnaire se compose de plusieurs champs (voir
* Équipes préférées (choix multiple éventuellement vide, vide pour les chefs de bus/staff)
* Rôles souhaités
Les trois derniers champs n'ont aucun caractère définitif et sont simplement là en suggestion pour læ GC WEI qui
Les trois derniers champs n'ont aucun caractère définitif et sont simplement là en suggestion pour le GC WEI qui
validera l'inscription. C'est utile si on hésite entre plusieurs bus.
L'inscription est ensuite créée, le GC WEI devra ensuite la valider (voir plus bas).
Inscription d'un⋅e 1A
^^^^^^^^^^^^^^^^^^^^^
Inscription d'un 1A
^^^^^^^^^^^^^^^^^^^
N'importe quelle personne déjà inscrite au WEI peut inscrire un⋅e 1A. Le formulaire 1A est assez peu différent du formulaire 2A+ :
N'importe quelle personne déjà inscrite au WEI peut inscrire un 1A. Le formulaire 1A est assez peu différent du formulaire 2A+ :
* Est-ce que l'utilisateur⋅rice a déclaré avoir ouvert un compte à la Société générale ?
* Est-ce que l'utilisateur a déclaré avoir ouvert un compte à la Société générale ?
* Date de naissance
* Genre (Homme/Femme/Non-binaire)
* Problèmes de santé
@ -179,10 +179,10 @@ N'importe quelle personne déjà inscrite au WEI peut inscrire un⋅e 1A. Le for
* S'inscrire à la ML BDA
* S'inscrire à la ML BDS
Læ 1A ne peut donc pas choisir de son bus et de son équipe, et peut s'inscrire aux listes de diffusion.
Le 1A ne peut donc pas choisir de son bus et de son équipe, et peut s'inscrire aux listes de diffusion.
Il y a néanmoins une différence majeure : une fois le formulaire rempli, un questionnaire se lance.
Ce questionnaire peut varier au fil des années (voir section Questionnaire), et contient divers formulaires de collecte
de données qui serviront à déterminer quel est le meilleur bus pour ce⋅tte nouvelleau utilisateur⋅rice.
de données qui serviront à déterminer quel est le meilleur bus pour ce nouvel utilisateur.
Questionnaire 1A
^^^^^^^^^^^^^^^^
@ -200,7 +200,7 @@ Je veux changer d'algorithme de répartition, que faire ?
Cette section est plus technique et s'adresse surtout aux respos info en cours de mandat.
Première règle : on ne supprime rien (sauf si vraiment c'est du mauvais boulot). En prenant exemple sur des fichiers déjà existant tels que ``apps/wei/forms/surveys/wei2021.py``, créer un nouveau fichier ``apps/wei/forms/surveys/wei20XY.py``. Ce fichier doit inclure les éléments suivants :
Première règle : on ne supprime rien (sauf si vraiment c'est du mauvais boulot). En prenant exemple sur des fichiers déjà existant tels que ``apps/wei/forms/surveys/wei2020.py``, créer un nouveau fichier ``apps/wei/forms/surveys/wei20XY.py``. Ce fichier doit inclure les éléments suivants :
WEISurvey
"""""""""
@ -223,7 +223,7 @@ Une classe héritant de ``wei.forms.surveys.base.WEISurvey``, comportant les él
Naturellement, il est implicite qu'une fonction ayant pour premier argument ``cls`` doit être annotée par ``@classmethod``.
Nativement, la classe ``WEISurvey`` comprend les informations suivantes :
* ``registration``, le modèle ``WEIRegistration`` de l'utilisateur⋅rice qui remplit le questionnaire
* ``registration``, le modèle ``WEIRegistration`` de l'utilisateur qui remplit le questionnaire
* ``information``, instance de ``WEISurveyInformation``, contient les données du questionnaire en cours de remplissage.
* ``get_wei(cls)``, renvoie le WEI correspondant à l'année du sondage.
* ``save(self)``, enregistre les informations du sondage dans l'objet ``registration`` associé, qui est ensuite
@ -291,7 +291,7 @@ pour unique effet d'appeler la fonction ``run_algorithm`` décrite plus tôt. Un
n'a pas été évoqué d'adhésion. L'adhésion est ensuite manuelle, l'algorithme ne fournit qu'une suggestion.
Cette structure, complexe mais raisonnable, permet de gérer plus ou moins proprement la répartition des 1A,
en limitant très fortement le hard code. Ami nouvelleeau développeur⋅se, merci de bien penser à la propreté du code :)
en limitant très fortement le hard code. Ami nouveau développeur, merci de bien penser à la propreté du code :)
En particulier, on évitera de mentionner dans le code le nom des bus, et profiter du champ ``information_json``
présent dans le modèle ``Bus``.
@ -300,34 +300,34 @@ Valider les inscriptions
Cette partie est moins technique.
Une fois la pré-inscription faite, elle doit être validée par le BDE, afin de procéder au paiement. Læ GC WEI a accès à
Une fois la pré-inscription faite, elle doit être validée par le BDE, afin de procéder au paiement. Le GC WEI a accès à
la liste des inscriptions non validées, soit sur la page de détails du WEI, soit sur un tableau plus large avec filtre.
Une inscription non validée peut soit être validée, soit supprimée (la suppression est irréversible).
Lorsque læ GC WEI veut valider une inscription, iel a accès au récapitulatif de l'inscription ainsi qu'aux informations
personnelles de l'utilisateur⋅rice. Il lui est proposé de les modifier si besoin (du moins les informations liées au WEI,
pas les informations personnelles). Iel a enfin accès aux résultats du sondage et la sortie de l'algorithme s'il s'agit
d'un⋅e 1A, aux préférences d'un⋅e 2A+. Avant de valider, læ GC WEI doit sélectionner un bus, éventuellement une équipe
et un rôle. Si c'est un⋅e 1A et que l'algorithme a tourné, ou si c'est un⋅e 2A+ qui n'a fait qu'un seul choix de bus,
Lorsque le GC WEI veut valider une inscription, il a accès au récapitulatif de l'inscription ainsi qu'aux informations
personnelles de l'utilisateur. Il lui est proposé de les modifier si besoin (du moins les informations liées au WEI,
pas les informations personnelles). Il a enfin accès aux résultats du sondage et la sortie de l'algorithme s'il s'agit
d'un 1A, aux préférences d'un 2A+. Avant de valider, le GC WEI doit sélectionner un bus, éventuellement une équipe
et un rôle. Si c'est un 1A et que l'algorithme a tourné, ou si c'est un 2A+ qui n'a fait qu'un seul choix de bus,
d'équipe, de rôles, les champs sont automatiquement pré-remplis.
Quelques restrictions cependant :
* Si c'est un⋅e 2A+, le chèque de caution doit être déclaré déposé
* Si c'est un 2A+, le chèque de caution doit être déclaré déposé
* Si l'inscription se fait via la Société générale, un message expliquant la situation apparaît : la transaction de
paiement sera créée mais invalidée, les trésorièr⋅es devront confirmer plus tard sur leur interface que le compte
paiement sera créée mais invalidée, les trésoriers devront confirmer plus tard sur leur interface que le compte
à la Société générale a bien été créé avant de valider la transaction (voir `Trésorerie <treasury>`_ section
Crédit de la Société générale).
* Dans le cas contraire, l'utilisateur⋅rice doit avoir le solde nécessaire sur sa note avant de pouvoir adhérer.
* L'utilisateur⋅rice doit enfin être membre du club Kfet. Un lien est présent pour le faire adhérer ou réadhérer selon le cas.
* Dans le cas contraire, l'utilisateur doit avoir le solde nécessaire sur sa note avant de pouvoir adhérer.
* L'utilisateur doit enfin être membre du club Kfet. Un lien est présent pour le faire adhérer ou réadhérer selon le cas.
Si tout est bon, læ GC WEI peut valider. L'utilisateur⋅rice a bien payé son WEI, et son interface est un peu plus grande.
Iel peut toujours changer ses paramètres au besoin. Un⋅e 1A ne voit rien de plus avant la fin du WEI.
Si tout est bon, le GC WEI peut valider. L'utilisateur a bien payé son WEI, et son interface est un peu plus grande.
Il peut toujours changer ses paramètres au besoin. Un 1A ne voit rien de plus avant la fin du WEI.
Un⋅e adhérent⋅e WEI non 1A a accès à la liste des bus, des équipes et de leur descriptions. Les chef⋅fes de bus peuvent gérer
les bus et leurs équipes. Les chef⋅fes d'équipe peuvent gérer leurs équipes. Cela inclut avoir accès à la liste des membres
Un adhérent WEI non 1A a accès à la liste des bus, des équipes et de leur descriptions. Les chefs de bus peuvent gérer
les bus et leurs équipes. Les chefs d'équipe peuvent gérer leurs équipes. Cela inclut avoir accès à la liste des membres
de ce bus / de cette équipe.
Un export au format PDF de la liste des membres *visibles* est disponible pour chacun⋅e.
Un export au format PDF de la liste des membres *visibles* est disponible pour chacun.
Bon WEI à toustes !
Bon WEI à tous !

View File

@ -18,7 +18,7 @@
# -- Project information -----------------------------------------------------
project = 'Note Kfet 2020'
copyright = '2020-2022, BDE ENS Paris-Saclay'
copyright = '2020-2021, BDE ENS Paris-Saclay'
author = 'BDE ENS Paris-Saclay'
# The full version, including alpha/beta/rc tags

View File

@ -3,7 +3,7 @@ Service d'Authentification Centralisé (CAS)
Un `CAS <https://fr.wikipedia.org/wiki/Central_Authentication_Service>`_ est
déployé sur la Note Kfet. Il est accessible à l'adresse `<https://note.crans.org/cas/>`_.
Il a pour but uniquement d'authentifier les utilisateur⋅rices via la note et ne communique
Il a pour but uniquement d'authentifier les utilisateurs via la note et ne communique
que peu d'informations.
Configuration

View File

@ -12,9 +12,9 @@ Applications externes
L'utilisation de la note par des services externes est actuellement en beta. Il est
fort à parier que cette utilisation sera revue et améliorée à l'avenir.
Puisque la Note Kfet recense tous les comptes des adhérent⋅es BDE, les clubs ont alors
Puisque la Note Kfet recense tous les comptes des adhérents BDE, les clubs ont alors
la possibilité de développer leurs propres applications et de les interfacer avec la
note. De cette façon, chaque application peut authentifier ses utilisateur⋅rices via la note,
note. De cette façon, chaque application peut authentifier ses utilisateurs via la note,
et récupérer leurs adhésion, leur nom de note afin d'éventuellement faire des transferts
via l'API.
@ -25,4 +25,4 @@ Deux protocoles d'authentification sont implémentées :
À ce jour, il n'y a pas encore d'exemple d'utilisation d'application qui utilise ce
mécanisme, mais on peut imaginer par exemple que la Mediatek ou l'AMAP implémentent
ces protocoles pour récupérer leurs adhérent⋅es.
ces protocoles pour récupérer leurs adhérents.

View File

@ -2,12 +2,12 @@ OAuth2
======
L'authentification `OAuth2 <https://fr.wikipedia.org/wiki/OAuth>`_ est supportée par la
Note Kfet. Elle offre l'avantage non seulement d'identifier les utilisateur⋅rices, mais
aussi de transmettre des informations à un service tiers tels que des informations
personnelles, le solde de la note ou encore les adhésions de l'utilisateur⋅rice, en
l'avertissant sur quelles données sont effectivement collectées. Ainsi, il est possible
de développer des appplications tierces qui peuvent se baser sur les données de la Note
Kfet ou encore faire des transactions.
Note Kfet. Elle offre l'avantage non seulement d'identifier les utilisateurs, mais aussi
de transmettre des informations à un service tiers tels que des informations personnelles,
le solde de la note ou encore les adhésions de l'utilisateur, en l'avertissant sur
quelles données sont effectivement collectées. Ainsi, il est possible de développer des
appplications tierces qui peuvent se baser sur les données de la Note Kfet ou encore
faire des transactions.
Configuration du serveur
@ -79,7 +79,7 @@ Il vous suffit de donner à votre application :
* Les scopes, qui peuvent être récupérées sur cette page : `<https://note.crans.org/permission/scopes/>`_
* L'URL d'autorisation : `<https://note.crans.org/o/authorize/>`_
* L'URL d'obtention de jeton : `<https://note.crans.org/o/token/>`_
* Si besoin, l'URL de récupération des informations de l'utilisateur⋅rice : `<https://note.crans.org/api/me/>`_
* Si besoin, l'URL de récupération des informations de l'utilisateur : `<https://note.crans.org/api/me/>`_
N'hésitez pas à consulter la page `<https://note.crans.org/api/me/>`_ pour s'imprégner
du format renvoyé.
@ -88,14 +88,14 @@ du format renvoyé.
Un petit mot sur les scopes : tel qu'implémenté, une scope est une permission unitaire
(telle que décrite dans le modèle ``Permission``) associée à un club. Ainsi, un jeton
a accès à une scope si et seulement si læ propriétaire du jeton dispose d'une adhésion
a accès à une scope si et seulement si le/la propriétaire du jeton dispose d'une adhésion
courante dans le club lié à la scope qui lui octroie cette permission.
Par exemple, un jeton pourra avoir accès à la permission de créer des transactions en lien
avec un club si et seulement si læ propriétaire du jeton est trésorièr⋅e du club.
avec un club si et seulement si le propriétaire du jeton est trésorier du club.
La vérification des droits de læ propriétaire est faite systématiquement, afin de ne pas
faire confiance au jeton en cas de droits révoqués à saon propriétaire.
La vérification des droits du propriétaire est faite systématiquement, afin de ne pas
faire confiance au jeton en cas de droits révoqués à son propriétaire.
Vous pouvez donc contrôler le plus finement possible les permissions octroyées à vos
jetons.
@ -118,8 +118,8 @@ du format renvoyé.
Par exemple, vous pourriez demander la permission d'accéder
aux membres d'un club ou de faire des transactions, et agir
uniquement dans le cas où l'utilisateur⋅rice connecté⋅e
possède la permission problématique.
uniquement dans le cas où l'utilisateur connecté possède la
permission problématique.
Avec Django-allauth
###################
@ -152,7 +152,7 @@ Le paramètre ``DOMAIN`` permet de changer d'instance de Note Kfet. Par défaut,
se connectera à ``note.crans.org`` si vous ne renseignez rien.
Le paramètre ``SCOPE`` permet de définir les scopes à demander.
Dans l'exemple ci-dessous, les permissions d'accéder à l'utilisateur⋅rice
Dans l'exemple ci-dessous, les permissions d'accéder à l'utilisateur
et au profil sont demandées.
En créant l'application sur la note, vous pouvez renseigner
@ -200,7 +200,7 @@ cas où elle n'est pas explicitement indiquée lors de l'autorisation.
Lorsqu'un client veut s'authentifier via la Note Kfet, il va devoir accéder à une page
d'authentification. La page d'autorisation est `<https://note.crans.org/o/authorize/>`_,
c'est sur cette page qu'il faut rediriger les utilisateur⋅rices. Il faut mettre en paramètre GET :
c'est sur cette page qu'il faut rediriger les utilisateurs. Il faut mettre en paramètre GET :
* ``client_id`` : l'identifiant client de l'application (public) ;
* ``response_type`` : mettre ``code`` ;
@ -211,10 +211,10 @@ c'est sur cette page qu'il faut rediriger les utilisateur⋅rices. Il faut mettr
* ``state`` : optionnel, peut être utilisé pour permettre au client de détecter des requêtes
provenant d'autres sites.
Sur cette page, les permissions demandées seront listées, et l'utilisateur⋅rice aura le
choix d'accepter ou non. Dans les deux cas, l'utilisateur⋅rice sera redirigée vers
``redirect_uri``, avec pour paramètre GET soit le message d'erreur, soit un paramètre
``code`` correspondant au code d'autorisation.
Sur cette page, les permissions demandées seront listées, et l'utilisateur aura le choix
d'accepter ou non. Dans les deux cas, l'utilisateur sera redirigée vers ``redirect_uri``,
avec pour paramètre GET soit le message d'erreur, soit un paramètre ``code`` correspondant
au code d'autorisation.
Une fois ce code d'autorisation récupéré, il faut désormais récupérer le jeton d'accès.
Il faut pour cela aller sur l'URL `<https://note.crans.org/o/token/>`_, effectuer une

View File

@ -5,7 +5,7 @@ Des transactions anormales sont apparues sur mon compte.
--------------------------------------------------------
.. note::
Tu dois immédiatement contacter les trésorièr⋅es du BDE (voir ci-dessous) pour
Tu dois immédiatement contacter les trésoriers du BDE (voir ci-dessous) pour
signaler l'incident. Précise bien ton nom de note, l'heure de la transaction
ainsi que l'alias utilisé pour faire la transaction (en plaçant ta souris sur
ton pseudo sur la ligne de transaction, l'alias utilisé apparaît). La raison
@ -19,13 +19,13 @@ Je souhaite consommer mais le solde de ma note est insuffisant
--------------------------------------------------------------
.. note::
Le BDE ne fait pas crédit à ses adhérent⋅es. Il est de ton devoir de t'assurer
d'avoir en permanence un solde positif sur ta note. Les permanencièr⋅es à la
Le BDE ne fait pas crédit à ses adhérents. Il est de ton devoir de t'assurer
d'avoir en permanence un solde positif sur ta note. Les permanenciers à la
Kfet ont la possibilité de refuser une consommation qui fait passer en négatif,
et ont obligation de refuser si la consommation est alcoolisée, en accord avec
la règlementation en vigueur.
Les trésorièr⋅es connaissent la liste des personnes en situation irrégulière et
Les trésoriers connaissent la liste des personnes en situation irrégulière et
n'hésiteront pas à faire des rappels pour recharger la note.
@ -34,9 +34,9 @@ Comment recharger ma note ?
.. note::
Le solde de la note peut être rechargé soit par espèces, par chèque à l'ordre
de l'amicale des élèves de l'ENS Paris-Saclay, par carte bancaire via un terminal
de l'amicale des élèves de l'ENS Cachan, par carte bancaire via un terminal
de paiement électronique ou encore par virement bancaire, dont les coordonnées
sont à demander auprès des trésorièr⋅es BDE.
sont à demander auprès des trésoriers BDE.
Les trois premières options sont à faire directement dans la Kfet.
@ -47,7 +47,7 @@ Je pars en stage / en vacances. Puis-je bloquer ma note ?
.. note::
Bien sûr : il te suffit de te rendre sur ton compte et de cliquer sur le bouton
dédié. Ta note ne sera plus affichée par les autres personnes et les transferts
seront impossibles, sauf pour les trésorièr⋅es BDE et respo info.
seront impossibles, sauf pour les trésoriers BDE et respo info.
Il est toutefois de ton devoir de rembourser tout ce que tu dois.
@ -56,44 +56,36 @@ Quelle est la limite maximale au nombre d'alias d'une note ?
------------------------------------------------------------
.. note::
Certain⋅es parlent d'une dizaine d'alias par note.
Certains parlent d'une dizaine d'alias par note.
Sois conscient⋅e qu'ajouter des alias ne peut qu'augmenter la probabilité de
collisions avec une autre note, et peut aussi retarder la livraison de ta
commande lors d'un perm bouffe.
Je suis trésorièr⋅e d'un club, qu'ai-je le droit de faire ?
-----------------------------------------------------------
Je suis trésorier d'un club, qu'ai-je le droit de faire ?
---------------------------------------------------------
.. note::
Être trésorièr⋅e d'un club donne la responsabilité de gérer la trésorerie du
Être trésorier d'un club donne la responsabilité de gérer la trésorerie du
club, et donc de gérer sa note. Vous obtenez donc le droit d'effectuer
n'importe quelle transaction via la note en provenance ou à destination de
la note de votre club. Vous pouvez également gérer les adhésions de votre club,
en permettant à n'importe quel⋅le adhérent⋅e BDE de rejoindre votre club, en
prélevant d'éventuels frais d'adhésion. Les paramètres du club peuvent être
également modifiés.
en permettant à n'importe quel adhérent BDE de rejoindre votre club, en prélevant
d'éventuels frais d'adhésion. Les paramètres du club peuvent être également modifiés.
.. danger::
Avoir des droits sur la Note Kfet ne signifie pas que vous devez les utiliser.
Chaque opération nécessitant des droits doit être fait pour une bonne raison,
et doit avoir un lien avec votre club. Vous n'avez par exemple pas le droit
d'aller récupérer des informations personnelles d'adhérent⋅es pour une raison
d'aller récupérer des informations personnelles d'adhérents pour une raison
personnelle. En revanche, faire le lien entre nom/prénom et nom de note est
bien sûr permis pour faciliter des transferts. Tout abus de droits constaté
pourra mener à des sanctions prises par le bureau du BDE.
.. warning::
Une fonctionnalité pour permettre de gérer plus proprement les remboursements
entre ami⋅es est en cours de développement. Temporairement et pour des raisons
de confort, les trésorièr⋅es de clubs ont le droit de prélever n'importe quelle
adhérente vers n'importe quelle autre note adhérente, tant que la source ne
descend pas sous ``- 50 €``. Ces droits seront retirés d'ici quelques semaines.
Je suis trésorièr⋅e d'un club, je n'arrive pas à voir le solde du club / faire des transactions
-----------------------------------------------------------------------------------------------------
Je suis trésorier d'un club, je n'arrive pas à voir le solde du club / faire des transactions
---------------------------------------------------------------------------------------------------
.. note::
As-tu bien vérifié que tu t'es connecté⋅e initialement avec tous tes droits ?
@ -105,8 +97,8 @@ Je suis trésorièr⋅e d'un club, je n'arrive pas à voir le solde du club / fa
opérations en empêchant l'accès à des opérations trop sensibles.
Je suis trésorièr⋅e d'un club. Puis-je créer un bouton ?
--------------------------------------------------------
Je suis trésorier d'un club. Puis-je créer un bouton ?
------------------------------------------------------
.. note::
Oui bien sûr ! Tant qu'il redirige bien vers la note de ton club.
@ -122,20 +114,20 @@ Je suis trésorièr⋅e d'un club. Puis-je créer un bouton ?
devient accessible.
Après passation, je suis trésorièr⋅e d'un club. Comment récupérer mes droits note ?
-----------------------------------------------------------------------------------
Après passation, je suis trésorier d'un club. Comment récupérer mes droits note ?
---------------------------------------------------------------------------------
.. note::
Tu dois pour cela contacter les trésorièr⋅es BDE (voir ci-dessous). Iels vous
Tu dois pour cela contacter les trésoriers BDE (voir ci-dessous). Ils vous
expliqueront en détails vos droits et vos interdits et vous donneront les
droits requis.
Je souhaite contacter un⋅e trésorièr⋅e
--------------------------------------
Je souhaite contacter un trésorier
----------------------------------
.. note::
Pour contacter un⋅e trésorièr⋅e, il te suffit d'envoyer un mail à l'adresse
Pour contacter un trésorier, il te suffit d'envoyer un mail à l'adresse
`tresorerie.bde@lists.crans.org <tresorerie.bde@lists.crans.org>`_. Pense bien
à donner ton nom de note, voire à envoyer un scan de ta carte d'identité si ta
demande concerne un virement entre le compte du BDE et ton propre compte.
@ -145,9 +137,9 @@ J'ai trouvé un bug, comment le signaler ?
-----------------------------------------
.. note::
La Note Kfet est développée bénévolement par des membres du BDE. Nous mettons
tous nos efforts pour fournir une plateforme sans erreur et la plus ergonomique
possible. Toutefois, il n'est évidemment pas exclu que des bugs soient présents :)
La Note Kfet est développée bénévolement par des membres du BDE. Malgré tous nos
efforts pour fournir une plateforme sans erreur et la plus ergonomique possible.
Toutefois, il n'est évidemment pas exclu que des bugs soient présents :)
Pour nous soumettre un bug, tu peux envoyer un mail à
`notekfet2020@lists.crans.org <notekfet2020@lists.crans.org>`_
@ -167,14 +159,14 @@ Je souhaite contribuer
La Note Kfet est essentiellement développée par des responsables informatiques du
BDE de l'ENS Paris-Saclay. Toutefois, si vous souhaitez contribuer, vous pouvez
bien sûr le faire en accord avec la licence GPLv3 avec laquelle la Note Kfet est
distribuée. Pour cela, si vous êtes adhérent⋅e Crans, vous pouvez proposer une
distribuée. Pour cela, si vous êtes adhérent Crans, vous pouvez proposer une
demande de fusion de votre code. Si ce n'est pas le cas, vous pouvez envoyer un
mail à `notekfet2020@lists.crans.org <notekfet2020@lists.crans.org>`_.
Dans les deux cas, merci de rejoindre le canal ``#note`` sur IRC :)
Contributeur⋅rices
------------------
Contributeurs
-------------
.. note::
La version 2020 de la Note Kfet a été développée sous le mandat de la
@ -188,19 +180,18 @@ Contributeur⋅rices
Liste des contributeurs majeurs, par ordre alphabétique :
* Pierre-André « PAC » COMBY
* Emmy « ÿnérant » D'ANELLO
* Yohann « ÿnérant » D'ANELLO
* Benjamin « esum » GRAILLOT
* Alexandre « erdnaxe » IOOSS
* Nicolas « nicomarg » MARGULIES
Hébergement
-----------
.. note::
En accord entre le BDE de l'ENS Paris-Saclay et le Crans, l'instance de production
présente sur `<https://note.crans.org/>`_ est hébergée sur l'un des serveurs du
Crans. Les données sont hébergées à l'adresse :
En accord entre de l'ENS Paris-Saclay et le Crans, l'instance de production présente
sur `<https://note.crans.org/>`_ est hébergée sur l'un des serveurs du Crans.
Les données sont hébergées à l'adresse :
.. code::

View File

@ -1,12 +1,12 @@
La note, c'est quoi ?
=====================
La Note Kfet est un porte-monnaie virtuel proposé gratuitement à toustes les
adhérent⋅es BDE. C'est le moyen de paiement privilégié au sein du campus, que ce soit
pour payer des activités du BDE, ou bien pour faire des remboursements entre ami⋅es.
La note contient également la base d'adhérent⋅es du BDE et contient de nombreux outils
facilitant la vie des différents bureaux de clubs, en particulier les trésorièr⋅es et
surtout les trésorièr⋅es BDE.
La Note Kfet est un porte-monnaie virtuel proposé gratuitement à tous les adhérents BDE.
C'est le moyen de paiement privilégié au sein du campus, que ce soit pour payer des
activités du BDE, ou bien pour faire des remboursements entre amis. La note contient
également la base d'adhérents du BDE et contient de nombreux outils facilitant la vie
des différents bureaux de clubs, en particulier les trésoriers et surtout les trésoriers
BDE.
La Note Kfet est accessible à l'adresse `<https://note.crans.org>`_. La version actuelle
a été développée par le BDE 2020-2021, et est maintenue par les respos infos du BDE de
@ -23,7 +23,7 @@ Chaque adhérent⋅e BDE dispose d'un compte appelé « note », créé lors de
Une note est associée à un solde en euros, et à un pseudo appelé « nom de note ».
Le solde associé à la note correspond au solde de l'adhérent⋅e, avec lequel il est
possible de payer tout ce qui est en lien avec le BDE. Le nom de note suffit à
identifier une personne, et il suffit par exemple de donner ce nom à un⋅e permanencièr⋅e
identifier une personne, et il suffit par exemple de donner ce nom à un permanencier
à la Kfet pour acheter un produit.
Faire une transaction
@ -34,10 +34,10 @@ d'un⋅e autre adhérent⋅e, pourvu que le montant de la transaction n'excède
propre solde. Pour cela, il suffit d'aller sur la page de transferts, qui est la
page par défaut après connexion : `<https://note.crans.org/note/transfer/>`_
Le formulaire pour effectuer un transfert apparaît alors. En cliquant sur le bouton
« Je suis l'émetteur⋅rice », le champ « Émetteur⋅rices » est directement complété
avec votre propre note. Il vous suffit alors de remplir le champ « Destinataires »
avec les noms de note que vous voulez créditer, de spécifier le montant et la raison
de votre transfert, puis de cliquer sur le bouton « Virement ». Après quelques secondes,
« Je suis l'émetteur », le champ « Émetteurs » est directement complété avec votre
propre note. Il vous suffit alors de remplir le champ « Destinataires » avec le ou
les noms de note que vous voulez créditer, de spécifier le montant et la raison de
votre transfert, puis de cliquer sur le bouton « Virement ». Après quelques secondes,
si votre solde est suffisant et que la transaction est acceptée, un message de
confirmation apparaîtra et la transaction apparaîtra dans l'historique ci-dessous.
@ -49,7 +49,7 @@ Consulter ses données personnelles
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
La Note Kfet sert non seulement à faire des transactions d'argent virtuel au sein
du BDE, mais aussi à permettre au BDE et à ses clubs de gérer leurs adhérent⋅es.
du BDE, mais aussi à permettre au BDE et à ses clubs de gérer leurs adhérents.
À cet effet, diverses informations personnelles sont collectées par la Note.
Pour accéder à votre compte, une fois connecté⋅e, rendez-vous dans le menu en haut
@ -79,22 +79,22 @@ présentes sont :
* Inscription aux listes de diffusion du BDE, du BDA et du BDS
Les trois premières informations sont obligatoires pour pouvoir vous contacter
et pouvoir tenir un registre d'adhérent⋅es.
et pouvoir tenir un registre d'adhérent.
Le numéro de téléphone et l'adresse ne sont utilisés uniquement en cas d'urgence,
notamment en cas de WEI, et ne sont pas des champs obligatoires. De plus,
promotion, département et section ne sont utilisés sérieusement que en cas
de WEI, et ne sont pas utilisés sinon.
La distinction élève/étudiant⋅e permet de distinguer les achats qui peuvent avoir
des prix différents pour les élèves et pour les étudiant⋅es, notamment en cas de
La distinction élève/étudiant permet de distinguer les achats qui peuvent avoir
des prix différents pour les élèves et pour les étudiants, notamment en cas de
transferts d'argent, de WEI ou d'adhésion à certains clubs.
Hormis le pseudo, toutes ces informations ont un caractère confidentiel qui les
rend privées et inaccessible aux utilisateur⋅rices. Les trésorièr⋅es BDE et les respos
rend privées et inaccessible aux utilisateurs. Les trésoriers BDE et les respos
info ont accès à toutes les informations (en cas de besoin uniquement, le cas
contraire est considéré comme de l'abus de droit qui est punissable), et les
trésorièr⋅es de club ont accès au nom et au prénom afin de faciliter l'association
trésoriers de club ont accès au nom et au prénom afin de faciliter l'association
à un nom de note.
Dans la partie informations personnelles, il est également possible de modifier
@ -106,7 +106,7 @@ de nom de note.
Le tableau des adhésions actives sur la partie du haut contient l'ensemble des
clubs auxquels vous êtes adhérent⋅es, avec les dates de début, de fin, votre
rôle au sein du club (généralement simple membre, mais cela peut également
être trésorièr⋅e par exemple) ainsi que la cotisation que vous avez éventuellement
être trésorier⋅ère par exemple) ainsi que la cotisation que vous avez éventuellement
payée au club.
L'historique des transactions contient l'ensemble des transactions qui ont fait

View File

@ -3,7 +3,7 @@ Documentation de la Note Kfet 2020
Bienvenue sur la documentation de la Note Kfet 2020. Cette documentation est à la fois
destinée aux adhérent⋅es BDE pour découvrir le fonctionnement de la note, mais aussi aux
destinée aux adhérents BDE pour découvrir le fonctionnement de la note, mais aussi aux
respos info qui souhaitent découvrir comment fonctionne la note sous le capot.
Des informations complémentaires sont également disponibles sur le `Wiki Crans <https://wiki.crans.org/NoteKfet/NoteKfet2020/>`_.

View File

@ -162,8 +162,8 @@ Ouvrez votre navigateur, tapez `<http://localhost:8000/>`_, enjoy :)
optimisé pour recevoir des requêtes en parallèle ou être utilisé en production.
Créer un⋅e super-utilisateur⋅rice
---------------------------------
Créer un super-utilisateur
--------------------------
La commande ``./manage.py createsuperuser`` vous permettra de créer un⋅e
super-utilisateur⋅rice initial⋅e.
La commande ``./manage.py createsuperuser`` vous permettra de créer un super-utilisateur
initial.

View File

@ -3,7 +3,7 @@ Installer la Note Kfet en production
Cette page détaille comment installer la Note Kfet sur un serveur de production,
dédié uniquement à l'utilisation de la note. On supposera que le serveur tourne
avec un Debian Bullseye à jour.
avec un Debian Buster à jour.
Ajout des dépôts buster-backports
@ -15,7 +15,7 @@ version 1.11, qui n'est plus maintenue par Django depuis déjà quelques mois.
Les versions stables de Django sont les versions 2.2 et 3.2, Debian Bullseye
utilisant la version 2.2.
Afin de permettre à ses utilisateur⋅rices d'utiliser les dernières fonctionnalités de
Afin de permettre à ses utilisateurs d'utiliser les dernières fonctionnalités de
certains paquets, Debian a créé une distribution particulière, appelée
``buster-backports``. Cette distribution contient des paquets de la distribution
à venir « ``testing`` » (``bullseye`` à l'heure où cette documentation est écrite)
@ -42,7 +42,7 @@ Vérifiez que les paquets sont bien récupérés, en cherchant cette ligne :
.. warning::
Avis aux futur⋅es respos info : pensez à bien actualiser cette documentation lorsque
Avis aux futurs respos info : pensez à bien actualiser cette documentation lorsque
Debian Bullseye sera sorti. En particulier, il ne sera pas déconnant de continuer
à utiliser non pas buster-backports mais bullseye-backports pour installer la
note avec Django 3.2 et non Django 2.2.
@ -50,24 +50,17 @@ Vérifiez que les paquets sont bien récupérés, en cherchant cette ligne :
Bien sûr, vous testerez sur un serveur qui n'est pas celui utilisé avant :)
.. error::
Avis aux respos info : on utilise maintenant Debian Bullseye. À voir si on veut
avoir bullseye-backports pour avoir Django 3.2 ou non. La section du haut sera à
adapter.
Installation des dépendances nécessaires
----------------------------------------
On s'efforce pour récupérer le plus possible de dépendances via les paquets Debian
plutôt que via ``pip`` afin de faciliter les mises à jour et avoir une installation
plus propre. On peut donc installer tout ce dont on a besoin :
plus propre. On peut donc installer tout ce dont on a besoin, depuis buster-backports :
.. code:: bash
$ sudo apt update
$ sudo apt install --no-install-recommends \
$ sudo apt install -t buster-backports --no-install-recommends \
gettext git ipython3 \ # Dépendances basiques
fonts-font-awesome libjs-bootstrap4 \ # Pour l'affichage web
python3-bs4 python3-django python3-django-crispy-forms python3-django-extensions \
@ -194,7 +187,7 @@ clé sous forme de chaîne de caractère suffisamment longue (64 caractères par
qui n'est pas à transmettre et qui évite d'autres sites malveillants de faire des requêtes
directement sur la note.
Le champ ``CONTACT_EMAIL`` correspond l'adresse mail que les adhérent⋅es peuvent contacter
Le champ ``CONTACT_EMAIL`` correspond l'adresse mail que les adhérent⋅es peuvent contacter
en cas de problème. C'est là où le champ ``Nous contacter`` redirigera.
Le champ ``NOTE_URL`` correspond au nom de domaine autorisé à accéder au site. C'est également
@ -292,7 +285,7 @@ On doit compiler les traductions (pour pouvoir les lire plus vite par la suite)
$ ./manage.py compilemessages
Les fichiers statiques (fiches de style, fichiers Javascript, images,) doivent
Les fichiers statiques (fiches de style, fichiers Javascript, images, ...) doivent
être exportées dans le dossier ``static`` :
.. code:: bash
@ -622,7 +615,7 @@ pas déjà fait par créer un utilisateur sur les deux serveurs :
.. code:: bash
ynerant@bde-note:~$ sudo -u postgres createuser -l ynerant
ynerant@bde-note:~$ sudo -u postgres createuser -s ynerant
On réinitialise **sur le serveur de développement** la base de données présente, en
éteignant tout d'abord le serveur Web :
@ -643,5 +636,5 @@ On peut enfin redémarrer le serveur Web. Les données ont bien été copiées.
.. caution::
On ne copiera **jamais** des données d'adhérent⋅es sur une machine personnelle.
On ne copiera **jamais** des données d'adhérent⋅es sur une machine personnelle.
Ce type d'opération doit s'effectuer impérativement entre des serveurs du BDE.

View File

@ -131,8 +131,8 @@ de diffusion utiles.
Il prend 2 options :
* ``--type``, qui prend en argument ``members`` (défaut), ``clubs``, ``events``, ``art``,
``sport``, qui permet respectivement de sortir la liste des adresses mails des adhérent⋅es
actuel⋅les (pour la liste ``adherents.bde@lists.crans.org), des clubs (pour
``sport``, qui permet respectivement de sortir la liste des adresses mails des adhérents
actuels (pour la liste ``adherents.bde@lists.crans.org), des clubs (pour
``clubs@lists.crans.org``), des personnes à abonner à ``evenements@lists.crans.org``,
à ``all.bda@lists.crans.org`` et enfin à ``bds@lists.crans.org``.
* ``--lang``, qui prend en argument ``fr`` ou ``en``. N'est utile que pour la ML événements,
@ -146,8 +146,8 @@ malheureusement pas aussi simple que de simplement supposer que ces listes sont
À terme, il pourrait être envisageable de synchroniser automatiquement les listes avec la note.
Suppression d'un⋅e utilisateur⋅rice
-----------------------------------
Suppression d'un utilisateur
----------------------------
Le script s'appelle ``force_delete_user``.
@ -156,7 +156,7 @@ Le script s'appelle ``force_delete_user``.
Ce script est dangereux. À n'utiliser qu'avec de très grosses pincettes si vous savez
ce que vous faites. Seul cas d'usage pour l'instant recensé : supprimer des comptes en
double qui se sont malencontreusement retrouvés validés pour raison de Sogé et de mauvaise
communication au sein des trésorièr⋅es.
communication au sein des trésorier⋅ère⋅s.
Il n'est certainement pas prévu de supprimer des vrais comptes existants via ce script.
On ne supprime pas l'historique. Si jamais quelqu'un demanderait à supprimer son compte,
@ -164,8 +164,8 @@ Le script s'appelle ``force_delete_user``.
Ce script est utile lorsqu'il faut supprimer un compte créer par erreur. Tant que la validation
n'est pas faite, il suffit en général de cliquer sur le bouton « Supprimer le compte » sur
l'interface de validation. Cela supprimera l'utilisateur⋅rice et le profil associé, sans
toucher à une quelconque note puisqu'elle ne sera pas créée.
l'interface de validation. Cela supprimera l'utilisateur et le profil associé, sans toucher
à une quelconque note puisqu'elle ne sera pas créée.
Ce script supprime donc un compte ainsi que toutes les données associées (note, alias,
transactions). Il n'est donc pas à prendre à la légère, et vous devez savoir ce que vous
@ -181,8 +181,8 @@ pour supprimer tout ce qu'il faut, et une validation manuelle sera requise pour
la suppression. L'option ``--doit`` évite cette confirmation manuelle.
**Vous n'avez jamais à utiliser cette option en théorie.**
À la fin du processus, un mail est envoyé aux administrateur⋅rices pour les prévenir des
éléments supprimés.
À la fin du processus, un mail est envoyé aux administrateurs pour les prévenir des
élements supprimés.
Des données réelles jamais tu ne supprimeras.
@ -199,14 +199,14 @@ la Note Kfet 2015.
s'est déroulé.
Ajouter un⋅e super-utilisateur⋅rice
-----------------------------------
Ajouter un super-utilisateur
----------------------------
Le script s'appelle ``make_su``.
Il prend en argument un pseudo.
Avec l'option ``--SUPER, -S``, la personne avec ce pseudo devient super-utilisateur⋅rice,
Avec l'option ``--SUPER, -S``, la personne avec ce pseudo devient super-utilisateur,
et obtiens donc les pleins pouvoirs sur la note. À ne donner qu'aux respos info.
Avec l'option ``--STAFF, -s``, la personne avec ce pseudo acquiert le statut équipe,
@ -253,20 +253,20 @@ Envoi des rappels de négatif
Le script s'appelle ``send_mail_to_negative_balances``.
Il sert à rappeler aux adhérent⋅es et clubs en négatif qui le sont, mais également
à envoyer aux trésorièr⋅es et respos info la liste des adhérent⋅es en négatif.
Il sert à rappeler aux adhérent⋅es et clubs en négatif qu'ils le sont, mais également
à envoyer aux trésorier⋅ère⋅s et respos info la liste des adhérent⋅es en négatif.
Il prend les options suivantes :
* ``--spam, -s`` : envoie à chaque adhérent⋅e en négatif un rappel par mail pour recharger
* ``--report, -r`` : envoie le rapport aux trésorièr⋅es et respos info
* ``--report, -r`` : envoie le rapport aux trésorier⋅ère⋅s et respos info
* ``--negative-amount,-n`` : définit le solde maximal en-dessous duquel les notes
apparaitront sur le rapport / seront spammées
* ``--add-years, -y`` : ajoute également les adhérent⋅es des ``n`` dernières années
* ``--add-years, -y`` : ajoute également les adhérent⋅es des ``n`` dernières années
Ce script est appelé tous les mardis à 5h00 pour spammer les utilisateur⋅ices en
négatif et tous les 6 du mois pour envoyer le rapport des notes d'adhérent⋅es ou de
vieilleux adhérent⋅es de moins d'un an sous -10 €.
Ce script est appelé tous les mardis à 5h00 pour spammer les utilisateur⋅rices en
négatif et tous les 6 du mois pour envoyer le rapport des notes d'adhérent⋅es ou de
vieux/vieilles adhérent⋅es de moins d'un an sous -10 €.
Envoi des rapports
@ -274,8 +274,8 @@ Envoi des rapports
Le script s'appelle ``send_reports``.
Les utilisateur⋅rices ont la possibilité de recevoir sur demande un rapport à la
fréquence de leur choix (en jours) des transactions effectuées sur leur note.
Les utilisateurs ont la possibilité de recevoir sur demande un rapport à la fréquence de
leur choix (en jours) des transactions effectuées sur leur note.
Le script prend 2 options :

Some files were not shown because too many files have changed in this diff Show More