1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-08-02 21:54:24 +02:00

Compare commits

...

7 Commits

Author SHA1 Message Date
ehouarn
fc0071144e Merge branch 'wei' into 'main'
More robust algorithm

See merge request bde/nk20!337
2025-08-02 17:34:45 +02:00
Ehouarn
573f2d8a22 More robust algorithm 2025-08-02 17:18:51 +02:00
ehouarn
da30382f41 Merge branch 'wei' into 'main'
Soge credit fixed

See merge request bde/nk20!336
2025-08-02 16:50:24 +02:00
Ehouarn
8e98d62b69 Soge credit fixed 2025-08-02 16:31:04 +02:00
ehouarn
3b7f8b87c4 Merge branch 'wei' into 'main'
Wei

See merge request bde/nk20!335
2025-08-01 23:38:46 +02:00
Ehouarn
023fc1db84 Visual fixes 2025-08-01 22:53:15 +02:00
Ehouarn
d50bb2134a Algorithm changed again 2025-08-01 11:56:34 +02:00
8 changed files with 135 additions and 76 deletions

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-08-02 13:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('member', '0014_create_bda'),
]
operations = [
migrations.AlterField(
model_name='profile',
name='promotion',
field=models.PositiveSmallIntegerField(default=2025, help_text='Year of entry to the school (None if not ENS student)', null=True, verbose_name='promotion'),
),
]

View File

@@ -353,13 +353,11 @@ class SogeCredit(models.Model):
def amount(self): def amount(self):
if self.valid: if self.valid:
return self.credit_transaction.total return self.credit_transaction.total
amount = sum(max(transaction.total - 2000, 0) for transaction in self.transactions.all()) amount = 0
if 'wei' in settings.INSTALLED_APPS: transactions_wei = self.transactions.filter(membership__club__weiclub__isnull=False)
from wei.models import WEIMembership amount += sum(max(transaction.total - transaction.membership.club.weiclub.fee_soge_credit, 0) for transaction in transactions_wei)
if not WEIMembership.objects\ transactions_not_wei = self.transactions.filter(membership__club__weiclub__isnull=True)
.filter(club__weiclub__year=self.credit_transaction.created_at.year, user=self.user).exists(): amount += sum(transaction.total for transaction in transactions_not_wei)
# 80 € for people that don't go to WEI
amount += 8000
return amount return amount
def update_transactions(self): def update_transactions(self):
@@ -441,7 +439,7 @@ class SogeCredit(models.Model):
With Great Power Comes Great Responsibility... With Great Power Comes Great Responsibility...
""" """
total_fee = sum(max(transaction.total - 2000, 0) for transaction in self.transactions.all() if not transaction.valid) total_fee = self.amount
if self.user.note.balance < total_fee: if self.user.note.balance < total_fee:
raise ValidationError(_("This user doesn't have enough money to pay the memberships with its note. " raise ValidationError(_("This user doesn't have enough money to pay the memberships with its note. "
"Please ask her/him to credit the note before invalidating this credit.")) "Please ask her/him to credit the note before invalidating this credit."))

View File

