1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-07-23 17:26:46 +02:00

Changed score calculation in survey

This commit is contained in:
Ehouarn
2025-07-23 16:48:59 +02:00
parent 296d021d54
commit bfa5734d55
5 changed files with 240 additions and 55 deletions

View File

@ -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.

View File

@ -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': {

View File

@ -39,6 +39,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
data-turbolinks="false">
{% trans "Update my registration" %}
</a>
{% if not not_first_year %}
<a class="btn btn-warning" href="{% url "wei:wei_survey" pk=my_registration.pk %}?reset=true" data-turbolinks="false">
{% trans "Restart survey" %}
</a>
{% endif %}
{% endif %}
</div>
{% endif %}

View File

@ -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 %

View File

@ -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,)))