From 7af2ebba409f82300768ab191526912ce5afb988 Mon Sep 17 00:00:00 2001 From: mcngnt Date: Wed, 3 Jul 2024 18:55:41 +0200 Subject: [PATCH] basic survey --- apps/wei/forms/surveys/__init__.py | 4 +- apps/wei/forms/surveys/wei2024.py | 303 +++++++++++++++++++++++++++++ 2 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 apps/wei/forms/surveys/wei2024.py diff --git a/apps/wei/forms/surveys/__init__.py b/apps/wei/forms/surveys/__init__.py index 0e0c37e2..733f7c37 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 .wei2023 import WEISurvey2023 +from .wei2024 import WEISurvey2024 __all__ = [ 'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey', ] -CurrentSurvey = WEISurvey2023 +CurrentSurvey = WEISurvey2024 diff --git a/apps/wei/forms/surveys/wei2024.py b/apps/wei/forms/surveys/wei2024.py new file mode 100644 index 00000000..7e8c576e --- /dev/null +++ b/apps/wei/forms/surveys/wei2024.py @@ -0,0 +1,303 @@ +# 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.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", +"bus faible en alcool mais fort en connerie avec une partie calme pour les amateurs de sieste et de jeux de société. non discriminant il accepte tout le monde y compris le plus nulle des pokémons (magicarpe !!!!!!). Malgré les accusations mensongères, il n'y a aucun weeb dans le Magi[Kar]p"], +["Va[car]me","#fd7a28", +"descr"], +["[Kar]aïbes","#a5cfdd", +"descr"], +["[Kar]di [Bus]","#e46398", +"descr"], +["Sparta[bus] 🐺🐒🏉","#ebdac2", +"descr"], +["Zanzo[Bus]","#FFFF", +"descr"], +["Bran[Kar]","#6da1ac", +"Si vous ne connaissez pas le Bran[Kar], c’est comme une grande famille qui fait un apéro, qui se bourre un peu la gueule en discutant des heures autour d’une table remplie de bouffe et de super bons cocktails (la plupart des barmen/barwomen du bus sont les barmans de Shakens), sauf qu’on 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", +"descr"], +["[Bus]ka-P","#7c4768", +"descr"] +] + +def get_survey_info(id): + s = {"recap": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0 + }} + s_ = {f"bus{id}" : {f"{i}" : 0 for i in range(1,5+1)} for id in range(len(buses_descr))} + s.update(s_) + s.update({f"bus{id}" : {f"{i}" : i for i in range(1,5+1)}}) + return {"scores" : s} + + +def print_bus(id): + return buses_descr[id][0] + "\n\n" + buses_descr[id][2] + +def print_all_buses(): + l = [print_bus(id) for id in range(len(buses_descr))] + return "\n\n---------\n\n".join(l) + +WORDS = {"recap": ["Cher(e) 1A, te voilà arrivé(e) devant un choix fatidique, le choix de ton bus....... \n (Musique effrayante) \n Peite blagounette évidemment, chacun des bus te permettra de passer un excellent WEI ! Mais quitte à avoir le choix, voici la liste de tous les bus ainsi qu'une description détaillée de ces derniers ! Prends ton temps, observe les bien et quand tu te sens prêt(e), appuye sur le bouton 'Noter les bus' pour continuer (pas besoin d'apprendre par coeur les bus, la descirption du bus te sera rappeler avant de le noter !) \n\n\n" + print_all_buses(), { + 1: "Noter les bus :" +}]} + +WORDS.update({ + f"bus{id}" : [print_bus(id),{i : f"Noter {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 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 = WEISurveyInformation2024(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 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() +