mirror of
				https://gitlab.crans.org/bde/nk20
				synced 2025-10-30 23:39:54 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			623 lines
		
	
	
		
			27 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			623 lines
		
	
	
		
			27 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| # Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
 | ||
| # SPDX-License-Identifier: GPL-3.0-or-later
 | ||
| 
 | ||
| import time
 | ||
| import json
 | ||
| from functools import lru_cache
 | ||
| from random import Random
 | ||
| 
 | ||
| from django import forms
 | ||
| from django.db import transaction
 | ||
| from django.db.models import Q
 | ||
| from django.utils.translation import gettext_lazy as _
 | ||
| from django.utils.safestring import mark_safe
 | ||
| 
 | ||
| from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, WEIBusInformation
 | ||
| from ...models import WEIMembership, Bus
 | ||
| 
 | ||
| WORDS = {
 | ||
|     'list': [
 | ||
|         'Fiesta', 'Graillance', 'Move it move it', 'Calme', 'Nerd et geek', 'Jeux de rôles et danse rock',
 | ||
|         'Strass et paillettes', 'Spectaculaire', 'Splendide', 'Flow inégalable', 'Rap', 'Battles légendaires',
 | ||
|         'Techno', 'Alcool', 'Kiffeur·euse', 'Rugby', 'Médiéval', 'Festif',
 | ||
|         'Stylé', 'Chipie', 'Rétro', 'Vache', 'Farfadet', 'Fanfare',
 | ||
|     ],
 | ||
|     'questions': {
 | ||
|         "alcool": [
 | ||
|             """Sur une échelle allant de 0 (= 0 alcool ou très peu) à 5 (= la fontaine de jouvence alcoolique),
 | ||
|             quel niveau de consommation d’alcool souhaiterais-tu ?""",
 | ||
|             {
 | ||
|                 42: 4,
 | ||
|                 47: 1,
 | ||
|                 48: 3,
 | ||
|                 45: 3.5,
 | ||
|                 44: 4,
 | ||
|                 46: 5,
 | ||
|                 43: 3,
 | ||
|                 49: 3
 | ||
|             }
 | ||
|         ],
 | ||
|         "voie_post_bac": [
 | ||
|             """Si la DA du bus de ton choix correspondait à une voie post-bac, laquelle serait-elle ?""",
 | ||
|             {
 | ||
|                 42: "Double licence cuisine/arts du cirque option burger",
 | ||
|                 47: "BTS Exploration de donjon",
 | ||
|                 48: "Ecole des stars en herbe",
 | ||
|                 45: "Déscolarisation précoce",
 | ||
|                 44: "Rattrapage pour excès de kiff",
 | ||
|                 46: "Double cursus STAPS / Licence d’histoire",
 | ||
|                 43: "Recherche active d’un sugar daddy/d’un sugar mommy",
 | ||
|                 49: "Licence de musicologie"
 | ||
|             }
 | ||
|         ],
 | ||
|         "boite": [
 | ||
|             """Tu es seul·e sur une île déserte et devant toi il y a une sombre boîte de taille raisonnable.
 | ||
|             Qu’y a-t-il à l’intérieur ?""",
 | ||
|             {
 | ||
|                 42: "Un burgouzz de valouzz",
 | ||
|                 47: "Un ocarina (pour me téléporter hors de ce bourbier)",
 | ||
|                 48: "Des paillettes, un micro de karaoké et une enceinte bluetooth",
 | ||
|                 45: "Un kebab",
 | ||
|                 44: "Une 86 et un caisson pour taper du pied",
 | ||
|                 46: "Une épée, un ballon et une tireuse",
 | ||
|                 43: "Des lunettes de soleil",
 | ||
|                 49: "Mon instrument de musique"
 | ||
|             }
 | ||
|         ],
 | ||
|         "tardif": [
 | ||
|             """Il est 00h, tu as passé la journée à la plage avec tes copains et iels te proposent de prolonger parce
 | ||
|             qu’après tout, il n’y a plus personne sur la plage à cette heure-ci. Tu n’habites pas loin mais t’enchaînes
 | ||
|             demain avec une journée similaire avec un autre groupe d’amis parce que t’es trop #busy. Que fais-tu ?""",
 | ||
|             {
 | ||
|                 42: "On veut se déchaîner toute la nuit !!",
 | ||
|                 47: "Je prends une glace et chill un moment avant d’aller dormir",
 | ||
|                 48: "J’enfile mes boogie shoes pour enflammer le dancefloor avec elleux et lancer un concours de slay, le perdant finit la bouteille de rhum",
 | ||
|                 45: "La fête continuuuuue",
 | ||
|                 44: "Soirée sangria plage → boîte → lever de soleil sur la plage",
 | ||
|                 46: "Minuit ? C’est l’heure du genepi. On commence les alcools forts !!",
 | ||
|                 43: "T’enchaînes direct (faut pas les priver de ta présence)",
 | ||
|                 49: "On continue en mode chill (soirée potins)"
 | ||
|             }
 | ||
|         ],
 | ||
|         "cohesion": [
 | ||
|             """C’est la rentrée de Seconde et tu découvres ta classe, tes camarades et ta prof principale!!!
 | ||
|             qui vous propose une activité de cohésion. Laquelle est-elle ?""",
 | ||
|             {
 | ||
|                 42: "Un relais cubi en ventriglisse",
 | ||
|                 47: "Un jeu de rôle",
 | ||
|                 48: "Organiser la soirée de l’année dans le lycée. Le thème : SLAY (Spotlight, Love, Amaze/All-night, Yeah), paillettes, disco",
 | ||
|                 45: "La prof de français propose un slam parce qu'elle pense que c'est du rap littéraire qui fera plaisir aux élèves",
 | ||
|                 44: "P’tit escape game + apéro",
 | ||
|                 46: "Joute avec des boucliers Gilbert",
 | ||
|                 43: "Tournage d’un clip de confessions nocturnes de Diam’s",
 | ||
|                 49: "Je sais pas j’ai raté mon BAFA"
 | ||
|             }
 | ||
|         ],
 | ||
|         "artiste": [
 | ||
|             """C’est l’été et la saison des festivals a commencé. Tu regardes la programmation du festival
 | ||
|             pas loin de chez toi et tu découvres avec joie la présence d’un·e artiste. De qui s’agit-il ?""",
 | ||
|             {
 | ||
|                 42: "Moto-Moto (il chantera son fameux tube “je les aime grosses, je les aime bombées”)",
 | ||
|                 47: "Hatsune Miku",
 | ||
|                 48: "Rihanna",
 | ||
|                 45: "Vald",
 | ||
|                 44: "Qui connaît vraiment les noms des artistes de tech ?",
 | ||
|                 46: "Perceval",
 | ||
|                 43: "Fatal bazooka",
 | ||
|                 49: "Måneskin"
 | ||
|             }
 | ||
|         ],
 | ||
|         "annonce_noel": [
 | ||
|             """C’est Noël et tu revois toute ta famille, oncles, tantes, cousin·e·s, grands-parents, la totale.
 | ||
|             D’un coup, tu te lèves, tapotes de manière pompeuse sur ton verre avec un de tes couverts.
 | ||
|             Qu’annonces-tu ?""",
 | ||
|             {
 | ||
|                 42: """« Chère famille. Je sais bien que nous avions dit : pas de politique à table.
 | ||
|                     Je ne peux toutefois me retenir de vous annoncer une grande nouvelle…
 | ||
|                     j’ai décidé de quitter la ville pour consacrer ma vie au culte du Roi Julian.
 | ||
|                     A moi la jungle luxuriante, là où le soleil chaud caresse les palmiers,
 | ||
|                     où les lémuriens dansent avec frénésie et où chaque repas est une ode au burger sauvage.
 | ||
|                     Longue vie à Sa Majesté le Roi Julian ! »""",
 | ||
|                 47: "« J’ai perdu »",
 | ||
|                 48: "« Mes chers parents je pars, j’arrête l’ENS pour devenir DJ slay à Ibiza »",
 | ||
|                 45: "J’interromps le repas pour rapper les 6min de bande organisée",
 | ||
|                 44: "« Digestif ? Pétanque ? Les deux ? »",
 | ||
|                 46: "« Montjoie St Denis à bas la Macronie »",
 | ||
|                 43: "« Je suis enceinte » (c’est faux j’ai juste besoin d’attention)",
 | ||
|                 49: """Discours de remerciement :
 | ||
|                     je lance un powerpoint de 65 slides et sors une feuille A4 blanche (je fais semblant de lire mon discours dessus)"""
 | ||
|             }
 | ||
|         ],
 | ||
|         "vacances": [
 | ||
|             """Les vacances sont là et t’aimerais bien partir quelque part, mais où ?""",
 | ||
|             {
 | ||
|                 42: "A Madagascar, à bord d’un bus conduit par des pingouins",
 | ||
|                 47: "Dans ma chambre",
 | ||
|                 48: "Rio de Janeiro",
 | ||
|                 45: "N'importe où tant qu'on peut sortir tous les soirs",
 | ||
|                 44: "Tu suis les plans du club ski ou de piratens",
 | ||
|                 46: "Carcassonne",
 | ||
|                 43: "Coachella",
 | ||
|                 49: "Dans les montagnes de la république populaire d’Auvergne-Rhônes-Alpes pour profiter de la fraîcheur, de la nature et de mes ami·e·s"
 | ||
|             }
 | ||
|         ],
 | ||
|         "loisir": [
 | ||
|             """T’as fini ta journée de cours et tu t’apprêtes à profiter d’une activité/hobby/loisir de ton choix.
 | ||
|             Laquelle est-ce ?""",
 | ||
|             {
 | ||
|                 42: "Cueillir des noix de coco",
 | ||
|                 47: "Essayer de travailler puis chill avec des potes autour d’un jeu en buvant du thé",
 | ||
|                 48: "Repet du nouveau spectacle de mon club, before (potins) puis sortie avec les potes jusqu’au bout de la night",
 | ||
|                 45: "Zoner avec les copaings jusqu’à pas d’heure",
 | ||
|                 44: "Go Kfet pour se faire traquenard jusqu’à 3h du mat",
 | ||
|                 46: "Déterminer ce qui est le plus solide entre mon crâne et une ecocup",
 | ||
|                 43: "Revoir pour la 6e fois gossip girl au fond de ton lit",
 | ||
|                 49: "Jouer de mon instrument préféré avec les copains/copines pour préparer le prochain concert #solidays"
 | ||
|             }
 | ||
|         ],
 | ||
|         "plan": [
 | ||
|             """Tu reçois un message sur la conversation de groupe que tu partages avec tes potes :
 | ||
|             vous êtes chaud·e·s pour vous retrouver. Quel plan t’attire le plus ?""",
 | ||
|             {
 | ||
|                 42: """Après-midi piscine, puis before arrosé de mojito,
 | ||
|                     avant d’aller s’éclater en pot avec toute la savane et de finir sur un after spécial pina colada""",
 | ||
|                 47: """(matin) : Ptit jeu de rôle
 | ||
|                     (repas) : le traditionnel poké-tacos
 | ||
|                     (juste après le repas) : combat avec des épées en mousse avec les copains!
 | ||
|                     (16h00) : pause thé
 | ||
|                     (fin d’après midi) : initiation à la danse rock
 | ||
|                     (soirée) : découverte d’un jeu de société avec des règles obscures
 | ||
|                     """,
 | ||
|                 48: "Soirée champagne and chic : spectacle et dîner au moulin rouge puis soirée sur les champs",
 | ||
|                 45: "Se regrouper pour une soirée, même si il n’est encore que 10h",
 | ||
|                 44: "P’tit poké qui termine en koin koin avec after poker",
 | ||
|                 46: "Une dégustation de bière, un rugby et toute autre activité joviale",
 | ||
|                 43: "Un brunch de pour papoter puis friperies",
 | ||
|                 49: "Soirée raclette !"
 | ||
|             }
 | ||
|         ]
 | ||
|     },
 | ||
|     'stats': [
 | ||
|         {
 | ||
|             "question": """Le WEI est structuré par bus, et au sein de chaque bus, par équipes.
 | ||
|                          Pour toi, être dans une équipe où tout le monde reste sobre (primo-entrants comme encadrants) c'est :""",
 | ||
|             "answers": [
 | ||
|                 (1, "Inenvisageable"),
 | ||
|                 (2, "À contre cœur"),
 | ||
|                 (3, "Pourquoi pas"),
 | ||
|                 (4, "Souhaitable"),
 | ||
|                 (5, "Nécessaire"),
 | ||
|             ],
 | ||
|             "help_text": "(De toute façon aucun alcool n'est consommé pendant les trajets du bus, ni aller, ni retour.)",
 | ||
|         },
 | ||
|         {
 | ||
|             "question": "Faire partie d'un bus qui n'apporte pas de boisson alcoolisée pour ses membres, pour toi c'est :",
 | ||
|             "answers": [
 | ||
|                 (1, "Inenvisageable"),
 | ||
|                 (2, "À contre cœur"),
 | ||
|                 (3, "Pourquoi pas"),
 | ||
|                 (4, "Souhaitable"),
 | ||
|                 (5, "Nécessaire"),
 | ||
|             ],
 | ||
|             "help_text": """(Tout les bus apportent de l'alcool cette année, cette question sert à l'organisation pour l'année prochaine.
 | ||
|                          De plus il y aura de toute façon de l'alcool commun au WEI et aucun alcool n'est consommé pendant les trajets en bus.)""",
 | ||
|         },
 | ||
|     ]
 | ||
| }
 | ||