@@ -30,117 +30,117 @@ WORDS = {
'Description 1', 'Description 1',
{ {
3: 'Réponse 1 Madagas[car]', 3: 'Réponse 1 Madagas[car]',
43: 'Réponse 1 Y2[KAR]', 4: 'Réponse 1 Y2[KAR]',
2: 'Réponse 1 Tcherno[bus]', 2: 'Réponse 1 Tcherno[bus]',
45: 'Réponse 1 [Kar]tier', 5: 'Réponse 1 [Kar]tier',
1: 'Réponse 1 [Car]cassonne', 1: 'Réponse 1 [Car]cassonne',
47: 'Réponse 1 O[car]ina', 6: 'Réponse 1 O[car]ina',
48: 'Réponse 1 Show[bus]', 7: 'Réponse 1 Show[bus]',
49: 'Réponse 1 [Car]ioca' 8: 'Réponse 1 [Car]ioca'
} }
], ],
'Question 2': [ 'Question 2': [
'Description 2', 'Description 2',
{ {
3: 'Réponse 2 Madagas[car]', 3: 'Réponse 2 Madagas[car]',
43: 'Réponse 2 Y2[KAR]', 4: 'Réponse 2 Y2[KAR]',
2: 'Réponse 2 Tcherno[bus]', 2: 'Réponse 2 Tcherno[bus]',
45: 'Réponse 2 [Kar]tier', 5: 'Réponse 2 [Kar]tier',
1: 'Réponse 2 [Car]cassonne', 1: 'Réponse 2 [Car]cassonne',
47: 'Réponse 2 O[car]ina', 6: 'Réponse 2 O[car]ina',
48: 'Réponse 2 Show[bus]', 7: 'Réponse 2 Show[bus]',
49: 'Réponse 2 [Car]ioca' 8: 'Réponse 2 [Car]ioca'
} }
], ],
'Question 3': [ 'Question 3': [
'Description 3', 'Description 3',
{ {
3: 'Réponse 3 Madagas[car]', 3: 'Réponse 3 Madagas[car]',
43: 'Réponse 3 Y2[KAR]', 4: 'Réponse 3 Y2[KAR]',
2: 'Réponse 3 Tcherno[bus]', 2: 'Réponse 3 Tcherno[bus]',
45: 'Réponse 3 [Kar]tier', 5: 'Réponse 3 [Kar]tier',
1: 'Réponse 3 [Car]cassonne', 1: 'Réponse 3 [Car]cassonne',
47: 'Réponse 3 O[car]ina', 6: 'Réponse 3 O[car]ina',
48: 'Réponse 3 Show[bus]', 7: 'Réponse 3 Show[bus]',
49: 'Réponse 3 [Car]ioca' 8: 'Réponse 3 [Car]ioca'
} }
], ],
'Question 4': [ 'Question 4': [
'Description 4', 'Description 4',
{ {
3: 'Réponse 4 Madagas[car]', 3: 'Réponse 4 Madagas[car]',
43: 'Réponse 4 Y2[KAR]', 4: 'Réponse 4 Y2[KAR]',
2: 'Réponse 4 Tcherno[bus]', 2: 'Réponse 4 Tcherno[bus]',
45: 'Réponse 4 [Kar]tier', 5: 'Réponse 4 [Kar]tier',
1: 'Réponse 4 [Car]cassonne', 1: 'Réponse 4 [Car]cassonne',
47: 'Réponse 4 O[car]ina', 6: 'Réponse 4 O[car]ina',
48: 'Réponse 4 Show[bus]', 7: 'Réponse 4 Show[bus]',
49: 'Réponse 4 [Car]ioca' 8: 'Réponse 4 [Car]ioca'
} }
], ],
'Question 5': [ 'Question 5': [
'Description 5', 'Description 5',
{ {
3: 'Réponse 5 Madagas[car]', 3: 'Réponse 5 Madagas[car]',
43: 'Réponse 5 Y2[KAR]', 4: 'Réponse 5 Y2[KAR]',
2: 'Réponse 5 Tcherno[bus]', 2: 'Réponse 5 Tcherno[bus]',
45: 'Réponse 5 [Kar]tier', 5: 'Réponse 5 [Kar]tier',
1: 'Réponse 5 [Car]cassonne', 1: 'Réponse 5 [Car]cassonne',
47: 'Réponse 5 O[car]ina', 6: 'Réponse 5 O[car]ina',
48: 'Réponse 5 Show[bus]', 7: 'Réponse 5 Show[bus]',
49: 'Réponse 5 [Car]ioca' 8: 'Réponse 5 [Car]ioca'
} }
], ],
'Question 6': [ 'Question 6': [
'Description 6', 'Description 6',
{ {
3: 'Réponse 6 Madagas[car]', 3: 'Réponse 6 Madagas[car]',
43: 'Réponse 6 Y2[KAR]', 4: 'Réponse 6 Y2[KAR]',
2: 'Réponse 6 Tcherno[bus]', 2: 'Réponse 6 Tcherno[bus]',
45: 'Réponse 6 [Kar]tier', 5: 'Réponse 6 [Kar]tier',
1: 'Réponse 6 [Car]cassonne', 1: 'Réponse 6 [Car]cassonne',
47: 'Réponse 6 O[car]ina', 6: 'Réponse 6 O[car]ina',
48: 'Réponse 6 Show[bus]', 7: 'Réponse 6 Show[bus]',
49: 'Réponse 6 [Car]ioca' 8: 'Réponse 6 [Car]ioca'
} }
], ],
'Question 7': [ 'Question 7': [
'Description 7', 'Description 7',
{ {
3: 'Réponse 7 Madagas[car]', 3: 'Réponse 7 Madagas[car]',
43: 'Réponse 7 Y2[KAR]', 4: 'Réponse 7 Y2[KAR]',
2: 'Réponse 7 Tcherno[bus]', 2: 'Réponse 7 Tcherno[bus]',
45: 'Réponse 7 [Kar]tier', 5: 'Réponse 7 [Kar]tier',
1: 'Réponse 7 [Car]cassonne', 1: 'Réponse 7 [Car]cassonne',
47: 'Réponse 7 O[car]ina', 6: 'Réponse 7 O[car]ina',
48: 'Réponse 7 Show[bus]', 7: 'Réponse 7 Show[bus]',
49: 'Réponse 7 [Car]ioca' 8: 'Réponse 7 [Car]ioca'
} }
], ],
'Question 8': [ 'Question 8': [
'Description 8', 'Description 8',
{ {
3: 'Réponse 8 Madagas[car]', 3: 'Réponse 8 Madagas[car]',
43: 'Réponse 8 Y2[KAR]', 4: 'Réponse 8 Y2[KAR]',
2: 'Réponse 8 Tcherno[bus]', 2: 'Réponse 8 Tcherno[bus]',
45: 'Réponse 8 [Kar]tier', 5: 'Réponse 8 [Kar]tier',
1: 'Réponse 8 [Car]cassonne', 1: 'Réponse 8 [Car]cassonne',
47: 'Réponse 8 O[car]ina', 6: 'Réponse 8 O[car]ina',
48: 'Réponse 8 Show[bus]', 7: 'Réponse 8 Show[bus]',
49: 'Réponse 8 [Car]ioca' 8: 'Réponse 8 [Car]ioca'
} }
], ],
'Question 9': [ 'Question 9': [
'Description 9', 'Description 9',
{ {
3: 'Réponse 9 Madagas[car]', 3: 'Réponse 9 Madagas[car]',
43: 'Réponse 9 Y2[KAR]', 4: 'Réponse 9 Y2[KAR]',
2: 'Réponse 9 Tcherno[bus]', 2: 'Réponse 9 Tcherno[bus]',
45: 'Réponse 9 [Kar]tier', 5: 'Réponse 9 [Kar]tier',
1: 'Réponse 9 [Car]cassonne', 1: 'Réponse 9 [Car]cassonne',
47: 'Réponse 9 O[car]ina', 6: 'Réponse 9 O[car]ina',
48: 'Réponse 9 Show[bus]', 7: 'Réponse 9 Show[bus]',
49: 'Réponse 9 [Car]ioca' 8: 'Réponse 9 [Car]ioca'
} }
] ]
} }
@@ -365,25 +365,41 @@ class WEISurvey2025(WEISurvey):
return sum([cls.get_algorithm_class().get_bus_information(bus).scores[word] for bus in buses]) / buses.count() return sum([cls.get_algorithm_class().get_bus_information(bus).scores[word] for bus in buses]) / buses.count()
@lru_cache() @lru_cache()
def score(self, bus): def score_questions(self, bus):
"""
The score given by the answers to the questions
"""
if not self.is_complete():
raise ValueError("Survey is not ended, can't calculate score")
# Score is the given score by the bus subtracted to the mid-score of the buses.
s = sum(1 for q in WORDS['questions'] if getattr(self.information, q) == bus.pk)
return s
@lru_cache()
def score_words(self, bus):
"""
The score given by the choice of words
"""
if not self.is_complete(): if not self.is_complete():
raise ValueError("Survey is not ended, can't calculate score") raise ValueError("Survey is not ended, can't calculate score")
bus_info = self.get_algorithm_class().get_bus_information(bus) 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. # 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))] 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, 1 + NB_WORDS)) / NB_WORDS - self.word_mean(getattr(self.information, 'word' + str(i))) for i in range(1, 1 + NB_WORDS))
s += sum(1 for q in WORDS['questions'] if getattr(self.information, q) == str(bus.pk))
return s return s
@lru_cache() @lru_cache()
def scores_per_bus(self): def scores_per_bus(self):
return {bus: self.score(bus) for bus in self.get_algorithm_class().get_buses()} return {bus: (self.score_questions(bus), self.score_words(bus)) for bus in self.get_algorithm_class().get_buses()}
@lru_cache() @lru_cache()
def ordered_buses(self): def ordered_buses(self):
"""
Force the choice of bus to be in the 3 preferred buses according to the words
"""
values = list(self.scores_per_bus().items()) values = list(self.scores_per_bus().items())
values.sort(key=lambda item: -item[1]) values.sort(key=lambda item: -item[1][1])
return values return values
@classmethod @classmethod
@@ -421,6 +437,7 @@ class WEISurveyAlgorithm2025(WEISurveyAlgorithm):
""" """
Gale-Shapley algorithm implementation. Gale-Shapley algorithm implementation.
We modify it to allow buses to have multiple "weddings". We modify it to allow buses to have multiple "weddings".
We use lexigographical order on both scores
""" """
surveys = list(self.get_survey_class()(r) for r in self.get_registrations()) # All surveys 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 surveys = [s for s in surveys if s.is_complete()] # Don't consider invalid surveys
@@ -481,7 +498,7 @@ class WEISurveyAlgorithm2025(WEISurveyAlgorithm):
while free_surveys: # Some students are not affected while free_surveys: # Some students are not affected
survey = free_surveys[0] survey = free_surveys[0]
buses = survey.ordered_buses() # Preferences of the student buses = survey.ordered_buses() # Preferences of the student
for bus, current_score in buses: for bus, current_scores in buses:
if self.get_bus_information(bus).has_free_seats(surveys, quotas): if self.get_bus_information(bus).has_free_seats(surveys, quotas):
# Selected bus has free places. Put student in the bus # Selected bus has free places. Put student in the bus
survey.select_bus(bus) survey.select_bus(bus)
@@ -496,8 +513,8 @@ class WEISurveyAlgorithm2025(WEISurveyAlgorithm):
for survey2 in surveys: for survey2 in surveys:
if not survey2.information.valid or survey2.information.get_selected_bus() != bus: if not survey2.information.valid or survey2.information.get_selected_bus() != bus:
continue continue
score2 = survey2.score(bus) score2 = survey2.score_questions(bus)
if current_score <= score2: # Ignore better students if current_scores[0] <= score2: # Ignore better students
continue continue
if least_preferred_survey is None or score2 < least_score: if least_preferred_survey is None or score2 < least_score:
least_preferred_survey = survey2 least_preferred_survey = survey2

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-08-02 13:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wei', '0016_weiregistration_fee_alter_weiclub_fee_soge_credit'),
]
operations = [
migrations.AlterField(
model_name='weiclub',
name='fee_soge_credit',
field=models.PositiveIntegerField(default=0, verbose_name='membership fee (soge credit)'),
),
]

