Compare commits

..

18 Commits

Author SHA1 Message Date
Otthorn 92b2c4690f Merge branch 'qrcode' into 'main'
Draft: Qrcode

See merge request bde/nk20!196
2024-08-30 15:24:47 +02:00
mcngnt 21cbf2b21a Merge branch 'survey_wei_2024' into 'main'
Survey wei 2024

See merge request bde/nk20!269
2024-08-29 23:10:57 +02:00
mcngnt 185a2cabf2 corrected emoji + linting 2024-08-29 22:47:33 +02:00
mcngnt 7552e55c8d removed diet filed 2024-08-29 22:19:11 +02:00
mcngnt e4aa16986f Merge branch 'survey_wei_2024' into 'main'
linting

See merge request bde/nk20!267
2024-08-29 19:12:23 +02:00
mcngnt b92e6e4e10 linting 2024-08-29 18:36:20 +02:00
mcngnt dd675b3676 Merge branch 'survey_wei_2024' into 'main'
Survey wei 2024

See merge request bde/nk20!266
2024-08-29 14:45:28 +02:00
mcngnt f50849b4f8 delete print 2024-08-29 14:01:55 +02:00
mcngnt 73ff35c232 updated bus descr 2024-08-29 12:42:26 +02:00
korenstin a5df98224f Merge branch 'migration-django-4-2' into 'main'
Migration django 4 2