| 
 | ||
| IMAGES = {
 | ||
|     "vacances": {
 | ||
|         49: "/static/wei/img/logo_auvergne_rhone_alpes.jpg",
 | ||
|     }
 | ||
| }
 | ||
| 
 | ||
| NB_WORDS = 5
 | ||
| 
 | ||
| 
 | ||
| class OptionalImageRadioSelect(forms.RadioSelect):
 | ||
|     def __init__(self, images=None, *args, **kwargs):
 | ||
|         self.images = images or {}
 | ||
|         super().__init__(*args, **kwargs)
 | ||
| 
 | ||
|     def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
 | ||
|         option = super().create_option(name, value, label, selected, index, subindex=subindex, attrs=attrs)
 | ||
|         img_url = self.images.get(value)
 | ||
|         if img_url:
 | ||
|             option['label'] = mark_safe(f'{label} <img src="{img_url}" style="height:32px;vertical-align:middle;">')
 | ||
|         else:
 | ||
|             option['label'] = label
 | ||
|         return option
 | ||
| 
 | ||
| 
 | ||
| class WEISurveyForm2025(forms.Form):
 | ||
|     """
 | ||
|     Survey form for the year 2025.
 | ||
|     Members choose 20 words, 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 = WEISurveyInformation2025(registration)
 | ||
|         if not information.seed:
 | ||
|             information.seed = int(1000 * time.time())
 | ||
|             information.save(registration)
 | ||
|             registration._force_save = True
 | ||
|             registration.save()
 | ||
| 
 | ||
|         rng = Random((information.step + 1) * information.seed)
 | ||
| 
 | ||
|         if information.step == 0:
 | ||
|             self.fields["words"] = forms.MultipleChoiceField(
 | ||
|                 label=_(f"Select {NB_WORDS} words that describe the WEI experience you want to have."),
 | ||
|                 choices=[(w, w) for w in WORDS['list']],
 | ||
|                 widget=forms.CheckboxSelectMultiple(),
 | ||
|                 required=True,
 | ||
|             )
 | ||
|             if self.is_valid():
 | ||
|                 return
 | ||
| 
 | ||
|             all_preferred_words = WORDS['list']
 | ||
|             rng.shuffle(all_preferred_words)
 | ||
|             self.fields["words"].choices = [(w, w) for w in all_preferred_words]
 | ||
|         elif information.step <= len(WORDS['questions']):
 | ||
|             questions = list(WORDS['questions'].items())
 | ||
|             idx = information.step - 1
 | ||
|             if idx < len(questions):
 | ||
|                 q, (desc, answers) = questions[idx]
 | ||
|                 if q == 'alcool':
 | ||
|                     choices = [(i / 2, str(i / 2)) for i in range(11)]
 | ||
|                 else:
 | ||
|                     choices = [(k, v) for k, v in answers.items()]
 | ||
|                     rng.shuffle(choices)
 | ||
|                 self.fields[q] = forms.ChoiceField(
 | ||
|                     label=desc,
 | ||
|                     choices=choices,
 | ||
|                     widget=OptionalImageRadioSelect(images=IMAGES.get(q, {})),
 | ||
|                     required=True,
 | ||
|                 )
 | ||
|         elif information.step == len(WORDS['questions']) + 1:
 | ||
|             for i, v in enumerate(WORDS['stats']):
 | ||
|                 self.fields[f'stat_{i}'] = forms.ChoiceField(
 | ||
|                     label=v['question'],
 | ||
|                     choices=v['answers'],
 | ||
|                     widget=forms.RadioSelect(),
 | ||
|                     required=False,
 | ||
|                     help_text=_(v.get('help_text', ''))
 | ||
|                 )
 | ||
| 
 | ||
|     def clean_words(self):
 | ||
|         data = self.cleaned_data['words']
 | ||
|         if len(data) != NB_WORDS:
 | ||
|             raise forms.ValidationError(_(f"Please choose exactly {NB_WORDS} words"))
 | ||
|         return data
 | ||
| 
 | ||
| 
 | ||
| class WEIBusInformation2025(WEIBusInformation):
 | ||
|     """
 | ||