View File

@@ -40,7 +40,7 @@ class WEIClub(Club):
fee_soge_credit = models.PositiveIntegerField( fee_soge_credit = models.PositiveIntegerField(
verbose_name=_("membership fee (soge credit)"), verbose_name=_("membership fee (soge credit)"),
default=2000, default=0,
) )
class Meta: class Meta:

View File

@@ -143,7 +143,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endblocktrans %} {% endblocktrans %}
</div> </div>
{% endif %} {% endif %}
<div class="alert {% if registration.user.note.balance < fee %}alert-danger{% else %}alert-success{% endif %}"> <div class="alert {% if registration.validation_status == 2 %}alert-danger{% else %}alert-success{% endif %}">
<h5>{% trans "Required payments:" %}</h5> <h5>{% trans "Required payments:" %}</h5>
<ul> <ul>
<li>{% blocktrans trimmed with amount=fee|pretty_money %} <li>{% blocktrans trimmed with amount=fee|pretty_money %}

View File

@@ -30,7 +30,7 @@ class TestWEIAlgorithm(TestCase):
) )
self.buses = [] self.buses = []
for i in range(10): for i in range(8):
bus = Bus.objects.create(wei=self.wei, name=f"Bus {i}", size=10) bus = Bus.objects.create(wei=self.wei, name=f"Bus {i}", size=10)
self.buses.append(bus) self.buses.append(bus)
information = WEIBusInformation2025(bus) information = WEIBusInformation2025(bus)
@@ -74,7 +74,7 @@ class TestWEIAlgorithm(TestCase):
Buses are full of first year people, ensure that they are happy Buses are full of first year people, ensure that they are happy
""" """
# Add a lot of users # Add a lot of users
for i in range(95): for i in range(80):
user = User.objects.create(username=f"user{i}") user = User.objects.create(username=f"user{i}")
registration = WEIRegistration.objects.create( registration = WEIRegistration.objects.create(
user=user, user=user,
@@ -90,6 +90,7 @@ class TestWEIAlgorithm(TestCase):
information.step = len(WORDS['questions']) + 1 information.step = len(WORDS['questions']) + 1
information.save(registration) information.save(registration)
registration.save() registration.save()
survey = WEISurvey2025(registration)
# Run algorithm # Run algorithm
WEISurvey2025.get_algorithm_class()().run_algorithm() WEISurvey2025.get_algorithm_class()().run_algorithm()
@@ -104,10 +105,11 @@ class TestWEIAlgorithm(TestCase):
survey = WEISurvey2025(r) survey = WEISurvey2025(r)
chosen_bus = survey.information.get_selected_bus() chosen_bus = survey.information.get_selected_bus()
buses = survey.ordered_buses() buses = survey.ordered_buses()
score = min(v for bus, v in buses if bus == chosen_bus) self.assertIn(chosen_bus, [x[0] for x in buses])
max_score = buses[0][1] score = min(v for bus, (v, __) in buses if bus == chosen_bus)
max_score = buses[0][1][0]
penalty += (max_score - score) ** 2 penalty += (max_score - score) ** 2
self.assertLessEqual(max_score - score, 1) # Always less than 25 % of tolerance self.assertLessEqual(max_score - score, 1)
self.assertLessEqual(penalty / 100, 25) # Tolerance of 5 % self.assertLessEqual(penalty / 100, 25) # Tolerance of 5 %

View File

@@ -798,11 +798,6 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
choose_bus_form.fields["team"].queryset = BusTeam.objects.filter(bus__wei=context["club"]) choose_bus_form.fields["team"].queryset = BusTeam.objects.filter(bus__wei=context["club"])
context["membership_form"] = choose_bus_form context["membership_form"] = choose_bus_form
if not self.object.soge_credit and self.object.user.profile.soge:
form = context["form"]
form.fields["soge_credit"].disabled = True
form.fields["soge_credit"].help_text = _("You already opened an account in the Société générale.")
return context return context
def get_form(self, form_class=None): def get_form(self, form_class=None):
@@ -823,6 +818,10 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
form.fields["deposit_type"].required = True form.fields["deposit_type"].required = True
form.fields["deposit_type"].help_text = _("Choose how you want to pay the deposit") form.fields["deposit_type"].help_text = _("Choose how you want to pay the deposit")
if self.object.user.profile.soge:
form.fields["soge_credit"].disabled = True
form.fields["soge_credit"].help_text = _("You already opened an account in the Société générale.")
return form return form
def get_membership_form(self, data=None, instance=None): def get_membership_form(self, data=None, instance=None):
@@ -1125,16 +1124,16 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
'credit': credit_amount, 'credit': credit_amount,
'needed': total_needed} 'needed': total_needed}
) )
return super().form_invalid(form) return self.form_invalid(form)
if credit_amount: if credit_amount:
if not last_name: if not last_name:
form.add_error('last_name', _("This field is required.")) form.add_error('last_name', _("This field is required."))
return super().form_invalid(form) return self.form_invalid(form)
if not first_name: if not first_name:
form.add_error('first_name', _("This field is required.")) form.add_error('first_name', _("This field is required."))
return super().form_invalid(form) return self.form_invalid(form)
# Credit note before adding the membership # Credit note before adding the membership
SpecialTransaction.objects.create( SpecialTransaction.objects.create(
@@ -1178,6 +1177,13 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
return super().form_valid(form) return super().form_valid(form)
def form_invalid(self, form):
registration = getattr(form.instance, "registration", None)
if registration is not None:
registration.deposit_check = False
registration.save()
return super().form_invalid(form)
def get_success_url(self): def get_success_url(self):
self.object.refresh_from_db() self.object.refresh_from_db()
return reverse_lazy("wei:wei_registrations", kwargs={"pk": self.object.club.pk}) return reverse_lazy("wei:wei_registrations", kwargs={"pk": self.object.club.pk})