diff --git a/apps/wei/forms/registration.py b/apps/wei/forms/registration.py
index fd6fea2c..2458a2eb 100644
--- a/apps/wei/forms/registration.py
+++ b/apps/wei/forms/registration.py
@@ -213,4 +213,3 @@ class BusTeamForm(forms.ModelForm):
),
"color": ColorWidget(),
}
- # "color": ColorWidget(),
diff --git a/apps/wei/forms/surveys/__init__.py b/apps/wei/forms/surveys/__init__.py
index 0a33bc16..ef692d25 100644
--- a/apps/wei/forms/surveys/__init__.py
+++ b/apps/wei/forms/surveys/__init__.py
@@ -2,11 +2,11 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm
-from .wei2024 import WEISurvey2024
+from .wei2025 import WEISurvey2025
__all__ = [
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
]
-CurrentSurvey = WEISurvey2024
+CurrentSurvey = WEISurvey2025
diff --git a/apps/wei/forms/surveys/base.py b/apps/wei/forms/surveys/base.py
index 3f3bff3b..99c84583 100644
--- a/apps/wei/forms/surveys/base.py
+++ b/apps/wei/forms/surveys/base.py
@@ -121,6 +121,12 @@ class WEISurveyAlgorithm:
"""
raise NotImplementedError
+ @classmethod
+ def get_bus_information_form(cls):
+ """
+ The class of the form to update the bus information.
+ """
+ raise NotImplementedError
class WEISurvey:
"""
diff --git a/apps/wei/forms/surveys/wei2025.py b/apps/wei/forms/surveys/wei2025.py
new file mode 100644
index 00000000..749d89c4
--- /dev/null
+++ b/apps/wei/forms/surveys/wei2025.py
@@ -0,0 +1,343 @@
+# 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 .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, WEIBusInformation
+from ...models import WEIMembership, Bus
+
+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',
+]
+
+
+class WEISurveyForm2025(forms.Form):
+ """
+ Survey form for the year 2025.
+ Members choose 20 words, from which we calculate the best associated bus.
+ """
+
+ word = forms.ChoiceField(
+ label=_("Choose a word:"),
+ widget=forms.RadioSelect(),
+ )
+
+ 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()
+
+ if self.data:
+ self.fields["word"].choices = [(w, w) for w in WORDS]
+ if self.is_valid():
+ return
+
+ rng = Random((information.step + 1) * information.seed)
+
+ buses = WEISurveyAlgorithm2025.get_buses()
+ informations = {bus: WEIBusInformation2025(bus) for bus in buses}
+ scores = sum((list(informations[bus].scores.values()) for bus in buses), [])
+ average_score = sum(scores) / len(scores)
+
+ preferred_words = {bus: [word for word in WORDS
+ if informations[bus].scores[word] >= average_score]
+ for bus in buses}
+
+ # Correction : proposer plusieurs mots différents à chaque étape
+ N_CHOICES = 4 # Nombre de mots à proposer à chaque étape
+ all_preferred_words = set()
+ for bus_words in preferred_words.values():
+ all_preferred_words.update(bus_words)
+ all_preferred_words = list(all_preferred_words)
+ rng.shuffle(all_preferred_words)
+ words = all_preferred_words[:N_CHOICES]
+ self.fields["word"].choices = [(w, w) for w in words]
+
+
+class WEIBusInformation2025(WEIBusInformation):
+ """
+ For each word, the bus has a score
+ """
+ scores: dict
+
+ def __init__(self, bus):
+ self.scores = {}
+ for word in WORDS:
+ self.scores[word] = 0
+ super().__init__(bus)
+
+
+class BusInformationForm2025(forms.ModelForm):
+ class Meta:
+ model = Bus
+ fields = ['information_json']
+ widgets = {}
+
+ 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
+ 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),
+ 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, 21):
+ setattr(self, "word" + str(i), None)
+ super().__init__(registration)
+
+
+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):
+ word = form.cleaned_data["word"]
+ self.information.step += 1
+ setattr(self.information, "word" + str(self.information.step), word)
+ 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 == 20
+
+ @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(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 = sum(bus_info.scores[getattr(self.information, 'word' + str(i))]
+ - self.word_mean(getattr(self.information, 'word' + str(i))) for i in range(1, 21)) / 20
+ 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):
+ 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
+ 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
+ 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_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()
diff --git a/apps/wei/templates/wei/bus_detail.html b/apps/wei/templates/wei/bus_detail.html
index 04ef5f9a..af4eaccb 100644
--- a/apps/wei/templates/wei/bus_detail.html
+++ b/apps/wei/templates/wei/bus_detail.html
@@ -22,6 +22,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endif %}
{% trans "Edit" %}
+ {% trans "Edit information" %}
{% trans "Add team" %}
diff --git a/apps/wei/urls.py b/apps/wei/urls.py
index 3084dd51..5f9283c0 100644
--- a/apps/wei/urls.py
+++ b/apps/wei/urls.py
@@ -4,7 +4,7 @@
from django.urls import path
from .views import CurrentWEIDetailView, WEI1AListView, WEIListView, WEICreateView, WEIDetailView, WEIUpdateView, \
- WEIRegistrationsView, WEIMembershipsView, MemberListRenderView, \
+ WEIRegistrationsView, WEIMembershipsView, MemberListRenderView, BusInformationUpdateView, \
BusCreateView, BusManageView, BusUpdateView, BusTeamCreateView, BusTeamManageView, BusTeamUpdateView, \
WEIAttributeBus1AView, WEIAttributeBus1ANextView, WEIRegister1AView, WEIRegister2AView, WEIUpdateRegistrationView, \
WEIDeleteRegistrationView, WEIValidateRegistrationView, WEISurveyView, WEISurveyEndView, WEIClosedView
@@ -42,4 +42,5 @@ urlpatterns = [
path('detail//closed/', WEIClosedView.as_view(), name="wei_closed"),
path('bus-1A//', WEIAttributeBus1AView.as_view(), name="wei_bus_1A"),
path('bus-1A/next//', WEIAttributeBus1ANextView.as_view(), name="wei_bus_1A_next"),
+ path('update-bus-info//', BusInformationUpdateView.as_view(), name="update_bus_info"),
]
diff --git a/apps/wei/views.py b/apps/wei/views.py
index 34b274a2..b3ad8883 100644
--- a/apps/wei/views.py
+++ b/apps/wei/views.py
@@ -1422,3 +1422,29 @@ class WEIAttributeBus1ANextView(LoginRequiredMixin, RedirectView):
# On redirige vers la page d'attribution pour le premier étudiant trouvé
return reverse_lazy('wei:wei_bus_1A', args=(qs.first().pk,))
+
+
+class BusInformationUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
+ model = Bus
+
+ def get_form_class(self):
+ return CurrentSurvey.get_algorithm_class().get_bus_information_form()
+
+ def dispatch(self, request, *args, **kwargs):
+ wei = self.get_object().wei
+ today = date.today()
+ # We can't update a bus once the WEI is started
+ if today >= wei.date_start:
+ return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,)))
+ return super().dispatch(request, *args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["club"] = self.object.wei
+ context["information"] = CurrentSurvey.get_algorithm_class().get_bus_information(self.object)
+ self.object.save()
+ return context
+
+ def get_success_url(self):
+ self.object.refresh_from_db()
+ return reverse_lazy("wei:manage_bus", kwargs={"pk": self.object.pk})
\ No newline at end of file