|     For each word, the bus has a score
 | ||
|     """
 | ||
|     scores: dict
 | ||
| 
 | ||
|     def __init__(self, bus):
 | ||
|         self.scores = {}
 | ||
|         super().__init__(bus)
 | ||
| 
 | ||
| 
 | ||
| class BusInformationForm2025(forms.ModelForm):
 | ||
|     class Meta:
 | ||
|         model = Bus
 | ||
|         fields = ['information_json']
 | ||
|         widgets = {
 | ||
|             'information_json': forms.HiddenInput(),
 | ||
|         }
 | ||
| 
 | ||
|     def __init__(self, *args, words=None, **kwargs):
 | ||
|         super().__init__(*args, **kwargs)
 | ||
| 
 | ||
|         initial_scores = {}
 | ||
|         if self.instance and self.instance.information_json:
 | ||
|             try:
 | ||
|                 info = json.loads(self.instance.information_json)
 | ||
|                 initial_scores = info.get("scores", {})
 | ||
|             except (json.JSONDecodeError, TypeError, AttributeError):
 | ||
|                 initial_scores = {}
 | ||
|         if words is None:
 | ||
|             words = WORDS['list']
 | ||
|         self.words = words
 | ||
| 
 | ||
|         choices = [(i, str(i)) for i in range(6)]  # [(0, '0'), (1, '1'), ..., (5, '5')]
 | ||
|         for word in words:
 | ||
|             self.fields[word] = forms.TypedChoiceField(
 | ||
|                 label=word,
 | ||
|                 choices=choices,
 | ||
|                 coerce=int,
 | ||
|                 initial=initial_scores.get(word, 0) if word in initial_scores else None,
 | ||
|                 required=True,
 | ||
|                 widget=forms.RadioSelect,
 | ||
|                 help_text=_("Rate between 0 and 5."),
 | ||
|             )
 | ||
| 
 | ||
|     def clean(self):
 | ||
|         cleaned_data = super().clean()
 | ||
|         scores = {}
 | ||
|         for word in self.words:
 | ||
|             value = cleaned_data.get(word)
 | ||
|             if value is not None:
 | ||
|                 scores[word] = value
 | ||
|         # On encode en JSON
 | ||
|         cleaned_data['information_json'] = json.dumps({"scores": scores})
 | ||
|         return cleaned_data
 | ||
| 
 | ||
| 
 | ||
| class WEISurveyInformation2025(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.
 | ||
|     """
 | ||
