diff --git a/apps/wei/forms/surveys/base.py b/apps/wei/forms/surveys/base.py index 99c84583..c2fde39d 100644 --- a/apps/wei/forms/surveys/base.py +++ b/apps/wei/forms/surveys/base.py @@ -128,6 +128,7 @@ class WEISurveyAlgorithm: """ raise NotImplementedError + class WEISurvey: """ Survey associated to a first year WEI registration. diff --git a/apps/wei/forms/surveys/wei2025.py b/apps/wei/forms/surveys/wei2025.py index 749d89c4..d92cc23f 100644 --- a/apps/wei/forms/surveys/wei2025.py +++ b/apps/wei/forms/surveys/wei2025.py @@ -58,20 +58,23 @@ class WEISurveyForm2025(forms.Form): 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) + if scores: + average_score = sum(scores) / len(scores) + else: + average_score = 0 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 + 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] + words = all_preferred_words[:n_choices] self.fields["word"].choices = [(w, w) for w in words] @@ -96,7 +99,7 @@ class BusInformationForm2025(forms.ModelForm): def __init__(self, *args, words=None, **kwargs): super().__init__(*args, **kwargs) - + initial_scores = {} if self.instance and self.instance.information_json: try: @@ -106,13 +109,13 @@ class BusInformationForm2025(forms.ModelForm): initial_scores = {} if words is None: words = WORDS - self.words = words + self.words = words - CHOICES = [(i, str(i)) for i in range(6)] # [(0, '0'), (1, '1'), ..., (5, '5')] + 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, + choices=choices, coerce=int, initial=initial_scores.get(word, 0), required=True, @@ -239,6 +242,7 @@ class WEISurveyAlgorithm2025(WEISurveyAlgorithm): @classmethod def get_bus_information_form(cls): return BusInformationForm2025 + def run_algorithm(self, display_tqdm=False): """ Gale-Shapley algorithm implementation. diff --git a/apps/wei/tests/test_wei_algorithm_2024.py b/apps/wei/tests/test_wei_algorithm_2024.py index bae36399..d1e5f428 100644 --- a/apps/wei/tests/test_wei_algorithm_2024.py +++ b/apps/wei/tests/test_wei_algorithm_2024.py @@ -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.wei2024 import WEIBusInformation2024, WEISurvey2024, WORDS, WEISurveyInformation2024 from ..models import Bus, WEIClub, WEIRegistration @@ -129,44 +127,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, 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()) diff --git a/apps/wei/tests/test_wei_algorithm_2025.py b/apps/wei/tests/test_wei_algorithm_2025.py new file mode 100644 index 00000000..5930eb3b --- /dev/null +++ b/apps/wei/tests/test_wei_algorithm_2025.py @@ -0,0 +1,111 @@ +# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +import random + +from django.contrib.auth.models import User +from django.test import TestCase + +from ..forms.surveys.wei2025 import WEIBusInformation2025, WEISurvey2025, WORDS, WEISurveyInformation2025 +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.wei = WEIClub.objects.create( + name="WEI 2025", + email="wei2025@example.com", + date_start='2025-09-12', + date_end='2025-09-14', + year=2025, + membership_start='2025-06-01' + ) + + 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 = WEIBusInformation2025(bus) + for word in WORDS: + information.scores[word] = random.randint(0, 101) + 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 = WEISurveyInformation2025(registration) + for j in range(1, 21): + setattr(information, f'word{j}', random.choice(WORDS)) + information.step = 20 + information.save(registration) + registration.save() + + # Run algorithm + WEISurvey2025.get_algorithm_class()().run_algorithm() + + # Ensure that everyone has its first choice + for r in WEIRegistration.objects.filter(wei=self.wei).all(): + survey = WEISurvey2025(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 = WEISurveyInformation2025(registration) + for j in range(1, 21): + setattr(information, f'word{j}', random.choice(WORDS)) + information.step = 20 + information.save(registration) + registration.save() + + # Run algorithm + WEISurvey2025.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 = WEISurvey2025(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 % diff --git a/apps/wei/tests/test_wei_registration.py b/apps/wei/tests/test_wei_registration.py index f7ce5ac0..d286581c 100644 --- a/apps/wei/tests/test_wei_registration.py +++ b/apps/wei/tests/test_wei_registration.py @@ -778,7 +778,7 @@ class TestDefaultWEISurvey(TestCase): WEISurvey.update_form(None, None) self.assertEqual(CurrentSurvey.get_algorithm_class().get_survey_class(), CurrentSurvey) - self.assertEqual(CurrentSurvey.get_year(), 2024) + self.assertEqual(CurrentSurvey.get_year(), 2025) class TestWeiAPI(TestAPI): diff --git a/apps/wei/views.py b/apps/wei/views.py index b3ad8883..97b49eaf 100644 --- a/apps/wei/views.py +++ b/apps/wei/views.py @@ -1447,4 +1447,4 @@ class BusInformationUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateV 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 + return reverse_lazy("wei:manage_bus", kwargs={"pk": self.object.pk})