diff --git a/apps/wei/forms/registration.py b/apps/wei/forms/registration.py index fd6fea2c..455d77ca 100644 --- a/apps/wei/forms/registration.py +++ b/apps/wei/forms/registration.py @@ -5,7 +5,7 @@ from bootstrap_datepicker_plus.widgets import DatePickerInput from django import forms from django.contrib.auth.models import User from django.db.models import Q -from django.forms import CheckboxSelectMultiple +from django.forms import CheckboxSelectMultiple, RadioSelect from django.utils.translation import gettext_lazy as _ from note.models import NoteSpecial, NoteUser from note_kfet.inputs import AmountInput, Autocomplete, ColorWidget @@ -140,6 +140,19 @@ class WEIMembershipForm(forms.ModelForm): required=False, ) + def __init__(self, *args, wei=None, **kwargs): + super().__init__(*args, **kwargs) + if 'bus' in self.fields: + if wei is not None: + self.fields['bus'].queryset = Bus.objects.filter(wei=wei) + else: + self.fields['bus'].queryset = Bus.objects.none() + if 'team' in self.fields: + if wei is not None: + self.fields['team'].queryset = BusTeam.objects.filter(bus__wei=wei) + else: + self.fields['team'].queryset = BusTeam.objects.none() + def clean(self): cleaned_data = super().clean() if 'team' in cleaned_data and cleaned_data["team"] is not None \ @@ -151,21 +164,8 @@ class WEIMembershipForm(forms.ModelForm): model = WEIMembership fields = ('roles', 'bus', 'team',) widgets = { - "bus": Autocomplete( - Bus, - attrs={ - 'api_url': '/api/wei/bus/', - 'placeholder': 'Bus ...', - } - ), - "team": Autocomplete( - BusTeam, - attrs={ - 'api_url': '/api/wei/team/', - 'placeholder': 'Équipe ...', - }, - resetable=True, - ), + "bus": RadioSelect(), + "team": RadioSelect(), } @@ -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..c2fde39d 100644 --- a/apps/wei/forms/surveys/base.py +++ b/apps/wei/forms/surveys/base.py @@ -121,6 +121,13 @@ 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..d92cc23f --- /dev/null +++ b/apps/wei/forms/surveys/wei2025.py @@ -0,0 +1,347 @@ +# 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), []) + 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 + 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/templates/wei/weimembership_form.html b/apps/wei/templates/wei/weimembership_form.html index f0f3d800..a9c85d5d 100644 --- a/apps/wei/templates/wei/weimembership_form.html +++ b/apps/wei/templates/wei/weimembership_form.html @@ -210,4 +210,27 @@ SPDX-License-Identifier: GPL-3.0-or-later } } + {% endblock %} 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/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..bfc7c616 100644 --- a/apps/wei/views.py +++ b/apps/wei/views.py @@ -788,7 +788,8 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update return form def get_membership_form(self, data=None, instance=None): - membership_form = WEIMembershipForm(data if data else None, instance=instance) + registration = self.get_object() + membership_form = WEIMembershipForm(data if data else None, instance=instance, wei=registration.wei) del membership_form.fields["credit_type"] del membership_form.fields["credit_amount"] del membership_form.fields["first_name"] @@ -969,6 +970,13 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView): return WEIMembership1AForm return WEIMembershipForm + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + registration = WEIRegistration.objects.get(pk=self.kwargs["pk"]) + wei = registration.wei + kwargs['wei'] = wei + return kwargs + def get_form(self, form_class=None): form = super().get_form(form_class) registration = WEIRegistration.objects.get(pk=self.kwargs["pk"]) @@ -1422,3 +1430,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}) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 70aa20ce..9832c986 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-06-20 13:50+0200\n" +"POT-Creation-Date: 2025-06-27 19:15+0200\n" "PO-Revision-Date: 2022-04-11 22:05+0200\n" "Last-Translator: bleizi \n" "Language-Team: French \n" @@ -3046,9 +3046,14 @@ msgid "This team doesn't belong to the given bus." msgstr "Cette équipe n'appartient pas à ce bus." #: apps/wei/forms/surveys/wei2021.py:35 apps/wei/forms/surveys/wei2022.py:38 +#: apps/wei/forms/surveys/wei2025.py:36 msgid "Choose a word:" msgstr "Choisissez un mot :" +#: apps/wei/forms/surveys/wei2025.py:123 +msgid "Rate between 0 and 5." +msgstr "Note entre 0 et 5." + #: apps/wei/models.py:25 apps/wei/templates/wei/base.html:36 msgid "year" msgstr "année" @@ -3250,7 +3255,7 @@ msgstr "Année" msgid "preferred bus" msgstr "bus préféré" -#: apps/wei/tables.py:210 apps/wei/templates/wei/bus_detail.html:36 +#: apps/wei/tables.py:210 apps/wei/templates/wei/bus_detail.html:38 #: apps/wei/templates/wei/busteam_detail.html:52 msgid "Teams" msgstr "Équipes" @@ -3344,18 +3349,23 @@ msgstr "Voir le WEI" #: apps/wei/templates/wei/bus_detail.html:21 msgid "View club" -msgstr "Voir le lub" +msgstr "Voir le club" #: apps/wei/templates/wei/bus_detail.html:26 +#| msgid "survey information" +msgid "Edit information" +msgstr "Modifier les informations" + +#: apps/wei/templates/wei/bus_detail.html:28 #: apps/wei/templates/wei/busteam_detail.html:24 msgid "Add team" msgstr "Ajouter une équipe" -#: apps/wei/templates/wei/bus_detail.html:49 +#: apps/wei/templates/wei/bus_detail.html:51 msgid "Members" msgstr "Membres" -#: apps/wei/templates/wei/bus_detail.html:58 +#: apps/wei/templates/wei/bus_detail.html:60 #: apps/wei/templates/wei/busteam_detail.html:62 #: apps/wei/templates/wei/weimembership_list.html:31 msgid "View as PDF" @@ -3708,11 +3718,11 @@ msgstr "montant total" msgid "Attribute buses to first year members" msgstr "Répartir les 1A dans les bus" -#: apps/wei/views.py:1379 +#: apps/wei/views.py:1380 msgid "Attribute bus" msgstr "Attribuer un bus" -#: apps/wei/views.py:1419 +#: apps/wei/views.py:1420 msgid "" "No first year student without a bus found. Either all of them have a bus, or " "none has filled the survey yet."