|     # Random seed that is stored at the first time to ensure that words are generated only once
 | ||
|     seed = 0
 | ||
|     step = 0
 | ||
| 
 | ||
|     def __init__(self, registration):
 | ||
|         for i in range(1, NB_WORDS + 1):
 | ||
|             setattr(self, "word" + str(i), None)
 | ||
|         for q in WORDS['questions']:
 | ||
|             setattr(self, q, None)
 | ||
|         super().__init__(registration)
 | ||
| 
 | ||
|     def reset(self, registration):
 | ||
|         """
 | ||
|         Réinitialise complètement le questionnaire : step, seed, mots choisis et réponses aux questions.
 | ||
|         """
 | ||
|         self.step = 0
 | ||
|         self.seed = 0
 | ||
|         for i in range(1, NB_WORDS + 1):
 | ||
|             setattr(self, f"word{i}", None)
 | ||
|         for q in WORDS['questions']:
 | ||
|             setattr(self, q, None)
 | ||
|         self.save(registration)
 | ||
|         registration._force_save = True
 | ||
|         registration.save()
 | ||
| 
 | ||
| 
 | ||
| class WEISurvey2025(WEISurvey):
 | ||
|     """
 | ||
|     Survey for the year 2025.
 | ||
|     """
 | ||
| 
 | ||
