1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-08-03 06:03:58 +02:00

Compare commits

...

6 Commits

Author SHA1 Message Date
aeltheos
141260827f Merge branch 'nix-shell' into 'main'
Nix shell

See merge request bde/nk20!201
2025-08-02 03:12:17 +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
Yoann Beaugnon
dde1baa25c typo 2022-08-21 19:50:53 +02:00
Yoann Beaugnon
7a7ee47e0b Add two shell.nix to enable easier development on nixos. 2022-08-21 19:46:11 +02:00
7 changed files with 147 additions and 64 deletions

1
.gitignore vendored
View File

@@ -48,7 +48,6 @@ backups/
env/ env/
venv/ venv/
db.sqlite3 db.sqlite3
shell.nix
# ansibles customs host # ansibles customs host
ansible/host_vars/*.yaml ansible/host_vars/*.yaml

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'
} }
] ]
} }
@@ -366,24 +366,41 @@ class WEISurvey2025(WEISurvey):
@lru_cache() @lru_cache()
def score(self, bus): def score(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(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])
values = values[:3]
return values return values
@classmethod @classmethod
@@ -421,6 +438,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 +499,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)
@@ -491,17 +509,17 @@ class WEISurveyAlgorithm2025(WEISurveyAlgorithm):
else: else:
# Current bus has not enough places. Remove the least preferred student from the bus if existing # Current bus has not enough places. Remove the least preferred student from the bus if existing
least_preferred_survey = None least_preferred_survey = None
least_score = -1 least_scores = (-1, -1)
# Find the least student in the bus that has a lower score than the current student # Find the least student in the bus that has a lower score than the current student
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) scores2 = survey2.score(bus), survey2.score_words(bus)
if current_score <= score2: # Ignore better students if current_scores <= scores2: # Ignore better students
continue continue
if least_preferred_survey is None or score2 < least_score: if least_preferred_survey is None or scores2 < least_scores:
least_preferred_survey = survey2 least_preferred_survey = survey2
least_score = score2 least_scores = scores2
if least_preferred_survey is not None: if least_preferred_survey is not None:
# Remove the least student from the bus and put the current student in. # Remove the least student from the bus and put the current student in.

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,8 +105,9 @@ 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) # Always less than 25 % of tolerance

View File

@@ -1125,16 +1125,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 +1178,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})

34
shell-static.nix Executable file
View File

@@ -0,0 +1,34 @@
# This is a workaround meant for use with the nix package manager. If you don't know what it is or don't use it, please ignore this file.
#
# The nk20 javascript static location are hardcoded for imperative system.
# This make ./manage.py collectstatic hard to use with nixos.
#
# A workaround is to enter a FHSUserEnv with the static placed under /share/javascript/<static>.
# This emulate a debian like system and enable collecting static normally with ./manage.py collectstatics.
# The regular shell.nix should be enough for other configurations.
#
# Warning, you are still supposed to use pip package with a venv !
{ pkgs ? import <nixpkgs> {} }:
(pkgs.buildFHSUserEnv {
name = "pipzone";
targetPkgs = pkgs: (with pkgs;
let
fhs-static = stdenv.mkDerivation {
name = "fhs-static";
buildCommand = ''
mkdir -p $out/share/javascript/bootstrap4
mkdir -p $out/share/javascript/jquery
ln -s ${python39Packages.xstatic-bootstrap}/lib/python3.9/site-packages/xstatic/pkg/bootstrap/data/* $out/share/javascript/bootstrap4
ln -s ${python39Packages.xstatic-jquery}/lib/python3.9/site-packages/xstatic/pkg/jquery/data/* $out/share/javascript/jquery
'';
};
in [
fhs-static
python39
gettext
python39Packages.pip
python39Packages.virtualenv
python39Packages.setuptools
]);
runScript = "bash";
}).env

23
shell.nix Executable file
View File

@@ -0,0 +1,23 @@
# This is meant for use with the nix package manager. If you don't know what it is or don't use it, please ignore this file.
#
# This shell.nix contains all dependencies require to create a venv and pip install -r requirements.txt.
#
# Please check shell-static.nix for running ./manage.py collectstatics.
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = with pkgs; [
python39
python39Packages.pip
python39Packages.setuptools
gettext
];
shellHook = ''
# Tells pip to put packages into $PIP_PREFIX instead of the usual locations.
# See https://pip.pypa.io/en/stable/user_guide/#environment-variables.
export PIP_PREFIX=$(pwd)/_build/pip_packages
export PYTHONPATH="$PIP_PREFIX/${pkgs.python39.sitePackages}:$PYTHONPATH"
export PATH="$PIP_PREFIX/bin:$PATH"
unset SOURCE_DATE_EPOCH
'';
}