See merge request bde/nk20!265
2024-08-29 10:49:44 +02:00
korenstin 2cb9ac8735 replace "…" -> "..." (#130) and disable sorting on certain columns (#129) 2024-08-29 10:19:06 +02:00
korenstin 35d4849a28 fix Oauth 2024-08-29 00:43:33 +02:00
mcngnt 96539d262f working html for survey + fixed json error + added specific diet text field 2024-08-29 00:05:44 +02:00
korenstin 946674f59b inclusif, avoids python3.10 syntax 2024-08-28 11:11:32 +02:00
mcngnt a201d8376a updated survey 2024-08-28 11:01:33 +02:00
korenstin a21b9275ea Add caution_check in the validation form, #96 2024-08-28 09:48:52 +02:00
korenstin d4e85e8215 test wei 2024, linters 2024-08-28 09:48:52 +02:00
mcngnt 7af2ebba40 basic survey 2024-08-28 09:48:52 +02:00
30 changed files with 735 additions and 91 deletions

View File

@ -0,0 +1,24 @@
# Generated by Django 4.2.15 on 2024-08-28 08:00
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('note', '0006_trust'),
('activity', '0004_opener'),
]
operations = [
migrations.AlterModelOptions(
name='opener',
options={'verbose_name': 'Opener', 'verbose_name_plural': 'Openers'},
),
migrations.AlterField(
model_name='opener',
name='opener',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_responsible', to='note.note', verbose_name='Opener'),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 4.2.15 on 2024-08-28 08:00
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('food', '0004_auto_20240813_2358'),
]
operations = [
migrations.AlterField(
model_name='food',
name='polymorphic_ctype',
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'),
),
]

View File

@ -42,12 +42,12 @@ class UserTable(tables.Table):
"""
alias = tables.Column()
section = tables.Column(accessor='profile__section')
section = tables.Column(accessor='profile__section', orderable=False)
# Override the column to let replace the URL
email = tables.EmailColumn(linkify=lambda record: "mailto:{}".format(record.email))
balance = tables.Column(accessor='note__balance', verbose_name=_("Balance"))
balance = tables.Column(accessor='note__balance', verbose_name=_("Balance"), orderable=False)
def render_email(self, record, value):
# Replace the email by a dash if the user can't see the profile detail

View File

@ -11,7 +11,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{{ title }}
</h3>
<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...">
<div class="form-check">
<label class="form-check-label" for="only_active">
<input type="checkbox" class="checkboxinput form-check-input" id="only_active"

View File

@ -0,0 +1,25 @@
# Generated by Django 4.2.15 on 2024-08-28 08:00
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('note', '0006_trust'),
]
operations = [
migrations.AlterField(
model_name='note',
name='polymorphic_ctype',
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'),
),
migrations.AlterField(
model_name='transaction',
name='polymorphic_ctype',
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'),
),
]

View File

@ -260,11 +260,13 @@ class ButtonTable(tables.Table):
text=_('edit'),
accessor='pk',
verbose_name=_("Edit"),
orderable=False,
)
hideshow = tables.Column(
verbose_name=_("Hide/Show"),
accessor="pk",
orderable=False,
attrs={
'td': {
'class': 'col-sm-1',
@ -276,7 +278,8 @@ class ButtonTable(tables.Table):
delete_col = tables.TemplateColumn(template_code=DELETE_TEMPLATE,
extra_context={"delete_trans": _('delete')},
attrs={'td': {'class': 'col-sm-1'}},
verbose_name=_("Delete"), )
verbose_name=_("Delete"),
orderable=False, )
def render_amount(self, value):
return pretty_money(value)

View File

@ -135,18 +135,18 @@ class Permission(models.Model):
# A json encoded Q object with the following grammar
# query -> [] | {} (the empty query representing all objects)
# query -> ["AND", query, …] AND multiple queries
# | ["OR", query, …] OR multiple queries
# query -> ["AND", query, ...] AND multiple queries
# | ["OR", query, ...] OR multiple queries
# | ["NOT", query] Opposite of query
# query -> {key: value, …} A list of fields and values of a Q object
# query -> {key: value, ...} A list of fields and values of a Q object
# key -> string A field name
# value -> int | string | bool | null Literal values
# | [parameter, …] A parameter. See compute_param for more details.
# | [parameter, ...] A parameter. See compute_param for more details.
# | {"F": oper} An F object
# oper -> [string, …] A parameter. See compute_param for more details.
# | ["ADD", oper, …] Sum multiple F objects or literal
# oper -> [string, ...] A parameter. See compute_param for more details.
# | ["ADD", oper, ...] Sum multiple F objects or literal
# | ["SUB", oper, oper] Substract two F objects or literal
# | ["MUL", oper, …] Multiply F objects or literals
# | ["MUL", oper, ...] Multiply F objects or literals
# | int | string | bool | null Literal values
# | ["F", string] A field
#

View File

@ -0,0 +1,19 @@
# Generated by Django 4.2.15 on 2024-08-28 08:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('note', '0007_alter_note_polymorphic_ctype_and_more'),
('treasury', '0008_auto_20240322_0045'),
]
operations = [
migrations.AlterField(
model_name='sogecredit',
name='transactions',
field=models.ManyToManyField(blank=True, related_name='+', to='note.membershiptransaction', verbose_name='membership transactions'),
),
]

View File

@ -37,6 +37,7 @@ class InvoiceTable(tables.Table):
args=[A('id')],
verbose_name=_("delete"),
text=_("Delete"),
orderable=False,
attrs={
'th': {
'id': 'delete-membership-header'
@ -70,6 +71,7 @@ class RemittanceTable(tables.Table):
verbose_name=_("View"),
args=[A("pk")],
text=_("View"),
orderable=False,
attrs={
'a': {'class': 'btn btn-primary'}
}, )
@ -97,6 +99,7 @@ class SpecialTransactionTable(tables.Table):
verbose_name=_("Remittance"),
args=[A("specialtransactionproxy__pk")],
text=_("Add"),
orderable=False,
attrs={
'a': {'class': 'btn btn-primary'}
}, )
@ -105,6 +108,7 @@ class SpecialTransactionTable(tables.Table):
verbose_name=_("Remittance"),
args=[A("specialtransactionproxy__pk")],
text=_("Remove"),
orderable=False,
attrs={
'a': {'class': 'btn btn-primary btn-danger'}
}, )
@ -130,10 +134,12 @@ class SogeCreditTable(tables.Table):
amount = tables.Column(
verbose_name=_("Amount"),
orderable=False,
)
valid = tables.Column(
verbose_name=_("Valid"),
orderable=False,
)
def render_amount(self, value):

View File

@ -81,6 +81,11 @@ class WEIChooseBusForm(forms.Form):
class WEIMembershipForm(forms.ModelForm):
caution_check = forms.BooleanField(
required=False,
label=_("Caution check given"),
)
roles = forms.ModelMultipleChoiceField(
queryset=WEIRole.objects,
label=_("WEI Roles"),
@ -149,6 +154,7 @@ class WEIMembership1AForm(WEIMembershipForm):
"""
Used to confirm registrations of first year members without choosing a bus now.
"""
caution_check = None
roles = None
def clean(self):

View File

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

View File

@ -82,7 +82,7 @@ WORDS = {
5: "La quoi ?"
}],
"kokarde": ["Qu'est-ce que le mot Kokarde t'évoque ?", {
1: "Vraiment pas mon truc les soirées",
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 !",
@ -117,15 +117,15 @@ WORDS = {
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é",
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",
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"
@ -147,7 +147,7 @@ WORDS = {
"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",
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"
}]

View File

@ -0,0 +1,372 @@
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from functools import lru_cache
from django import forms
from django.utils.safestring import mark_safe
from django.db import transaction
from django.db.models import Q
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, WEIBusInformation
from ...models import WEIMembership
buses_descr = [
[
"Magi[Kar]p 🐙🎮🎲", "#ef5568", 1,
"""Vous l'aurez compris au nom du bus, l'ambiance est aux jeux et à la culture geek ! Ici, vous trouverez une ambiance
calme avec une bonne dose d'autodérision et de second degré. Que vous ayez besoin de beaucoup dormir pour tenir la soirée
du lendemain, ou que vous souhaitiez faire nuit blanche pour jouer toute la nuit, vous pouvez nous rejoindre. Votre voix
n'y survivra peut-être pas à force de chanter. PS : les meilleurs cocktails du WEI sont chez nous, à déguster, pas à
siphonner !""",
],
[
"Va[car]me 🎷🍎🔊", "#fd7a28", 3,
"""Ici, c'est le bus du bruit. Si vous voulez réveiller les autres bus en musique, apprendre de merveilleuses
mélodies au kazoo tout le week-end, ou simplement profiter d'une bonne ambiance musicale, le BDA et la
F[ENS]foire sont pour vous. Vous pourrez également goûter au célèbre cocktail de la fanfare, concocté
pour l'occasion par les tout nouveaux "meilleurs artisans v*********** de France" ! Alors que vous soyez artiste
dans l'âme ou que vous souhaitiez juste faire le plus grand Vacarme, rejoignez-nous !""",
],
[
"[Kar]aïbes 🏝️🏴‍☠️🥥", "#a5cfdd", 3,
"""Ahoy, explorateurs du WEI ! Le bus Karaibes tinvite à une traversée sous les tropiques, où lambiance est
toujours au beau fixe ! 🍹 Ici, cest soleil, rhum, et bonne humeur assurée : une atmosphère de vacances
lon se laisse porter par la chaleur humaine et la fête. Que tu sois un pirate en quête de sensations fortes ou
un amateur de chill avec un cocktail à la main, tu seras à ta place dans notre bus. Les soirées seront marquées
par des rythmes tropicaux qui te feront vibrer jusquà laube. Prêt à embarquer pour une aventure inoubliable
avec les meilleurs matelots du WEI ? On tattend sur le pont du Karaibes pour lever lancre ensemble !""",
],
[
"[Kar]di [Bus] 🎙️💅", "#e46398", 2.5,
"""Bienvenue à bord du Kardi Bus, la seul, lunique, linimitable pépite de ce weekend dintégration ! Inspiré par les
icônes suprêmes de la pop culture telles les Bratz, les Winx et autres Mean Girls, notre bus est un sanctuaire de style,
daudace et de pur plaisir. A nos cotés attends toi à siroter tes meilleurs Cosmo, sex on the Beach et autres cocktails
de maxi pétasse tout en papotant entre copains copines ! Si tu rejoins le Kardi Bus, tu entres dans un monde tu
pourras te déhancher sur du Beyoncé, Britney, Aya et autres reines de la pop ! À très vite, les futures stars du Kardi
Bus !""",
],
[
"Sparta[bus] 🐺🐒🏉", "#ebdac2", 5,
"""Dans notre bus, on vous donne un avant goût des plus grandes assos de l'ENS : les Kyottes et l'Aspique (clubs de rugby
féminin et masculin, mais pas que). Bien entendu, qui dit rugby dit les copaings, le pastaga et la Pena Bayona, mais vous
verrez par vous même qu'on est ouvert⋅e à toutes propositions quand il s'agit de faire la fête. Pour les casse-cous comme
pour les plus calmes, vous trouverez au bus Aspique-Kyottes les 2A+ qui vous feront kiffer votre WEI.""",
],
[
"Zanzo[Bus] 🤯🚸🐒", "#FFFF", 3,
"""Dans un entre-trois bien senti entre zinzinerie, enfance et vieillerie, le Zanzo[BUS] est un concentré de fun mêlé à
de la dinguerie à gogo. N'hésitez plus et rejoignez-nous pour un WEI toujours plus déjanté !""",
],
[
"Bran[Kar] 🍹🥳", "#6da1ac", 4,
"""Si vous ne connaissez pas le Bran[Kar], cest comme une grande famille qui fait un apéro, qui se bourre un peu la
gueule en discutant des heures autour dune table remplie de bouffe et de super bons cocktails (la plupart des
barmen/barwomen du bus sont les barmans de Shakens), sauf quon est un bus du Wei (vous comprendrez bien le nom de notre
bus en voyant létat de certain·e·s). Il nous arrive de faire quelques conneries, mais surtout de jouer au Bière-pong en
musique !""",
],
[
"Techno [kar]ade 🔊🚩", "#8065a3", 3,
"""Avis à tous·tes les gauchos, amoureux·ses de la fête et des manifs : le Techno [kar]ade vous ouvre grand ses bras pour
finir en beauté votre première inté. Préparez-vous à vous abreuver de cocktails (savamment élaborés) à la vibration dun
système son fabriqué pour loccasion. Des sets technos à « Mon père était tellement de gauche » en passant par « Female
Body », le car accueillant les meilleures DJs du plateau saura animer le trajet aussi bien que les soirées. Si alcool et
musique seront au rendez-vous, les maîtres mots sont sécurité et inclusivité. Qui que vous soyez et quelle que soit votre
manière de vous amuser, notre objectif est que vous vous sentiez à laise pour rencontrer au mieux les 1A, les 2A et les
(nombreuxses) 3A+ qui auront répondu à lappel. Bref, rejoignez-nous, on est super cools :)"""
],
[
"[Bus]ka-P 🥇🍻🎤", "#7c4768", 4.5,
"""Booska-p, cest le « site N°1 du Rap français ». Le [Bus]ka-p ? Le bus N°1 sur lambiance au WEI. Les nuits vont être
courtes, les cocktails vont couler à flots : tout sera réuni pour vivre un week-end dont tu te souviendras toute ta vie.
Au programme pas un seul temps mort et un maximum de rencontres pour bien commencer ta première année à lENS. Et bien
entendu, le tout accompagné des meilleurs sons, de Jul à Aya, en passant par ABBA et Sexion dAssaut. Bref, si tu veux
vivre un WEI danthologie et faire la fête, de jour comme de nuit, nous taccueillons avec plaisir !""",
],
]
def print_bus(i):
return f"""<h1 style="color:{buses_descr[i][1]};-webkit-text-stroke: 2px black;font-size: 50px;">{buses_descr[i][0]}</h1><br>
<b>Alcoolomètre : {buses_descr[i][2]} / 5 🍻</b><br><br>{buses_descr[i][3]}<br>"""
def print_all_buses():
liste = [print_bus(i) for i in range(len(buses_descr))]
return "<br><br><br><br>".join(liste)
def get_number_comment(i):
if i == 1:
return "Même pas en rêve"
elif i == 2:
return "Pas envie"
elif i == 3:
return "Mouais..."
elif i == 4:
return "Pourquoi pas !"
elif i == 5:
return "Ce bus ou rien !!!"
else:
return ""
WORDS = {
"recap":
[
"""<b>Chèr⋅e 1A, te voilà arrivé⋅e au moment fatidique du choix de ton bus !<br><br><br>
Ton bus est constitué des gens avec qui tu passeras la majorité de ton temps : que ce soit le voyage d'aller et de
retour et les différentes activité qu'ils pourront te proposer tout au long du WEI donc choisis le bien !
<br><br>Tu trouveras ci-dessous la liste de tous les bus ainsi qu'une description détaillée de ces derniers.
Prends ton temps pour étudier chacun d'eux et quand tu te sens prêt⋅e, appuie sur le bouton « J'ai pris connaissance
des bus » pour continuer
<br>(pas besoin d'apprendre par cœur chaque bus, la description de chaque bus te sera rappeler avant de lui attribuer
une note !)</b><br><br><br>""" + print_all_buses(),
{
"1": "J'ai pris connaissance des différents bus et me sent fin prêt à choisir celui qui me convient le mieux !",
}
]
}
WORDS.update({
f"bus{id}": [print_bus(id), {i: f"{get_number_comment(i)} ({i}/5)" for i in range(1, 5 + 1)}] for id in range(len(buses_descr))
})
class WEISurveyForm2024(forms.Form):
"""
Survey form for the year 2024.
Members score the different buses, 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 = WEISurveyInformation2024(registration)
question = information.questions[information.step]
self.fields[question] = forms.ChoiceField(
label=mark_safe(WORDS[question][0]),
widget=forms.RadioSelect(),
)
answers = [(answer, WORDS[question][1][answer]) for answer in WORDS[question][1]]
self.fields[question].choices = answers
class WEIBusInformation2024(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 WEISurveyInformation2024(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 WEISurvey2024(WEISurvey):
"""
Survey for the year 2024.
"""
@classmethod
def get_year(cls):
return 2024
@classmethod
def get_survey_information_class(cls):
return WEISurveyInformation2024
def get_form_class(self):
return WEISurveyForm2024
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 WEISurveyAlgorithm2024
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 WEISurveyAlgorithm2024(WEISurveyAlgorithm):
"""
The algorithm class for the year 2024.
We use Gale-Shapley algorithm to attribute 1y students into buses.
"""
@classmethod
def get_survey_class(cls):
return WEISurvey2024
@classmethod
def get_bus_information_class(cls):
return WEIBusInformation2024
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
WEISurvey2024.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

@ -0,0 +1,18 @@
# Generated by Django 4.2.15 on 2024-08-28 20:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wei', '0008_auto_20240111_1545'),
]
operations = [
migrations.AddField(
model_name='weiregistration',
name='specific_diet',
field=models.TextField(blank=True, default='', verbose_name='specific diet'),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.15 on 2024-08-29 20:15
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('wei', '0009_weiregistration_specific_diet'),
]
operations = [
migrations.RemoveField(
model_name='weiregistration',
name='specific_diet',
),
]

View File

@ -25,7 +25,7 @@
<dt class="col-xl-6">{% trans 'department'|capfirst %}</dt>
<dd class="col-xl-6">{{ object.user.profile.get_department_display }}</dd>
<dt class="col-xl-6">{% trans 'health issues'|capfirst %}</dt>
<dt class="col-xl-6">{% trans 'health issues or specific diet'|capfirst %}</dt>
<dd class="col-xl-6">{{ object.health_issues|default:"—" }}</dd>
<dt class="col-xl-6">{% trans 'suggested bus'|capfirst %}</dt>

View File

@ -64,7 +64,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<dt class="col-xl-6">{% trans 'birth date'|capfirst %}</dt>
<dd class="col-xl-6">{{ registration.birth_date }}</dd>
<dt class="col-xl-6">{% trans 'health issues'|capfirst %}</dt>
<dt class="col-xl-6">{% trans 'health issues or specific diet'|capfirst %}</dt>
<dd class="col-xl-6">{{ registration.health_issues }}</dd>
<dt class="col-xl-6">{% trans 'emergency contact name'|capfirst %}</dt>

View File

@ -6,8 +6,6 @@ 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
@ -127,44 +125,3 @@ class TestWEIAlgorithm(TestCase):
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

@ -0,0 +1,172 @@
# Copyright (C) 2018-2024 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.wei2024 import WEIBusInformation2024, WEISurvey2024, WORDS, WEISurveyInformation2024
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 2024",
email="wei2024@example.com",
parent_club_id=2,
membership_fee_paid=12500,
membership_fee_unpaid=5500,
membership_start='2024-01-01',
membership_end='2024-12-31',
date_start=date.today() + timedelta(days=2),
date_end='2024-12-31',
year=2024,
)
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 = WEIBusInformation2024(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 = WEISurveyInformation2024(registration)
for question in WORDS:
options = list(WORDS[question][1].keys())
setattr(information, question, random.choice(options))
information.step = 20
information.save(registration)
registration.save()
# Run algorithm
WEISurvey2024.get_algorithm_class()().run_algorithm()
# Ensure that everyone has its first choice
for r in WEIRegistration.objects.filter(wei=self.wei).all():
survey = WEISurvey2024(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 = WEISurveyInformation2024(registration)
for question in WORDS:
options = list(WORDS[question][1].keys())
setattr(information, question, random.choice(options))
information.step = 20
information.save(registration)
registration.save()
# Run algorithm
WEISurvey2024.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 = WEISurvey2024(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, 10 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 = WEISurvey2024(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 = WEISurvey2024(registration)
self.assertTrue(survey.is_complete())
survey.select_bus(self.buses[0])
survey.save()
self.assertIsNotNone(survey.information.get_selected_bus())

View File

@ -767,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(), 2023)
self.assertEqual(CurrentSurvey.get_year(), 2024)
class TestWeiAPI(TestAPI):

View File

@ -900,6 +900,9 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
form.fields["last_name"].initial = registration.user.last_name
form.fields["first_name"].initial = registration.user.first_name
if "caution_check" in form.fields:
form.fields["caution_check"].initial = registration.caution_check
if registration.soge_credit:
form.fields["credit_type"].disabled = True
form.fields["credit_type"].initial = NoteSpecial.objects.get(special_type="Virement bancaire")
@ -941,6 +944,9 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
club = registration.wei
user = registration.user
if "caution_check" in form.data:
registration.caution_check = form.data["caution_check"] == "on"
registration.save()
membership = form.instance
membership.user = user
membership.club = club

View File

@ -32,7 +32,7 @@ Applications indispensables
* `Note <note>`_ :
Les notes associées à des utilisateur⋅rices ou des clubs.
* `Activity <activity>`_ :
La gestion des activités (créations, gestion, entrées,)
La gestion des activités (créations, gestion, entrées, ...)
* `Permission <permission>`_ :
Backend de droits, limites les pouvoirs des utilisateur⋅rices
* `API <../api>`_ :
@ -64,7 +64,7 @@ Applications facultatives
* ``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
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...
* `WEI <wei>`_ :

View File

@ -43,7 +43,7 @@ l'utilisateur⋅rice, utiles pour l'adhésion au BDE :
* ``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,)
* ``section`` : ``CharField``, section de l'ENS à laquelle appartient l'utilisateur⋅rice (exemple : 1A0, ...)
Clubs
~~~~~
@ -101,7 +101,7 @@ 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,).
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
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

View File

@ -49,7 +49,7 @@ Une fois l'inscription validée, détail de ce qu'il se passe :
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,
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,
la note n'est pas débitée (commence alors à 0 €).

View File

@ -3744,8 +3744,8 @@ msgid "FAQ (FR)"
msgstr "FAQ (FR)"
#: note_kfet/templates/base_search.html:15
msgid "Search by attribute such as name"
msgstr "Suche nach Attributen wie Name"
msgid "Search by attribute such as name..."
msgstr "Suche nach Attributen wie Name..."
#: note_kfet/templates/base_search.html:23
msgid "There is no results."

View File

@ -3694,8 +3694,8 @@ msgid "FAQ (FR)"
msgstr "FAQ (FR)"
#: note_kfet/templates/base_search.html:15
msgid "Search by attribute such as name"
msgstr "Buscar con atributo, como el nombre"
msgid "Search by attribute such as name..."
msgstr "Buscar con atributo, como el nombre..."
#: note_kfet/templates/base_search.html:23
msgid "There is no results."

View File

@ -1930,8 +1930,8 @@ msgstr "Consommer"
#: apps/note/templates/note/conso_form.html:43
#: apps/note/templates/note/transaction_form.html:69
#: apps/note/templates/note/transaction_form.html:96
msgid "Name or alias"
msgstr "Pseudo ou alias"
msgid "Name or alias..."
msgstr "Pseudo ou alias..."
#: apps/note/templates/note/conso_form.html:53
msgid "Select consumptions"
@ -2031,8 +2031,8 @@ msgid "Current price"
msgstr "Prix actuel"
#: apps/note/templates/note/transactiontemplate_list.html:13
msgid "Name of the button"
msgstr "Nom du bouton"
msgid "Name of the button..."
msgstr "Nom du bouton..."
#: apps/note/templates/note/transactiontemplate_list.html:15
msgid "New button"
@ -3001,6 +3001,7 @@ msgid "Credit from Société générale"
msgstr "Crédit de la Société générale"
#: apps/wei/models.py:188
#: apps/wei/forms/registration.py:84
msgid "Caution check given"
msgstr "Chèque de caution donné"
@ -3385,8 +3386,8 @@ msgstr ""
"coût d'adhésion."
#: apps/wei/templates/wei/weimembership_list.html:27
msgid "View unvalidated registrations"
msgstr "Voir les inscriptions non validées"
msgid "View unvalidated registrations..."
msgstr "Voir les inscriptions non validées..."
#: apps/wei/templates/wei/weiregistration_confirm_delete.html:16
msgid "This registration is already validated and can't be deleted."
@ -3406,8 +3407,8 @@ msgid "There is no pre-registration found with this pattern."
msgstr "Il n'y a pas de pré-inscription en attente avec cette entrée."
#: apps/wei/templates/wei/weiregistration_list.html:27
msgid "View validated memberships…"
msgstr "Voir les adhésions validées"
msgid "View validated membershipis..."
msgstr "Voir les adhésions validées..."
#: apps/wei/views.py:58
msgid "Search WEI"
@ -3672,8 +3673,8 @@ msgid "FAQ (FR)"
msgstr "FAQ (FR)"
#: note_kfet/templates/base_search.html:15
msgid "Search by attribute such as name"
msgstr "Chercher par un attribut tel que le nom"
msgid "Search by attribute such as name..."
msgstr "Chercher par un attribut tel que le nom..."
#: note_kfet/templates/base_search.html:23
msgid "There is no results."

View File

@ -265,11 +265,9 @@ OAUTH2_PROVIDER = {
'SCOPES_BACKEND_CLASS': 'permission.scopes.PermissionScopes',
'OAUTH2_VALIDATOR_CLASS': "permission.scopes.PermissionOAuth2Validator",
'REFRESH_TOKEN_EXPIRE_SECONDS': timedelta(days=14),
'PKCE_REQUIRED': False, # PKCE (fix a breaking change of django-oauth-toolkit 2.0.0)
}
# PKCE (fix a breaking change of django-oauth-toolkit 2.0.0)
PKCE_REQUIRED = False
# Take control on how widget templates are sourced
# See https://docs.djangoproject.com/en/2.2/ref/forms/renderers/#templatessetting
FORM_RENDERER = 'django.forms.renderers.TemplatesSetting'

View File

@ -12,7 +12,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
</h3>
<div class="card-body">
<input id="searchbar" type="text" class="form-control"
placeholder="{% trans "Search by attribute such as name" %}">
placeholder="{% trans "Search by attribute such as name..." %}">
</div>
<div id="dynamic-table">
{% if table.data %}