|     @classmethod
 | ||
|     def get_year(cls):
 | ||
|         return 2025
 | ||
| 
 | ||
|     @classmethod
 | ||
|     def get_survey_information_class(cls):
 | ||
|         return WEISurveyInformation2025
 | ||
| 
 | ||
|     def get_form_class(self):
 | ||
|         return WEISurveyForm2025
 | ||
| 
 | ||
|     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):
 | ||
|         if self.information.step == 0:
 | ||
|             words = form.cleaned_data['words']
 | ||
|             for i, word in enumerate(words, 1):
 | ||
|                 setattr(self.information, "word" + str(i), word)
 | ||
|             self.information.step += 1
 | ||
|             self.save()
 | ||
|         elif 1 <= self.information.step <= len(WORDS['questions']):
 | ||
|             questions = list(WORDS['questions'].keys())
 | ||
|             idx = self.information.step - 1
 | ||
|             if idx < len(questions):
 | ||
|                 q = questions[idx]
 | ||
|                 setattr(self.information, q, form.cleaned_data[q])
 | ||
|                 self.information.step += 1
 | ||
|                 self.save()
 | ||
|         else:
 | ||
|             for i, __ in enumerate(WORDS['stats']):
 | ||
|                 ans = form.cleaned_data.get(f'stat_{i}')
 | ||
