diff --git a/apps/wei/forms/surveys/wei2025.py b/apps/wei/forms/surveys/wei2025.py index d92cc23f..ee748c6c 100644 --- a/apps/wei/forms/surveys/wei2025.py +++ b/apps/wei/forms/surveys/wei2025.py @@ -14,16 +14,139 @@ 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', -] +WORDS = { + 'list': [ + '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', + ], + 'questions': { + 'Question 1': [ + 'Description 1', + { + 3: 'Réponse 1 Madagas[car]', + 43: 'Réponse 1 Y2[KAR]', + 2: 'Réponse 1 Tcherno[bus]', + 45: 'Réponse 1 [Kar]tier', + 1: 'Réponse 1 [Car]cassonne', + 47: 'Réponse 1 O[car]ina', + 48: 'Réponse 1 Show[bus]', + 49: 'Réponse 1 [Car]ioca' + } + ], + 'Question 2': [ + 'Description 2', + { + 3: 'Réponse 2 Madagas[car]', + 43: 'Réponse 2 Y2[KAR]', + 2: 'Réponse 2 Tcherno[bus]', + 45: 'Réponse 2 [Kar]tier', + 1: 'Réponse 2 [Car]cassonne', + 47: 'Réponse 2 O[car]ina', + 48: 'Réponse 2 Show[bus]', + 49: 'Réponse 2 [Car]ioca' + } + ], + 'Question 3': [ + 'Description 3', + { + 3: 'Réponse 3 Madagas[car]', + 43: 'Réponse 3 Y2[KAR]', + 2: 'Réponse 3 Tcherno[bus]', + 45: 'Réponse 3 [Kar]tier', + 1: 'Réponse 3 [Car]cassonne', + 47: 'Réponse 3 O[car]ina', + 48: 'Réponse 3 Show[bus]', + 49: 'Réponse 3 [Car]ioca' + } + ], + 'Question 4': [ + 'Description 4', + { + 3: 'Réponse 4 Madagas[car]', + 43: 'Réponse 4 Y2[KAR]', + 2: 'Réponse 4 Tcherno[bus]', + 45: 'Réponse 4 [Kar]tier', + 1: 'Réponse 4 [Car]cassonne', + 47: 'Réponse 4 O[car]ina', + 48: 'Réponse 4 Show[bus]', + 49: 'Réponse 4 [Car]ioca' + } + ], + 'Question 5': [ + 'Description 5', + { + 3: 'Réponse 5 Madagas[car]', + 43: 'Réponse 5 Y2[KAR]', + 2: 'Réponse 5 Tcherno[bus]', + 45: 'Réponse 5 [Kar]tier', + 1: 'Réponse 5 [Car]cassonne', + 47: 'Réponse 5 O[car]ina', + 48: 'Réponse 5 Show[bus]', + 49: 'Réponse 5 [Car]ioca' + } + ], + 'Question 6': [ + 'Description 6', + { + 3: 'Réponse 6 Madagas[car]', + 43: 'Réponse 6 Y2[KAR]', + 2: 'Réponse 6 Tcherno[bus]', + 45: 'Réponse 6 [Kar]tier', + 1: 'Réponse 6 [Car]cassonne', + 47: 'Réponse 6 O[car]ina', + 48: 'Réponse 6 Show[bus]', + 49: 'Réponse 6 [Car]ioca' + } + ], + 'Question 7': [ + 'Description 7', + { + 3: 'Réponse 7 Madagas[car]', + 43: 'Réponse 7 Y2[KAR]', + 2: 'Réponse 7 Tcherno[bus]', + 45: 'Réponse 7 [Kar]tier', + 1: 'Réponse 7 [Car]cassonne', + 47: 'Réponse 7 O[car]ina', + 48: 'Réponse 7 Show[bus]', + 49: 'Réponse 7 [Car]ioca' + } + ], + 'Question 8': [ + 'Description 8', + { + 3: 'Réponse 8 Madagas[car]', + 43: 'Réponse 8 Y2[KAR]', + 2: 'Réponse 8 Tcherno[bus]', + 45: 'Réponse 8 [Kar]tier', + 1: 'Réponse 8 [Car]cassonne', + 47: 'Réponse 8 O[car]ina', + 48: 'Réponse 8 Show[bus]', + 49: 'Réponse 8 [Car]ioca' + } + ], + 'Question 9': [ + 'Description 9', + { + 3: 'Réponse 9 Madagas[car]', + 43: 'Réponse 9 Y2[KAR]', + 2: 'Réponse 9 Tcherno[bus]', + 45: 'Réponse 9 [Kar]tier', + 1: 'Réponse 9 [Car]cassonne', + 47: 'Réponse 9 O[car]ina', + 48: 'Réponse 9 Show[bus]', + 49: 'Réponse 9 [Car]ioca' + } + ] + } +} + +NB_WORDS = 5 class WEISurveyForm2025(forms.Form): @@ -32,11 +155,6 @@ class WEISurveyForm2025(forms.Form): 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. @@ -48,34 +166,56 @@ class WEISurveyForm2025(forms.Form): registration._force_save = True registration.save() - if self.data: - self.fields["word"].choices = [(w, w) for w in WORDS] + rng = Random((information.step + 1) * information.seed) + + if information.step == 0: + self.fields["words"] = forms.MultipleChoiceField( + label=_(f"Choose {NB_WORDS} words:"), + choices=[(w, w) for w in WORDS['list']], + widget=forms.CheckboxSelectMultiple(), + required=True, + ) 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), []) + if scores: + average_score = sum(scores) / len(scores) + else: + average_score = 0 - buses = WEISurveyAlgorithm2025.get_buses() - informations = {bus: WEIBusInformation2025(bus) for bus in buses} - scores = sum((list(informations[bus].scores.values()) for bus in buses), []) - if scores: - average_score = sum(scores) / len(scores) + preferred_words = { + bus: [word for word in WORDS['list'] if informations[bus].scores[word] >= average_score] + for bus in buses + } + + 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) + self.fields["words"].choices = [(w, w) for w in all_preferred_words] else: - average_score = 0 + questions = list(WORDS['questions'].items()) + idx = information.step - 1 + if idx < len(questions): + q, (desc, answers) = questions[idx] + choices = [(k, v) for k, v in answers.items()] + rng.shuffle(choices) + self.fields[q] = forms.ChoiceField( + label=desc, + choices=choices, + widget=forms.RadioSelect, + required=True, + ) - 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] + 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): @@ -86,7 +226,7 @@ class WEIBusInformation2025(WEIBusInformation): def __init__(self, bus): self.scores = {} - for word in WORDS: + for word in WORDS['list']: self.scores[word] = 0 super().__init__(bus) @@ -108,7 +248,7 @@ class BusInformationForm2025(forms.ModelForm): except (json.JSONDecodeError, TypeError, AttributeError): initial_scores = {} if words is None: - words = WORDS + words = WORDS['list'] self.words = words choices = [(i, str(i)) for i in range(6)] # [(0, '0'), (1, '1'), ..., (5, '5')] @@ -145,10 +285,26 @@ class WEISurveyInformation2025(WEISurveyInformation): step = 0 def __init__(self, registration): - for i in range(1, 21): + for i in range(1, 5): 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, 5): + 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): """ @@ -174,10 +330,20 @@ class WEISurvey2025(WEISurvey): @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() + 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() + else: + 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() @classmethod def get_algorithm_class(cls): @@ -187,7 +353,7 @@ class WEISurvey2025(WEISurvey): """ The survey is complete once the bus is chosen. """ - return self.information.step == 20 + return self.information.step > len(WORDS['questions']) @classmethod @lru_cache() @@ -206,7 +372,8 @@ class WEISurvey2025(WEISurvey): 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 + - self.word_mean(getattr(self.information, 'word' + str(i))) for i in range(1, 1 + NB_WORDS)) / NB_WORDS + s += sum(1 for q in WORDS['questions'] if getattr(self.information, q) == str(bus.pk)) return s @lru_cache() @@ -243,6 +410,13 @@ class WEISurveyAlgorithm2025(WEISurveyAlgorithm): 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. diff --git a/apps/wei/tables.py b/apps/wei/tables.py index f1d31813..cd493087 100644 --- a/apps/wei/tables.py +++ b/apps/wei/tables.py @@ -71,7 +71,7 @@ class WEIRegistrationTable(tables.Table): 'wei:wei_delete_registration', args=[A('pk')], orderable=False, - verbose_name=_("delete"), + verbose_name=_("Delete"), text=_("Delete"), attrs={ 'th': { diff --git a/apps/wei/templates/wei/weiclub_detail.html b/apps/wei/templates/wei/weiclub_detail.html index 2a573b03..e4b0bfbb 100644 --- a/apps/wei/templates/wei/weiclub_detail.html +++ b/apps/wei/templates/wei/weiclub_detail.html @@ -39,6 +39,11 @@ SPDX-License-Identifier: GPL-3.0-or-later data-turbolinks="false"> {% trans "Update my registration" %} + {% if not not_first_year %} + + {% trans "Restart survey" %} + + {% endif %} {% endif %} {% endif %} diff --git a/apps/wei/tests/test_wei_algorithm_2025.py b/apps/wei/tests/test_wei_algorithm_2025.py index 5930eb3b..4b5c91c4 100644 --- a/apps/wei/tests/test_wei_algorithm_2025.py +++ b/apps/wei/tests/test_wei_algorithm_2025.py @@ -6,7 +6,7 @@ import random from django.contrib.auth.models import User from django.test import TestCase -from ..forms.surveys.wei2025 import WEIBusInformation2025, WEISurvey2025, WORDS, WEISurveyInformation2025 +from ..forms.surveys.wei2025 import WEIBusInformation2025, WEISurvey2025, WORDS, NB_WORDS, WEISurveyInformation2025 from ..models import Bus, WEIClub, WEIRegistration @@ -34,8 +34,8 @@ class TestWEIAlgorithm(TestCase): 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) + for word in WORDS['list']: + information.scores[word] = random.randint(0, 6) information.save() bus.save() @@ -54,7 +54,7 @@ class TestWEIAlgorithm(TestCase): ) information = WEISurveyInformation2025(registration) for j in range(1, 21): - setattr(information, f'word{j}', random.choice(WORDS)) + setattr(information, f'word{j}', random.choice(WORDS['list'])) information.step = 20 information.save(registration) registration.save() @@ -83,9 +83,11 @@ class TestWEIAlgorithm(TestCase): 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 + for j in range(1, 1 + NB_WORDS): + setattr(information, f'word{j}', random.choice(WORDS['list'])) + for q in WORDS['questions']: + setattr(information, q, random.choice(list(WORDS['questions'][q][1].keys()))) + information.step = len(WORDS['questions']) + 1 information.save(registration) registration.save() @@ -106,6 +108,6 @@ class TestWEIAlgorithm(TestCase): max_score = buses[0][1] penalty += (max_score - score) ** 2 - self.assertLessEqual(max_score - score, 25) # Always less than 25 % of tolerance + self.assertLessEqual(max_score - score, 1) # Always less than 25 % of tolerance self.assertLessEqual(penalty / 100, 25) # Tolerance of 5 % diff --git a/apps/wei/views.py b/apps/wei/views.py index bd1f1f5f..3bca3928 100644 --- a/apps/wei/views.py +++ b/apps/wei/views.py @@ -1246,6 +1246,10 @@ class WEISurveyView(LoginRequiredMixin, BaseFormView, DetailView): if not self.survey: self.survey = CurrentSurvey(obj) + + if request.GET.get("reset") == "true": + info = self.survey.information + info.reset(obj) # If the survey is complete, then display the end page. if self.survey.is_complete(): return redirect(reverse_lazy('wei:wei_survey_end', args=(self.survey.registration.pk,)))