|                 if ans is not None:
 | ||
|                     setattr(self.information, f'stat_{i}', ans)
 | ||
|             self.information.step += 1
 | ||
|             self.save()
 | ||
| 
 | ||
|     @classmethod
 | ||
|     def get_algorithm_class(cls):
 | ||
|         return WEISurveyAlgorithm2025
 | ||
| 
 | ||
|     def is_complete(self) -> bool:
 | ||
|         """
 | ||
|         The survey is complete once the bus is chosen.
 | ||
|         """
 | ||
|         return self.information.step > len(WORDS['questions']) + 1
 | ||
| 
 | ||
|     @classmethod
 | ||
|     @lru_cache()
 | ||
|     def word_mean(cls, word):
 | ||
|         """
 | ||
|         Calculate the mid-score given by all buses.
 | ||
|         """
 | ||
|         buses = cls.get_algorithm_class().get_buses()
 | ||
|         return sum([cls.get_algorithm_class().get_bus_information(bus).scores[word] for bus in buses]) / buses.count()
 | ||
| 
 | ||
|     @lru_cache()
 | ||
|     def score_questions(self, bus):
 | ||
|         """
 | ||
|         The score given by the answers to the questions
 | ||
|         """
 | ||
|         if not self.is_complete():
 | ||
|             raise ValueError("Survey is not ended, can't calculate score")
 | ||
|         s = sum(1 for q in WORDS['questions'] if q != 'alcool' and getattr(self.information, q) == bus.pk)
 | ||
|         if 'alcool' in WORDS['questions'] and bus.pk in WORDS['questions']['alcool'][1] and hasattr(self.information, 'alcool'):
 | ||
|             s -= abs(float(self.information.alcool) - float(WORDS['questions']['alcool'][1][bus.pk]))
 | ||
|         return s
 | ||
| 
 | ||
|     @lru_cache()
 | ||
|     def score_words(self, bus):
 | ||
|         """
 | ||
|         The score given by the choice of words
 | ||
|         """
 | ||
|         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 = sum(bus_info.scores[getattr(self.information, 'word' + str(i))]
 | ||
|                 - self.word_mean(getattr(self.information, 'word' + str(i))) for i in range(1, 1 + NB_WORDS)) / self.get_algorithm_class().get_buses().count()
 | ||
|         return s
 | ||
| 
 | ||
|     @lru_cache()
 | ||
|     def scores_per_bus(self):
 | ||
|         return {bus: (self.score_questions(bus), self.score_words(bus)) for bus in self.get_algorithm_class().get_buses()}
 | ||
| 
 | ||
|     @lru_cache()
 | ||
|     def ordered_buses(self):
 | ||
|         """
 | ||
|         Order the buses by the score_questions of the survey.
 | ||
|         """
 | ||
|         values = list(self.scores_per_bus().items())
 | ||
|         values.sort(key=lambda item: -item[1][0])
 | ||
|         return values
 | ||
| 
 | ||
|     @classmethod
 | ||
|     def clear_cache(cls):
 | ||
|         cls.word_mean.cache_clear()
 | ||
|         return super().clear_cache()
 | ||
| 
 | ||
| 
 | ||
| class WEISurveyAlgorithm2025(WEISurveyAlgorithm):
 | ||
|     """
 | ||
|     The algorithm class for the year 2025.
 | ||
|     We use Gale-Shapley algorithm to attribute 1y students into buses.
 | ||
|     """
 | ||
| 
 | ||
|     @classmethod
 | ||
|     def get_survey_class(cls):
 | ||
|         return WEISurvey2025
 | ||
| 
 | ||
|     @classmethod
 | ||
|     def get_bus_information_class(cls):
 | ||
|         return WEIBusInformation2025
 | ||
| 
 | ||
|     @classmethod
 | ||
|     def get_bus_information_form(cls):
 | ||
|         return BusInformationForm2025
 | ||
| 
 | ||
|     @classmethod
 | ||
|     def get_buses(cls):
 | ||
| 
 | ||
|         if not hasattr(cls, '_buses'):
 | ||
|             cls._buses = Bus.objects.filter(wei__year=cls.get_survey_class().get_year(), size__gt=0).all().exclude(name='Staff')
 | ||
|         return cls._buses
 | ||
| 
 | ||
|     def run_algorithm(self, display_tqdm=False):
 | ||
|         """
 | ||
|         Gale-Shapley algorithm implementation.
 | ||
|         We modify it to allow buses to have multiple "weddings".
 | ||
|         We use lexigographical order on both scores
 | ||
|         """
 | ||
|         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
 | ||
|         WEISurvey2025.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_scores 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_words(bus)
 | ||
|                         if current_scores[1] <= 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()
 |