1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-08-13 17:39:36 +02:00

Compare commits

...

44 Commits

Author SHA1 Message Date
Ehouarn
61057b71ba Fuzzy translations 2025-08-13 16:42:31 +02:00
Ehouarn
80f28aa771 No hard coded phone number in template 2025-08-13 16:02:59 +02:00
Ehouarn
f13a44702a Permmissions 2025-08-13 15:21:27 +02:00
Ehouarn
85b857976a Merge branch 'main' into family 2025-08-12 23:57:14 +02:00
ikea
0c259155a8 Translation for family 2025-08-12 23:01:06 +02:00
Ehouarn
4a9b7c1312 Phone number link 2025-08-11 19:09:30 +02:00
Ehouarn
74f9c53c18 Visual improvement on manage page 2025-08-09 15:38:02 +02:00
ikea
b10b2fb3b6 Rajout du lien vers la page user dans table 2025-08-08 16:44:37 +02:00
ikea
4fa8ef4b56 Ajout de la pop-up de validation de défi 2025-08-06 08:20:43 +02:00
ehouarn
68e5f280b4 Merge branch 'wei' into 'main'
Signals used to ignore _no_signal

See merge request bde/nk20!340
2025-08-03 21:36:41 +02:00
Ehouarn
251bb933da Signals used to ignore _no_signal 2025-08-03 21:19:44 +02:00
ehouarn
4fbbfd2365 Merge branch 'translations' into 'main'
French translations for WEI

See merge request bde/nk20!339
2025-08-03 13:04:14 +02:00
Ehouarn
0ac719b1f6 French translations for WEI 2025-08-03 12:47:22 +02:00
ehouarn
e55a6ae407 Merge branch 'wei' into 'main'
Wei

See merge request bde/nk20!338
2025-08-03 01:38:27 +02:00
Ehouarn
59a502d624 Added column deposit_type to MembershipsTable 2025-08-03 01:02:06 +02:00
Ehouarn
312ab6dac4 Permissions 2025-08-03 00:41:10 +02:00
Ehouarn
cf53b480db Minor fix 2025-08-02 23:42:04 +02:00
Ehouarn
d1aa1edd09 Deposit check logic changed 2025-08-02 23:32:13 +02:00
Ehouarn
d6f9a9c5b0 Better test 2025-08-02 18:35:53 +02:00
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
ehouarn
0992a8a7ee Merge branch 'wei' into 'main'
Wei

See merge request bde/nk20!334
2025-07-24 15:39:51 +02:00
Ehouarn
97597eb103 Fixed 1A forms 2025-07-24 12:26:44 +02:00
Ehouarn
bfa5734d55 Changed score calculation in survey 2025-07-23 16:48:59 +02:00
Ehouarn
296d021d54 Permissions 2025-07-23 01:24:59 +02:00
Ehouarn
6e348b995b Better Membership update 2025-07-23 00:51:03 +02:00
quark
12477b33cb Merge branch 'fix_activity_form' into 'main'
fix organizer field error

See merge request bde/nk20!333
2025-07-22 18:55:27 +02:00
Ehouarn
adc925e4b1 Tests 2025-07-22 18:31:55 +02:00
quark
8c3ae338ea fix organizer field error 2025-07-22 18:20:05 +02:00
Ehouarn
c66cc14576 Added valid field and logic for Achievement 2025-07-22 01:30:47 +02:00
ikea
db4d0dd83a fix 2025-07-21 22:11:20 +02:00
Ehouarn
1274315cde Last untranslated field 2025-07-19 18:55:49 +02:00
ehouarn
4975c1ab6f Merge branch 'translations' into 'main'
Translations

See merge request bde/nk20!332
2025-07-19 18:29:18 +02:00
Ehouarn
61999a31a5 Wei details 2025-07-19 18:04:14 +02:00
ehouarn
b217f7ceec Merge branch 'wei' into 'main'
Wei

See merge request bde/nk20!331
2025-07-19 17:27:20 +02:00
Ehouarn
2755a5f7ab Minor fail 2025-07-19 17:10:25 +02:00
Ehouarn
9ab4df94e6 Minor fixes 2025-07-19 16:55:07 +02:00
Ehouarn
edb6abfff5 Add fee field to WEIRegistration to be able to sort on validation status 2025-07-19 16:24:25 +02:00
Ehouarn
03c1bb41b6 First of many 2025-07-18 23:49:34 +02:00
45 changed files with 2630 additions and 661 deletions

View File

@@ -32,7 +32,7 @@ class ActivityForm(forms.ModelForm):
def clean_organizer(self):
organizer = self.cleaned_data['organizer']
if not organizer.note.is_active:
self.add_error('organiser', _('The note of this club is inactive.'))
self.add_error('organizer', _('The note of this club is inactive.'))
return organizer
def clean_date_end(self):

View File

@@ -3,7 +3,7 @@
from api.viewsets import ReadProtectedModelViewSet
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter
from api.filters import RegexSafeSearchFilter
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
@@ -21,9 +21,9 @@ class FamilyViewSet(ReadProtectedModelViewSet):
"""
queryset = Family.objects.order_by('id')
serializer_class = FamilySerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['name', ]
search_fields = ['$name', ]
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', 'description', 'score', 'rank', ]
search_fields = ['$name', '$description', ]
class FamilyMembershipViewSet(ReadProtectedModelViewSet):
@@ -34,9 +34,11 @@ class FamilyMembershipViewSet(ReadProtectedModelViewSet):
"""
queryset = FamilyMembership.objects.order_by('id')
serializer_class = FamilyMembershipSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['name', ]
search_fields = ['$name', ]
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['user__username', 'user__first_name', 'user__last_name', 'user__email', 'user__note__alias__name',
'user__note__alias__normalized_name', 'family__name', 'family__description', 'year', ]
search_fields = ['$user__username', '$user__first_name', '$user__last_name', '$user__email', '$user__note__alias__name',
'$user__note__alias__normalized_name', '$family__name', '$family__description', '$year', ]
class ChallengeViewSet(ReadProtectedModelViewSet):
@@ -47,9 +49,9 @@ class ChallengeViewSet(ReadProtectedModelViewSet):
"""
queryset = Challenge.objects.order_by('id')
serializer_class = ChallengeSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['name', ]
search_fields = ['$name', ]
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', 'description', 'points', ]
search_fields = ['$name', '$description', '$points', ]
class AchievementViewSet(ReadProtectedModelViewSet):
@@ -60,22 +62,19 @@ class AchievementViewSet(ReadProtectedModelViewSet):
"""
queryset = Achievement.objects.order_by('id')
serializer_class = AchievementSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['name', ]
search_fields = ['$name', ]
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['family__name', 'family__description', 'challenge__name', 'challenge__description', 'obtained_at', 'valid', ]
search_fields = ['$family__name', '$family__description', '$challenge__name', '$challenge__description', ]
class BatchAchievementsAPIView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request, format=None):
print("POST de la view spéciale")
family_ids = request.data.get('families', [])
challenge_ids = request.data.get('challenges', [])
family_ids = request.data.get('families')
challenge_ids = request.data.get('challenges')
families = Family.objects.filter(id__in=family_ids)
challenges = Challenge.objects.filter(id__in=challenge_ids)
for family in families:
for challenge in challenges:
a = Achievement(family=family, challenge=challenge)

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.2.4 on 2025-07-21 21:02
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('family', '0002_family_display_image'),
]
operations = [
migrations.AddField(
model_name='achievement',
name='valid',
field=models.BooleanField(default=False, verbose_name='valid'),
),
migrations.AlterField(
model_name='familymembership',
name='family',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to='family.family', verbose_name='family'),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.2.4 on 2025-07-22 14:33
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('family', '0003_achievement_valid_alter_familymembership_family'),
]
operations = [
migrations.RemoveField(
model_name='challenge',
name='obtained',
),
]

View File

@@ -4,6 +4,7 @@
from django.db import models, transaction
from django.utils import timezone
from django.contrib.auth.models import User
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
@@ -44,10 +45,13 @@ class Family(models.Model):
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse_lazy('family:family_detail', args=(self.pk,))
def update_score(self, *args, **kwargs):
challenge_set = Challenge.objects.select_for_update().filter(achievement__family=self)
challenge_set = Challenge.objects.select_for_update().filter(achievement__family=self, achievement__valid=True)
points_sum = challenge_set.aggregate(models.Sum("points"))
self.score = points_sum["points__sum"]
self.score = points_sum["points__sum"] if points_sum["points__sum"] else 0
self.save()
self.update_ranking()
@@ -86,7 +90,7 @@ class FamilyMembership(models.Model):
family = models.ForeignKey(
Family,
on_delete=models.PROTECT,
related_name=_('members'),
related_name=_('memberships'),
verbose_name=_('family'),
)
@@ -119,10 +123,16 @@ class Challenge(models.Model):
verbose_name=_('points'),
)
obtained = models.PositiveIntegerField(
verbose_name=_('obtained'),
default=0,
)
@property
def obtained(self):
achievements = Achievement.objects.filter(challenge=self, valid=True)
return achievements.count()
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse_lazy('family:challenge_detail', args=(self.pk,))
@transaction.atomic
def save(self, *args, **kwargs):
@@ -136,9 +146,6 @@ class Challenge(models.Model):
verbose_name = _('challenge')
verbose_name_plural = _('challenges')
def __str__(self):
return self.name
class Achievement(models.Model):
challenge = models.ForeignKey(
@@ -157,6 +164,11 @@ class Achievement(models.Model):
default=timezone.now,
)
valid = models.BooleanField(
verbose_name=_('valid'),
default=False,
)
class Meta:
verbose_name = _('achievement')
verbose_name_plural = _('achievements')
@@ -171,7 +183,6 @@ class Achievement(models.Model):
"""
self.family = Family.objects.select_for_update().get(pk=self.family_id)
self.challenge = Challenge.objects.select_for_update().get(pk=self.challenge_id)
is_new = self.pk is None
super().save(*args, **kwargs)
@@ -179,13 +190,6 @@ class Achievement(models.Model):
self.family.refresh_from_db()
self.family.update_score()
# Count only when getting a new achievement
if is_new:
self.challenge.refresh_from_db()
self.challenge.obtained += 1
self.challenge._force_save = True
self.challenge.save()
@transaction.atomic
def delete(self, *args, **kwargs):
"""
@@ -200,8 +204,3 @@ class Achievement(models.Model):
# Remove points from the family
self.family.refresh_from_db()
self.family.update_score()
self.challenge.refresh_from_db()
self.challenge.obtained -= 1
self.challenge._force_save = True
self.challenge.save()

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -57,7 +57,6 @@ function addChallenge (id, name, amount) {
/** Ajout de 1 à chaque clic d'un bouton déjà choisi */
buttons.forEach(function (b) {
if (b.id === id) {
b.quantity += 1
challenge = b
}
})
@@ -65,8 +64,6 @@ function addChallenge (id, name, amount) {
challenge = {
id: id,
name: name,
quantity: 1,
amount: amount,
}
buttons.push(challenge)
}
@@ -76,8 +73,7 @@ function addChallenge (id, name, amount) {
const list = 'consos_list'
let html = ''
buttons.forEach(function (challenge) {
html += li('conso_button_' + challenge.id, challenge.name +
'<span class="badge badge-dark badge-pill">' + challenge.quantity + '</span>')
html += li('conso_button_' + challenge.id, challenge.name)
})
document.getElementById(list).innerHTML = html
@@ -94,7 +90,6 @@ function addChallenge (id, name, amount) {
* Reset the page as its initial state.
*/
function reset () {
console.log("reset lancée")
notes_display.length = 0
notes.length = 0
buttons.length = 0
@@ -130,7 +125,6 @@ function consumeAll () {
LOCK = false
return
}
// Récupérer les IDs des familles et des challenges
const family_ids = notes_display.map(fam => fam.id)
const challenge_ids = buttons.map(chal => chal.id)
@@ -157,34 +151,6 @@ function consumeAll () {
})
}
/**
* Create a new achievement through the API.
* @param family The selected family
* @param challenge The selected challenge
*/
function grantAchievement (family, challenge) {
console.log("grant lancée",family,challenge)
$.post('/api/family/achievement/',
{
csrfmiddlewaretoken: CSRF_TOKEN,
family: family.id,
challenge: challenge.id,
})
.done(function () {
reset()
addMsg("Défi validé pour la famille!", 'success', 5000)
})
.fail(function (e) {
reset()
if (e.responseJSON) {
errMsg(e.responseJSON)
} else if (e.responseText) {
errMsg(e.responseText)
} else {
errMsg("Erreur inconnue lors de la création de l'achievement.")
}
})
}
var searchbar = document.getElementById("search-input")
var search_results = document.getElementById("search-results")
@@ -264,7 +230,6 @@ function li (id, text, extra_css) {
*/
function autoCompleteFamily(field_id, family_list_id, families, families_display, family_prefix = 'family', user_family_field = null, profile_pic_field = null, family_click = null) {
const field = $('#' + field_id)
console.log("autoCompleteFamily commence")
// Configuration du tooltip
field.tooltip({
html: true,
@@ -344,7 +309,6 @@ function autoCompleteFamily(field_id, family_list_id, families, families_display
var disp = null
families_display.forEach(function (d) {
if (d.id === family.id) {
d.quantity += 1
disp = d
}
})
@@ -353,7 +317,6 @@ function autoCompleteFamily(field_id, family_list_id, families, families_display
name: family.name,
id: family.id,
family: family,
quantity: 1
}
families_display.push(disp)
}
@@ -364,9 +327,7 @@ function autoCompleteFamily(field_id, family_list_id, families, families_display
let html = ''
families_display.forEach(function (disp) {
html += li(family_prefix + '_' + disp.id,
disp.name +
'<span class="badge badge-dark badge-pill">' +
disp.quantity + '</span>',
disp.name,
'')
})
@@ -424,12 +385,6 @@ function removeFamily(d, family_prefix, families_display, family_list_id, user_f
const new_families_display = []
let html = ''
families_display.forEach(function (disp) {
if (disp.quantity > 1 || disp.id !== d.id) {
disp.quantity -= disp.id === d.id ? 1 : 0
new_families_display.push(disp)
html += li(family_prefix + '_' + disp.id, disp.name +
'<span class="badge badge-dark badge-pill">' + disp.quantity + '</span>')
}
})
families_display.length = 0

View File

@@ -2,17 +2,23 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import django_tables2 as tables
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django_tables2 import A
from django.urls import reverse, reverse_lazy
from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend
from .models import Family, Challenge, FamilyMembership, Achievement
from .models import Achievement, Challenge, Family, FamilyMembership
class FamilyTable(tables.Table):
"""
List all families
"""
description = tables.Column(verbose_name=_("Description"))
class Meta:
attrs = {
'class': 'table table-condensed table-striped table-hover'
@@ -32,6 +38,11 @@ class ChallengeTable(tables.Table):
"""
List all challenges
"""
name = tables.Column(verbose_name=_("Name"))
description = tables.Column(verbose_name=_("Description"))
points = tables.Column(verbose_name=_("Points"))
class Meta:
attrs = {
'class': 'table table-condensed table-striped table-hover'
@@ -51,6 +62,15 @@ class FamilyMembershipTable(tables.Table):
"""
List all family memberships.
"""
def render_user(self, value):
# Display user's name, clickable if permission is granted
s = value.username
if PermissionBackend.check_perm(get_current_request(), "auth.view_user", value):
s = format_html("<a href='{url}'>{name}</a>",
url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s)
return s
class Meta:
attrs = {
'class': 'table table-condensed table-striped',
@@ -65,6 +85,26 @@ class AchievementTable(tables.Table):
"""
List recent achievements.
"""
challenge = tables.Column(verbose_name=_("Challenge"))
validate = tables.LinkColumn(
'family:achievement_validate',
args=[A('id')],
verbose_name=_("Validate"),
text=_("Validate"),
orderable=False,
attrs={
'th': {
'id': 'validate-achievement-header'
},
'a': {
'class': 'btn btn-success',
'data-type': 'validate-achievement'
}
},
)
delete = tables.LinkColumn(
'family:achievement_delete',
args=[A('id')],
@@ -73,11 +113,11 @@ class AchievementTable(tables.Table):
orderable=False,
attrs={
'th': {
'id': 'delete-membership-header'
'id': 'delete-achievement-header'
},
'a': {
'class': 'btn btn-danger',
'data-type': 'delete-membership'
'data-type': 'delete-achievement'
}
},
)
@@ -87,18 +127,22 @@ class AchievementTable(tables.Table):
'class': 'table table-condensed table-striped table-hover'
}
model = Achievement
fields = ('family', 'challenge', 'challenge__points', 'obtained_at', )
fields = ('family', 'challenge', 'challenge__points', 'obtained_at', 'valid')
template_name = 'django_tables2/bootstrap4.html'
order_by = ('-obtained_at',)
class FamilyAchievementTable(tables.Table):
"""
Table des défis réalisés par une famille spécifique.
"""
challenge = tables.Column(verbose_name=_("Challenge"))
class Meta:
model = Achievement
template_name = 'django_tables2/bootstrap4.html'
fields = ('challenge', 'challenge__points', 'obtained_at',)
fields = ('challenge', 'challenge__points', 'obtained_at', 'valid')
attrs = {
'class': 'table table-condensed table-striped table-hover'
}

View File

@@ -18,9 +18,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<form method="post">
{% csrf_token %}
<a class="btn btn-primary" href="{% url 'family:achievement_list' %}">{% trans "Return to achievements list" %}</a>
{% if not object.locked %}
<button class="btn btn-danger" type="submit">{% trans "Delete" %}</button>
{% endif %}
</form>
</div>
</div>

View File

@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-light">
<div class="card-header text-center">
<h4>{% trans "Validate achievement" %}</h4>
</div>
<div class="card-body">
<div class="alert alert-warning">
{% blocktrans %}Are you sure you want to validate this achievement? This action can't be undone.{% endblocktrans %}
</div>
</div>
<div class="card-footer text-center">
<form method="post">
{% csrf_token %}
<a class="btn btn-primary" href="{% url 'family:achievement_list' %}">{% trans "Return to achievements list" %}</a>
<form method="post" action="{% url 'family:achievement_validate' pk %}">
{% csrf_token %}
<button type="submit" class="btn btn-success">{% trans "Validate" %}</button>
</form>
</form>
</div>
</div>
{% endblock %}

View File

@@ -10,13 +10,24 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div class="card mb-4" id="history">
<div class="card-header">
<p class="card-text font-weight-bold">
{% trans "Recent achievements history" %}
{% trans "Invalid achievements history" %}
</p>
<a class="btn btn-sm btn-primary mx-2" href="{% url "family:manage" %}">
{% trans "Return to management page" %}
</a>
</div>
{% render_table table %}
{% render_table invalid %}
</div>
<div class="card mb-4" id="history">
<div class="card-header">
<p class="card-text font-weight-bold">
{% trans "Valid achievements history" %}
</p>
<a class="btn btn-sm btn-primary mx-2" href="{% url "family:manage" %}">
{% trans "Return to management page" %}
</a>
</div>
{% render_table valid %}
</div>
{% endblock %}

View File

@@ -16,9 +16,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
<a href="#" class="btn btn-sm btn-outline-primary active">
{% trans "Challenges" %}
</a>
{% if can_manage %}
<a href="{% url "family:manage" %}" class="btn btn-sm btn-outline-primary">
{% trans "Manage" %}
</a>
{% endif %}
</div>
</div>
</div>

View File

@@ -7,6 +7,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% load i18n perms %}
{% block profile_content %}
{% if member_list.data %}
<div class="card">
<div class="card-header position-relative" id="clubListHeading">
<i class="fa fa-users"></i> {% trans "Family members" %}
@@ -15,11 +16,14 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div>
<div class="my-4"></div>
{% endif %}
{% if achievement_list.data %}
<div class="card">
<div class="card-header position-relative">
<i class="fa fa-trophy"></i> {% trans "Completed challenges" %}
</div>
{% render_table achievement_list %}
</div>
{% endif %}
{% endblock %}

View File

@@ -16,9 +16,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
<a href="{% url "family:challenge_list" %}" class="btn btn-sm btn-outline-primary">
{% trans "Challenges" %}
</a>
{% if can_manage %}
<a href="{% url "family:manage" %}" class="btn btn-sm btn-outline-primary">
{% trans "Manage" %}
</a>
{% endif %}
</div>
</div>
</div>

View File

@@ -26,6 +26,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div class="row mb-3">
<div class='col-sm-5 col-xl-6' id="infos_div">
{% if can_add_achievement %}
<div class="row justify-content-center justify-content-md-end">
{# Family details column #}
<div class="col picture-col">
@@ -52,7 +53,12 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div>
{# User search with autocompletion #}
<div class="card-footer">
<input class="form-control mx-auto d-block" placeholder="{% trans "Name" %}" type="text" id="note" autofocus />
<input class="form-control mx-auto d-block mb-2" placeholder="{% trans "Name" %}" type="text" id="note" autofocus />
{% if user_family %}
<button class="btn btn-sm btn-secondary btn-block" id="select_my_family">
{% trans "Select my family" %} ({{ user_family.name }})
</button>
{% endif %}
</div>
</div>
</div>
@@ -76,29 +82,33 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div>
</div>
</div>
{% endif %}
{# Create family/challenge buttons #}
{% if can_add_family or can_add_challenge %}
<div class="card bg-light border-success mb-4">
<h3 class="card-header font-weight-bold text-center">
{% trans "Create a family or challenge" %}
</h3>
<div class="card-body text-center">
{% if can_add_family %}
<a class="btn btn-sm btn-primary mx-2" href="{% url "family:add_family" %}">
<a class="btn btn-sm btn-primary mx-2" href="{% url "family:family_create" %}">
{% trans "Add a family" %}
</a>
{% endif %}
{% if can_add_challenge %}
<a class="btn btn-sm btn-primary mx-2" href="{% url "family:add_challenge" %}">
<a class="btn btn-sm btn-primary mx-2" href="{% url "family:challenge_create" %}">
{% trans "Add a challenge" %}
</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{# Buttons column #}
<div class="col">
{% if can_add_achievement %}
<div class="card bg-light border-primary text-center mb-4">
{# Tabs for list and search #}
<div class="card-header">
@@ -144,10 +154,12 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div>
</div>
{% endif %}
</div>
</div>
{# transaction history #}
{# achievement history #}
{% if table.data %}
<div class="card">
<div class="card-header position-relative" id="historyListHeading">
<a class="stretched-link font-weight-bold"
@@ -155,10 +167,43 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% trans "Recent achievements history" %}
</a>
</div>
<div id="history_list">
<div id="history">
{% render_table table %}
</div>
</div>
<!-- Popup de validation -->
<div class="modal fade" id="validationModal" tabindex="-1" role="dialog" aria-labelledby="validationModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content border-success">
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="validationModalLabel">{% trans "Challenge validated" %}</h5>
<button type="button" class="close text-white" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p><strong>Are you sure you want to validate this challenge?</strong></p>
<p>To have your challenge officially validated, please send a message with:</p>
<ul>
<li>The name of the family</li>
<li>The name of the challenge</li>
<li>A photo or video as proof</li>
</ul>
<p>
<strong>Send it via WhatsApp to:
{% for num in phone_numbers %}
<a href="https://wa.me/{{ num }}" target="_blank">{{ num }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-dismiss="modal">{% trans "OK" %}</button>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
@@ -178,4 +223,59 @@ SPDX-License-Identifier: GPL-3.0-or-later
});
{% endfor %}
</script>
<script>
document.getElementById("consume_all").addEventListener("click", function () {
$('#validationModal').modal('show');
});
{% if user_family %}
document.getElementById("select_my_family").addEventListener("click", function () {
// Simulate selecting the user's family
var userFamily = {
id: {{ user_family.id }},
name: "{{ user_family.name|escapejs }}",
display_image: "{{ user_family.display_image.url|default:'/static/member/img/default_picture.png'|escapejs }}"
};
// Check if family is already selected
var alreadySelected = false;
notes_display.forEach(function (d) {
if (d.id === userFamily.id) {
alreadySelected = true;
}
});
if (!alreadySelected) {
// Add the family to the selected families
var disp = {
name: userFamily.name,
id: userFamily.id,
family: userFamily,
};
notes_display.push(disp);
// Update the display
const family_list = $('#note_list');
let html = '';
notes_display.forEach(function (disp) {
html += li('note_' + disp.id, disp.name, '');
});
family_list.html(html);
// Add click handlers for removal
notes_display.forEach(function (disp) {
const line_obj = $('#note_' + disp.id);
line_obj.hover(function () {
displayFamily(disp.family, disp.name, 'user_note', 'profile_pic');
});
line_obj.click(removeFamily(disp, 'note', notes_display, 'note_list', 'user_note', 'profile_pic'));
});
// Display the family info
displayFamily(userFamily, userFamily.name, 'user_note', 'profile_pic');
}
});
{% endif %}
</script>
{% endblock %}

View File

View File

@@ -0,0 +1,318 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import os
from api.tests import TestAPI
from django.contrib.auth.models import User
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from rest_framework.test import APITestCase
from django.urls import reverse
from django.utils import timezone
from ..api.views import FamilyViewSet, FamilyMembershipViewSet, ChallengeViewSet, AchievementViewSet
from ..models import Family, FamilyMembership, Challenge, Achievement
class TestFamily(TestCase):
"""
Test family
"""
def setUp(self):
self.user = User.objects.create_superuser(
username='admintoto',
password='toto1234',
email='toto@example.com',
)
self.client.force_login(self.user)
sess = self.client.session
sess['permission_mask'] = 42
sess.save()
self.family = Family.objects.create(
name='Test family',
description='',
)
self.challenge = Challenge.objects.create(
name='Test challenge',
description='',
points=100,
)
self.achievement = Achievement.objects.create(
family=self.family,
challenge=self.challenge,
valid=False,
)
def test_family_list(self):
"""
Test display family list
"""
response = self.client.get(reverse("family:family_list"))
self.assertEqual(response.status_code, 200)
def test_family_create(self):
"""
Test create a family
"""
response = self.client.get(reverse("family:family_create"))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("family:family_create"), data={
"name": "Family toto",
"description": "A test family",
})
self.assertTrue(Family.objects.filter(name="Family toto").exists())
self.assertRedirects(response, reverse("family:manage"), 302, 200)
def test_family_detail(self):
"""
Test display the detail of a family
"""
response = self.client.get(reverse("family:family_detail", args=(self.family.pk,)))
self.assertEqual(response.status_code, 200)
def test_family_update(self):
"""
Test update a family
"""
response = self.client.get(reverse("family:family_update", args=(self.family.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("family:family_update", args=(self.family.pk,)), data=dict(
name="Toto family updated",
description="A larger description for the test family"
))
self.assertRedirects(response, self.family.get_absolute_url(), 302, 200)
self.assertTrue(Family.objects.filter(name="Toto family updated").exists())
def test_family_update_picture(self):
"""
Test update the picture of a family
"""
response = self.client.get(reverse("family:update_pic", args=(self.family.pk,)))
self.assertEqual(response.status_code, 200)
old_pic = self.family.display_image
with open("apps/family/static/family/img/default_picture.png", "rb") as f:
image = SimpleUploadedFile("image.png", f.read(), "image/png")
response = self.client.post(reverse("family:update_pic", args=(self.family.pk,)), dict(
image=image,
x=0,
y=0,
width=200,
height=200,
))
self.assertRedirects(response, self.family.get_absolute_url(), 302, 200)
self.family.refresh_from_db()
self.assertTrue(os.path.exists(self.family.display_image.path))
os.remove(self.family.display_image.path)
self.family.display_image = old_pic
self.family.save()
def test_family_add_member(self):
"""
Test add memberships to a family
"""
response = self.client.get(reverse("family:family_add_member", args=(self.family.pk,)))
self.assertEqual(response.status_code, 200)
user = User.objects.create(username="totototo")
user.profile.registration_valid = True
user.profile.email_confirmed = True
user.profile.save()
user.save()
response = self.client.post(reverse("family:family_add_member", args=(self.family.pk,)), data=dict(
user=user.pk,
))
self.assertRedirects(response, self.family.get_absolute_url(), 302, 200)
self.assertTrue(FamilyMembership.objects.filter(user=user, family=self.family, year=timezone.now().year).exists())
def test_challenge_list(self):
"""
Test display challenge list
"""
response = self.client.get(reverse('family:challenge_list'))
self.assertEqual(response.status_code, 200)
def test_challenge_create(self):
"""
Test create a challenge
"""
response = self.client.get(reverse("family:challenge_create"))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("family:challenge_create"), data={
"name": "Challenge for toto",
"description": "A test challenge",
"points": 50,
})
self.assertTrue(Challenge.objects.filter(name="Challenge for toto").exists())
self.assertRedirects(response, reverse("family:manage"), 302, 200)
def test_challenge_detail(self):
"""
Test display the detail of a challenge
"""
response = self.client.get(reverse("family:challenge_detail", args=(self.challenge.pk,)))
self.assertEqual(response.status_code, 200)
def test_challenge_update(self):
"""
Test update a challenge
"""
response = self.client.get(reverse("family:challenge_update", args=(self.challenge.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("family:challenge_update", args=(self.challenge.pk,)), data=dict(
name="Challenge updated",
description="Another description",
points=10,
))
self.assertRedirects(response, self.challenge.get_absolute_url(), 302, 200)
self.assertTrue(Challenge.objects.filter(name="Challenge updated").exists())
def test_render_manage_page(self):
"""
Test render manage page
"""
response = self.client.get(reverse("family:manage"))
self.assertEqual(response.status_code, 200)
def test_validate_achievement(self):
"""
Test validate an achievement
"""
old_family_score = self.family.score
response = self.client.get(reverse("family:achievement_validate", args=(self.achievement.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("family:achievement_validate", args=(self.achievement.pk,)))
self.assertRedirects(response, reverse("family:achievement_list"), 302, 200)
self.achievement.refresh_from_db()
self.assertIs(self.achievement.valid, True)
self.family.refresh_from_db()
self.assertEqual(self.family.score, old_family_score + self.achievement.challenge.points)
def test_delete_achievement(self):
"""
Test delete an achievement
"""
response = self.client.get(reverse("family:achievement_delete", args=(self.achievement.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.delete(reverse("family:achievement_delete", args=(self.achievement.pk,)))
self.assertRedirects(response, reverse("family:achievement_list"), 302, 200)
self.assertFalse(Achievement.objects.filter(pk=self.achievement.pk).exists())
class TestBatchAchievements(APITestCase):
def setUp(self):
self.user = User.objects.create_superuser(
username='admintoto',
password='toto1234',
email='toto@example.com',
)
self.client.force_login(self.user)
sess = self.client.session
sess['permission_mask'] = 42
sess.save()
self.families = [
Family.objects.create(name=f'Famille {i}', description='') for i in range(2)
]
self.challenges = [
Challenge.objects.create(name=f'Challenge {i}', description='', points=50) for i in range(3)
]
self.url = reverse("family:api:batch_achievements")
def test_batch_achievement_creation(self):
family_ids = [f.id for f in self.families]
challenge_ids = [c.id for c in self.challenges]
response = self.client.post(
self.url,
data={
'families': family_ids,
'challenges': challenge_ids
},
format='json'
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['status'], 'ok')
expected_count = len(family_ids) * len(challenge_ids)
self.assertEqual(Achievement.objects.count(), expected_count)
# Check that correct couples family/challenge exist
for f in self.families:
for c in self.challenges:
self.assertTrue(
Achievement.objects.filter(family=f, challenge=c).exists()
)
class TestFamilyAPI(TestAPI):
def setUp(self):
super().setUp()
self.family = Family.objects.create(
name='Test family',
description='',
)
self.familymembership = FamilyMembership.objects.create(
user=self.user,
family=self.family,
)
self.challenge = Challenge.objects.create(
name='Test challenge',
description='',
points=100,
)
self.achievement = Achievement.objects.create(
family=self.family,
challenge=self.challenge,
valid=False,
)
def test_family_api(self):
"""
Load Family API page and test all filters and permissions
"""
self.check_viewset(FamilyViewSet, '/api/family/family/')
def test_familymembership_api(self):
"""
Load FamilyMembership API page and test all filters and permissions
"""
self.check_viewset(FamilyMembershipViewSet, '/api/family/familymembership/')
def test_challenge_api(self):
"""
Load Challenge API page and test all filters and permissions
"""
self.check_viewset(ChallengeViewSet, '/api/family/challenge/')
def test_achievement_api(self):
"""
Load Achievement API page and test all filters and permissions
"""
self.check_viewset(AchievementViewSet, '/api/family/achievement/')

View File

@@ -8,17 +8,18 @@ from . import views
app_name = 'family'
urlpatterns = [
path('list/', views.FamilyListView.as_view(), name="family_list"),
path('add-family/', views.FamilyCreateView.as_view(), name="add_family"),
path('detail/<int:pk>/', views.FamilyDetailView.as_view(), name="family_detail"),
path('update/<int:pk>/', views.FamilyUpdateView.as_view(), name="family_update"),
path('update_pic/<int:pk>/', views.FamilyPictureUpdateView.as_view(), name="update_pic"),
path('add_member/<int:family_pk>/', views.FamilyAddMemberView.as_view(), name="family_add_member"),
path('create/', views.FamilyCreateView.as_view(), name="family_create"),
path('<int:pk>/detail/', views.FamilyDetailView.as_view(), name="family_detail"),
path('<int:pk>/update/', views.FamilyUpdateView.as_view(), name="family_update"),
path('<int:pk>/update_pic/', views.FamilyPictureUpdateView.as_view(), name="update_pic"),
path('<int:family_pk>/add_member/', views.FamilyAddMemberView.as_view(), name="family_add_member"),
path('challenge/list/', views.ChallengeListView.as_view(), name="challenge_list"),
path('add-challenge/', views.ChallengeCreateView.as_view(), name="add_challenge"),
path('challenge/detail/<int:pk>/', views.ChallengeDetailView.as_view(), name="challenge_detail"),
path('challenge/update/<int:pk>/', views.ChallengeUpdateView.as_view(), name="challenge_update"),
path('challenge/create/', views.ChallengeCreateView.as_view(), name="challenge_create"),
path('challenge/<int:pk>/detail/', views.ChallengeDetailView.as_view(), name="challenge_detail"),
path('challenge/<int:pk>/update/', views.ChallengeUpdateView.as_view(), name="challenge_update"),
path('manage/', views.FamilyManageView.as_view(), name="manage"),
path('achievements/', views.AchievementsView.as_view(), name="achievement_list"),
path('achievement/delete/<int:pk>/', views.AchievementDeleteView.as_view(), name="achievement_delete"),
path('api/family/', include('family.api.urls')),
path('achievement/list/', views.AchievementListView.as_view(), name="achievement_list"),
path('achievement/<int:pk>/validate/', views.AchievementValidateView.as_view(), name="achievement_validate"),
path('achievement/<int:pk>/delete/', views.AchievementDeleteView.as_view(), name="achievement_delete"),
path('api/family/', include(('family.api.urls', 'family_api'), namespace='api')),
]

View File

@@ -4,16 +4,20 @@
from datetime import date
from django.conf import settings
from django.shortcuts import redirect
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.views.generic import DetailView, UpdateView
from django.views.generic.edit import DeleteView
from django.views.generic import DetailView, UpdateView, ListView
from django.views.generic.edit import DeleteView, FormMixin
from django.views.generic.base import TemplateView
from django.utils.translation import gettext_lazy as _
from django_tables2 import SingleTableView
from django_tables2 import SingleTableView, MultiTableMixin
from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from django.urls import reverse_lazy
from member.views import PictureUpdateView
from member.forms import ImageForm
import phonenumbers
from .models import Family, Challenge, FamilyMembership, User, Achievement
from .tables import FamilyTable, ChallengeTable, FamilyMembershipTable, AchievementTable, FamilyAchievementTable
@@ -49,6 +53,24 @@ class FamilyListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
table_class = FamilyTable
extra_context = {"title": _('Families list')}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
fake_family = Family(name="", description="")
fake_challenge = Challenge(name="", description="", points=0)
can_add_family = PermissionBackend.check_perm(self.request, "family.add_family", fake_family)
can_add_challenge = PermissionBackend.check_perm(self.request, "family.add_challenge", fake_challenge)
if Family.objects.exists() and Challenge.objects.exists():
fake_achievement = Achievement(family=Family.objects.first(), challenge=Challenge.objects.first(), valid=False)
can_add_achievement = PermissionBackend.check_perm(self.request, "family.add_achievement", fake_achievement)
else:
can_add_achievement = False
context["can_manage"] = can_add_family or can_add_challenge or can_add_achievement
return context
class FamilyDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
@@ -110,17 +132,28 @@ class FamilyUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
return reverse_lazy('family:family_detail', kwargs={'pk': self.object.pk})
class FamilyPictureUpdateView(PictureUpdateView):
class FamilyPictureUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin, DetailView):
"""
Update profile picture of the family
"""
model = Family
extra_context = {"title": _("Update family picture")}
template_name = 'family/picture_update.html'
form_class = ImageForm
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['form'] = self.form_class(self.request.POST, self.request.FILES)
return context
def get_success_url(self):
"""Redirect to family page after upload"""
return reverse_lazy('family:family_detail', kwargs={'pk': self.object.id})
return reverse_lazy('family:family_detail', kwargs={'pk': self.object.pk})
def post(self, request, *args, **kwargs):
form = self.get_form()
self.object = self.get_object()
return self.form_valid(form) if form.is_valid() else self.form_invalid(form)
@transaction.atomic
def form_valid(self, form):
@@ -139,6 +172,11 @@ class FamilyPictureUpdateView(PictureUpdateView):
else:
image.name = "{}_pic.png".format(self.object.pk)
# Save
self.object.display_image = image
self.object.save()
return super().form_valid(form)
class FamilyAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
"""
@@ -213,6 +251,24 @@ class ChallengeListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableVie
table_class = ChallengeTable
extra_context = {"title": _('Challenges list')}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
fake_family = Family(name="", description="")
fake_challenge = Challenge(name="", description="", points=0)
can_add_family = PermissionBackend.check_perm(self.request, "family.add_family", fake_family)
can_add_challenge = PermissionBackend.check_perm(self.request, "family.add_challenge", fake_challenge)
if Family.objects.exists() and Challenge.objects.exists():
fake_achievement = Achievement(family=Family.objects.first(), challenge=Challenge.objects.first(), valid=False)
can_add_achievement = PermissionBackend.check_perm(self.request, "family.add_achievement", fake_achievement)
else:
can_add_achievement = False
context["can_manage"] = can_add_family or can_add_challenge or can_add_achievement
return context
class ChallengeDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
@@ -265,6 +321,11 @@ class FamilyManageView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView
if not request.user.is_authenticated:
return self.handle_no_permission()
perm = PermissionBackend.has_model_perm(self.request, Achievement(), "add")
perm = perm or PermissionBackend.has_model_perm(self.request, Challenge(), "add")
perm = perm or PermissionBackend.has_model_perm(self.request, Family(), "add")
if not perm:
raise PermissionDenied(_("You are not able to manage families and challenges."))
return super().dispatch(request, *args, **kwargs)
def get_queryset(self, **kwargs):
@@ -280,30 +341,109 @@ class FamilyManageView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView
PermissionBackend.filter_queryset(self.request, Challenge, "view")
).order_by('name')
context["can_add_family"] = PermissionBackend.check_perm(self.request, "family.add_family")
context["can_add_challenge"] = PermissionBackend.check_perm(self.request, "family.add_challenge")
context["can_add_family"] = PermissionBackend.has_model_perm(self.request, Family(), "add")
context["can_add_challenge"] = PermissionBackend.has_model_perm(self.request, Challenge(), "add")
context["can_add_achievement"] = PermissionBackend.has_model_perm(self.request, Achievement(), "add")
# Get the user's family if they have one
try:
user_family_membership = FamilyMembership.objects.get(user=self.request.user)
context["user_family"] = user_family_membership.family
except FamilyMembership.DoesNotExist:
context["user_family"] = None
phone_numbers = [
u.profile.phone_number for u in User.objects.filter(
memberships__roles__id=35,
memberships__date_end__gte=date.today(),
profile__phone_number__isnull=False
).distinct()
]
formatted_phone_numbers = [phonenumbers.format_number(num, phonenumbers.PhoneNumberFormat.INTERNATIONAL) for num in phone_numbers if num]
context["phone_numbers"] = formatted_phone_numbers
return context
def get_table(self, **kwargs):
table = super().get_table(**kwargs)
table.exclude = ('delete',)
table.exclude = ('delete', 'validate',)
table.orderable = False
return table
def get_table_data(self, **kwargs):
qs = super().get_queryset(**kwargs)
class AchievementsView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
qs = qs.filter(PermissionBackend.filter_queryset(self.request, Achievement, "view"))
return qs
class AchievementListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView):
"""
List all achievements
"""
model = Achievement
table_class = AchievementTable
tables = [AchievementTable, AchievementTable, ]
extra_context = {'title': _('Achievement list')}
def get_table(self, **kwargs):
table = super().get_table(**kwargs)
table.orderable = True
return table
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
if not PermissionBackend.has_model_perm(self.request, Achievement(), "change"):
raise PermissionDenied(_("You are not able to see the achievement validation interface."))
return super().dispatch(request, *args, **kwargs)
def get_tables(self, **kwargs):
tables = super().get_tables(**kwargs)
tables[0].prefix = 'invalid-'
tables[1].prefix = 'valid-'
tables[1].exclude = ('validate', 'delete',)
return tables
def get_tables_data(self):
table_valid = self.get_queryset().filter(valid=True)
table_invalid = self.get_queryset().filter(valid=False)
return [table_invalid, table_valid, ]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
tables = context['tables']
context['invalid'] = tables[0]
context['valid'] = tables[1]
return context
class AchievementValidateView(ProtectQuerysetMixin, LoginRequiredMixin, TemplateView):
"""
Validate an achievement obtained by a family
"""
template_name = 'family/achievement_confirm_validate.html'
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
fake_achievement = Achievement(
family=Family.objects.first(),
challenge=Challenge.objects.first(),
valid=False,
)
if not PermissionBackend.check_perm(self.request, "family.change_achievement_valid", fake_achievement):
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
def post(self, request, pk):
achievement = Achievement.objects.get(pk=pk)
achievement.valid = True
achievement.save()
return redirect(reverse_lazy('family:achievement_list'))
class AchievementDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView):
@@ -312,5 +452,18 @@ class AchievementDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView
"""
model = Achievement
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
fake_achievement = Achievement(
family=Family.objects.first(),
challenge=Challenge.objects.first(),
valid=False,
)
if not PermissionBackend.check_perm(self.request, "family.change_achievement_valid", fake_achievement):
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
def get_success_url(self):
return reverse_lazy('family:achievement_list')

View File

@@ -6,7 +6,7 @@ from django.conf import settings
from django.db.models.signals import post_save
from django.utils.translation import gettext_lazy as _
from .signals import save_user_profile
from .signals import save_user_profile, update_wei_registration_fee_on_membership_creation, update_wei_registration_fee_on_club_change
class MemberConfig(AppConfig):
@@ -17,7 +17,16 @@ class MemberConfig(AppConfig):
"""
Define app internal signals to interact with other apps
"""
from .models import Membership, Club
post_save.connect(
save_user_profile,
sender=settings.AUTH_USER_MODEL,
)
post_save.connect(
update_wei_registration_fee_on_membership_creation,
sender=Membership
)
post_save.connect(
update_wei_registration_fee_on_club_change,
sender=Club
)

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

@@ -13,3 +13,27 @@ def save_user_profile(instance, created, raw, **_kwargs):
instance.profile.email_confirmed = True
instance.profile.registration_valid = True
instance.profile.save()
def update_wei_registration_fee_on_membership_creation(sender, instance, created, **kwargs):
if not hasattr(instance, "_no_signal") and created:
from wei.models import WEIRegistration
if instance.club.id == 1 or instance.club.id == 2:
registrations = WEIRegistration.objects.filter(
user=instance.user,
wei__year=instance.date_start.year,
)
for r in registrations:
r._force_save = True
r.save()
def update_wei_registration_fee_on_club_change(sender, instance, **kwargs):
from wei.models import WEIRegistration
if not hasattr(instance, "_no_signal") and (instance.id == 1 or instance.id == 2):
registrations = WEIRegistration.objects.filter(
wei__year=instance.membership_start.year,
)
for r in registrations:
r._force_save = True
r.save()

View File

@@ -9,11 +9,13 @@
<dt class="col-xl-6">{% trans 'family'|capfirst %}</dt>
<dd class="col-xl-6">
{% for family in families %}
<a href="{% url 'family:family_detail' family.pk %}">{{ family.name }}</a>{% if not forloop.last %}, {% endif %}
{% empty %}
<em>{% trans 'None' %}</em>
{% if families %}
{% for fam in families %}
<a href="{% url 'family:family_detail' fam.pk %}">{{ fam.name }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
{% else %}
<span class="text-muted">Aucune</span>
{% endif %}
</dd>
{% if user_object.pk == user.pk %}

View File

@@ -93,9 +93,6 @@ class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
if fields_modifiable:
context['profile_form'] = profile_form
families = Family.objects.filter(members=user).distinct()
context["families"] = families
return context
@transaction.atomic
@@ -211,6 +208,9 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context["can_unlock_note"] = not user.note.is_active and PermissionBackend\
.check_perm(self.request, "note.change_noteuser_is_active", modified_note)
families = Family.objects.filter(memberships__user=user).distinct()
context["families"] = families
return context

View File

@@ -1391,12 +1391,12 @@
"wei",
"weiregistration"
],
"query": "{\"wei\": [\"club\"], \"wei__membership_end__gte\": [\"today\"]}",
"query": "[\"AND\", {\"wei\": [\"club\"], \"wei__membership_end__gte\": [\"today\"]}, {\"deposit_type\": \"note\"}]",
"type": "change",
"mask": 2,
"field": "caution_check",
"field": "deposit_given",
"permanent": false,
"description": "Dire si un chèque de caution est donné pour une inscription WEI"
"description": "Autoriser une transaction de caution WEI"
}
},
{
@@ -4347,7 +4347,343 @@
"mask": 3,
"field": "",
"permanent": false,
"description": "Ajouter un membre au BDE ou à la Kfet"
"description": "Faire adhérer BDE ou Kfet"
}
},
{
"model": "permission.permission",
"pk": 293,
"fields": {
"model": [
"wei",
"weimembership"
],
"query": "[\"AND\", {\"bus\": [\"membership\", \"weimembership\", \"bus\"]}, {\"club\": [\"club\"], \"club__weiclub__membership_end__gte\": [\"today\"]}]",
"type": "change",
"mask": 2,
"field": "team",
"permanent": false,
"description": "Modifier l'équipe d'une adhésion WEI à son bus"
}
},
{
"model": "permission.permission",
"pk": 294,
"fields": {
"model": [
"wei",
"weiregistration"
],
"query": "[\"AND\", {\"wei__year\": [\"today\", \"year\"], \"wei__membership_start__lte\": [\"today\"], \"wei__membership_end__gte\": [\"today\"]}, {\"deposit_type\": \"check\"}]",
"type": "change",
"mask": 2,
"field": "deposit_given",
"permanent": false,
"description": "Dire si un chèque de caution a été donné"
}
},
{
"model": "permission.permission",
"pk": 295,
"fields": {
"model": [
"wei",
"weiregistration"
],
"query": "{\"wei__year\": [\"today\", \"year\"]}",
"type": "view",
"mask": 2,
"field": "",
"permanent": false,
"description": "Voir toutes les inscriptions au WEI courant"
}
},
{
"model": "permission.permission",
"pk": 296,
"fields": {
"model": [
"wei",
"weimembership"
],
"query": "{\"club__weiclub__year\": [\"today\", \"year\"]}",
"type": "view",
"mask": 2,
"field": "",
"permanent": false,
"description": "Voir toutes les adhésions au WEI courant"
}
},
{
"model": "permission.permission",
"pk": 297,
"fields": {
"model": [
"wei",
"weiregistration"
],
"query": "[\"AND\", {\"user\": [\"user\"], \"wei__membership_start__lte\": [\"today\"], \"wei__membership_end__gte\": [\"today\"]}, [\"OR\", {\"wei\": [\"club\"]}, {\"wei__year\": [\"today\", \"year\"], \"membership\": null}]]",
"type": "change",
"mask": 1,
"field": "deposit_type",
"permanent": false,
"description": "Modifier le type de caution de mon inscription WEI tant qu'elle n'est pas validée"
}
},
{
"model": "permission.permission",
"pk": 311,
"fields": {
"model": [
"family",
"family"
],
"query": "{}",
"type": "view",
"mask": 1,
"field": "",
"permanent": false,
"description": "Voir toutes les familles"
}
},
{
"model": "permission.permission",
"pk": 312,
"fields": {
"model": [
"family",
"family"
],
"query": "{}",
"type": "add",
"mask": 2,
"field": "",
"permanent": false,
"description": "Créer une famille"
}
},
{
"model": "permission.permission",
"pk": 313,
"fields": {
"model": [
"family",
"family"
],
"query": "{}",
"type": "change",
"mask": 2,
"field": "",
"permanent": false,
"description": "Modifier n'importe quelle famille"
}
},
{
"model": "permission.permission",
"pk": 314,
"fields": {
"model": [
"family",
"family"
],
"query": "{\"pk\": [\"user\", \"family_memberships\", \"family\", \"pk\"]}",
"type": "change",
"mask": 2,
"field": "",
"permanent": false,
"description": "Modifier ma famille"
}
},
{
"model": "permission.permission",
"pk": 315,
"fields": {
"model": [
"family",
"familymembership"
],
"query": "{}",
"type": "view",
"mask": 2,
"field": "",
"permanent": false,
"description": "Voir les membres de n'importe quelle famille"
}
},
{
"model": "permission.permission",
"pk": 316,
"fields": {
"model": [
"family",
"familymembership"
],
"query": "{\"family\": [\"user\", \"family_memberships\", \"family\"]}",
"type": "view",
"mask": 1,
"field": "",
"permanent": false,
"description": "Voir les membres de ma famille"
}
},
{
"model": "permission.permission",
"pk": 317,
"fields": {
"model": [
"family",
"familymembership"
],
"query": "{}",
"type": "add",
"mask": 2,
"field": "",
"permanent": false,
"description": "Ajouter un membre à n'importe quelle famille"
}
},
{
"model": "permission.permission",
"pk": 318,
"fields": {
"model": [
"family",
"familymembership"
],
"query": "{\"family\": [\"user\", \"family_memberships\", \"family\"]}",
"type": "add",
"mask": 2,
"field": "",
"permanent": false,
"description": "Ajouter un membre à ma famille"
}
},
{
"model": "permission.permission",
"pk": 319,
"fields": {
"model": [
"family",
"challenge"
],
"query": "{}",
"type": "view",
"mask": 1,
"field": "",
"permanent": false,
"description": "Voir tous les défis"
}
},
{
"model": "permission.permission",
"pk": 320,
"fields": {
"model": [
"family",
"challenge"
],
"query": "{}",
"type": "add",
"mask": 2,
"field": "",
"permanent": false,
"description": "Créer un défi"
}
},
{
"model": "permission.permission",
"pk": 321,
"fields": {
"model": [
"family",
"challenge"
],
"query": "{}",
"type": "change",
"mask": 2,
"field": "",
"permanent": false,
"description": "Modifier un défi"
}
},
{
"model": "permission.permission",
"pk": 322,
"fields": {
"model": [
"family",
"challenge"
],
"query": "{}",
"type": "delete",
"mask": 2,
"field": "{}",
"permanent": false,
"description": "Supprimer un défi"
}
},
{
"model": "permission.permission",
"pk": 323,
"fields": {
"model": [
"family",
"achievement"
],
"query": "{}",
"type": "view",
"mask": 1,
"field": "",
"permanent": false,
"description": "Voir tous les succès"
}
},
{
"model": "permission.permission",
"pk": 324,
"fields": {
"model": [
"family",
"achievement"
],
"query": "{}",
"type": "add",
"mask": 2,
"field": "",
"permanent": false,
"description": "Créer un succès"
}
},
{
"model": "permission.permission",
"pk": 325,
"fields": {
"model": [
"family",
"achievement"
],
"query": "{}",
"type": "change",
"mask": 1,
"field": "valid",
"permanent": false,
"description": "Valider un succès"
}
},
{
"model": "permission.permission",
"pk": 326,
"fields": {
"model": [
"family",
"achievement"
],
"query": "{}",
"type": "delete",
"mask": 1,
"field": "",
"permanent": false,
"description": "Supprimer un succès"
}
},
{
@@ -4404,7 +4740,11 @@
249,
255,
256,
257
257,
311,
316,
319,
323
]
}
},
@@ -4444,7 +4784,8 @@
159,
160,
212,
222
222,
297
]
}
},
@@ -4631,7 +4972,10 @@
176,
177,
178,
183
183,
294,
295,
296
]
}
},
@@ -4764,7 +5108,6 @@
"name": "Chef\u22c5fe de bus",
"permissions": [
22,
84,
115,
117,
118,
@@ -4778,7 +5121,8 @@
287,
289,
290,
291
291,
293
]
}
},
@@ -4790,7 +5134,6 @@
"name": "Chef\u22c5fe d'\u00e9quipe",
"permissions": [
22,
84,
116,
123,
124,
@@ -4805,8 +5148,7 @@
"for_club": null,
"name": "\u00c9lectron libre",
"permissions": [
22,
84
22
]
}
},
@@ -4957,7 +5299,6 @@
"name": "Référent⋅e Bus",
"permissions": [
22,
84,
115,
117,
118,
@@ -4971,7 +5312,8 @@
287,
289,
290,
291
291,
293
]
}
},
@@ -5061,6 +5403,39 @@
]
}
},
{
"model": "permission.role",
"pk": 34,
"fields": {
"for_club": 1,
"name": "Chef·fe de famille",
"permissions": [
314,
318,
324
]
}
},
{
"model": "permission.role",
"pk": 35,
"fields": {
"for_club": 1,
"name": "Respo familles",
"permissions": [
312,
313,
315,
317,
320,
321,
322,
324,
325,
326
]
}
},
{
"model": "wei.weirole",
"pk": 12,

View File

@@ -353,13 +353,11 @@ class SogeCredit(models.Model):
def amount(self):
if self.valid:
return self.credit_transaction.total
amount = sum(max(transaction.total - 2000, 0) for transaction in self.transactions.all())
if 'wei' in settings.INSTALLED_APPS:
from wei.models import WEIMembership
if not WEIMembership.objects\
.filter(club__weiclub__year=self.credit_transaction.created_at.year, user=self.user).exists():
# 80 € for people that don't go to WEI
amount += 8000
amount = 0
transactions_wei = self.transactions.filter(membership__club__weiclub__isnull=False)
amount += sum(max(transaction.total - transaction.membership.club.weiclub.fee_soge_credit, 0) for transaction in transactions_wei)
transactions_not_wei = self.transactions.filter(membership__club__weiclub__isnull=True)
amount += sum(transaction.total for transaction in transactions_not_wei)
return amount
def update_transactions(self):
@@ -441,7 +439,7 @@ class SogeCredit(models.Model):
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:
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."))

View File

@@ -77,7 +77,7 @@ class WEIRegistrationViewSet(ReadProtectedModelViewSet):
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['user', 'user__username', 'user__first_name', 'user__last_name', 'user__email',
'user__note__alias__name', 'user__note__alias__normalized_name', 'wei', 'wei__name',
'wei__email', 'wei__year', 'soge_credit', 'deposit_check', 'birth_date', 'gender',
'wei__email', 'wei__year', 'soge_credit', 'deposit_given', 'birth_date', 'gender',
'clothing_cut', 'clothing_size', 'first_year', 'emergency_contact_name',
'emergency_contact_phone', ]
search_fields = ['$user__username', '$user__first_name', '$user__last_name', '$user__email',

View File

@@ -1,11 +1,11 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from .registration import WEIForm, WEIRegistrationForm, WEIRegistration1AForm, WEIRegistration2AForm, WEIMembership1AForm, \
from .registration import WEIForm, WEIRegistrationForm, WEIMembership1AForm, \
WEIMembershipForm, BusForm, BusTeamForm
from .surveys import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, CurrentSurvey
__all__ = [
'WEIForm', 'WEIRegistrationForm', 'WEIRegistration1AForm', 'WEIRegistration2AForm', 'WEIMembership1AForm', 'WEIMembershipForm', 'BusForm', 'BusTeamForm',
'WEIForm', 'WEIRegistrationForm', 'WEIMembership1AForm', 'WEIMembershipForm', 'BusForm', 'BusTeamForm',
'WEISurvey', 'WEISurveyInformation', 'WEISurveyAlgorithm', 'CurrentSurvey',
]

View File

@@ -44,7 +44,7 @@ class WEIRegistrationForm(forms.ModelForm):
fields = [
'user', 'soge_credit', 'birth_date', 'gender', 'clothing_size',
'health_issues', 'emergency_contact_name', 'emergency_contact_phone',
'first_year', 'information_json', 'deposit_check'
'first_year', 'information_json', 'deposit_given', 'deposit_type'
]
widgets = {
"user": Autocomplete(
@@ -59,30 +59,17 @@ class WEIRegistrationForm(forms.ModelForm):
'minDate': '1900-01-01',
'maxDate': '2100-01-01'
}),
"deposit_check": forms.BooleanField(
required=False,
"deposit_given": forms.CheckboxInput(
attrs={'class': 'form-check-input'},
),
}
class WEIRegistration2AForm(WEIRegistrationForm):
class Meta(WEIRegistrationForm.Meta):
fields = WEIRegistrationForm.Meta.fields + ['deposit_type']
widgets = WEIRegistrationForm.Meta.widgets.copy()
widgets.update({
"deposit_type": forms.RadioSelect(),
})
class WEIRegistration1AForm(WEIRegistrationForm):
class Meta(WEIRegistrationForm.Meta):
fields = WEIRegistrationForm.Meta.fields
}
class WEIChooseBusForm(forms.Form):
bus = forms.ModelMultipleChoiceField(
queryset=Bus.objects,
label=_("bus"),
label=_("Bus"),
help_text=_("This choice is not definitive. The WEI organizers are free to attribute for you a bus and a team,"
+ " in particular if you are a free eletron."),
widget=CheckboxSelectMultiple(),
@@ -174,7 +161,7 @@ class WEIMembership1AForm(WEIMembershipForm):
"""
Used to confirm registrations of first year members without choosing a bus now.
"""
deposit_check = None
deposit_given = None
roles = None
def clean(self):

View File

@@ -14,7 +14,8 @@ from django.utils.translation import gettext_lazy as _
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, WEIBusInformation
from ...models import WEIMembership, Bus
WORDS = [
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',
@@ -23,7 +24,129 @@ WORDS = [
'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]',
4: 'Réponse 1 Y2[KAR]',
2: 'Réponse 1 Tcherno[bus]',
5: 'Réponse 1 [Kar]tier',
1: 'Réponse 1 [Car]cassonne',
6: 'Réponse 1 O[car]ina',
7: 'Réponse 1 Show[bus]',
8: 'Réponse 1 [Car]ioca'
}
],
'Question 2': [
'Description 2',
{
3: 'Réponse 2 Madagas[car]',
4: 'Réponse 2 Y2[KAR]',
2: 'Réponse 2 Tcherno[bus]',
5: 'Réponse 2 [Kar]tier',
1: 'Réponse 2 [Car]cassonne',
6: 'Réponse 2 O[car]ina',
7: 'Réponse 2 Show[bus]',
8: 'Réponse 2 [Car]ioca'
}
],
'Question 3': [
'Description 3',
{
3: 'Réponse 3 Madagas[car]',
4: 'Réponse 3 Y2[KAR]',
2: 'Réponse 3 Tcherno[bus]',
5: 'Réponse 3 [Kar]tier',
1: 'Réponse 3 [Car]cassonne',
6: 'Réponse 3 O[car]ina',
7: 'Réponse 3 Show[bus]',
8: 'Réponse 3 [Car]ioca'
}
],
'Question 4': [
'Description 4',
{
3: 'Réponse 4 Madagas[car]',
4: 'Réponse 4 Y2[KAR]',
2: 'Réponse 4 Tcherno[bus]',
5: 'Réponse 4 [Kar]tier',
1: 'Réponse 4 [Car]cassonne',
6: 'Réponse 4 O[car]ina',
7: 'Réponse 4 Show[bus]',
8: 'Réponse 4 [Car]ioca'
}
],
'Question 5': [
'Description 5',
{
3: 'Réponse 5 Madagas[car]',
4: 'Réponse 5 Y2[KAR]',
2: 'Réponse 5 Tcherno[bus]',
5: 'Réponse 5 [Kar]tier',
1: 'Réponse 5 [Car]cassonne',
6: 'Réponse 5 O[car]ina',
7: 'Réponse 5 Show[bus]',
8: 'Réponse 5 [Car]ioca'
}
],
'Question 6': [
'Description 6',
{
3: 'Réponse 6 Madagas[car]',
4: 'Réponse 6 Y2[KAR]',
2: 'Réponse 6 Tcherno[bus]',
5: 'Réponse 6 [Kar]tier',
1: 'Réponse 6 [Car]cassonne',
6: 'Réponse 6 O[car]ina',
7: 'Réponse 6 Show[bus]',
8: 'Réponse 6 [Car]ioca'
}
],
'Question 7': [
'Description 7',
{
3: 'Réponse 7 Madagas[car]',
4: 'Réponse 7 Y2[KAR]',
2: 'Réponse 7 Tcherno[bus]',
5: 'Réponse 7 [Kar]tier',
1: 'Réponse 7 [Car]cassonne',
6: 'Réponse 7 O[car]ina',
7: 'Réponse 7 Show[bus]',
8: 'Réponse 7 [Car]ioca'
}
],
'Question 8': [
'Description 8',
{
3: 'Réponse 8 Madagas[car]',
4: 'Réponse 8 Y2[KAR]',
2: 'Réponse 8 Tcherno[bus]',
5: 'Réponse 8 [Kar]tier',
1: 'Réponse 8 [Car]cassonne',
6: 'Réponse 8 O[car]ina',
7: 'Réponse 8 Show[bus]',
8: 'Réponse 8 [Car]ioca'
}
],
'Question 9': [
'Description 9',
{
3: 'Réponse 9 Madagas[car]',
4: 'Réponse 9 Y2[KAR]',
2: 'Réponse 9 Tcherno[bus]',
5: 'Réponse 9 [Kar]tier',
1: 'Réponse 9 [Car]cassonne',
6: 'Réponse 9 O[car]ina',
7: 'Réponse 9 Show[bus]',
8: '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,13 +166,18 @@ 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), [])
@@ -63,19 +186,36 @@ class WEISurveyForm2025(forms.Form):
else:
average_score = 0
preferred_words = {bus: [word for word in WORDS
if informations[bus].scores[word] >= average_score]
for bus in buses}
preferred_words = {
bus: [word for word in WORDS['list'] 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]
self.fields["words"].choices = [(w, w) for w in all_preferred_words]
else:
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,
)
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,9 +330,19 @@ class WEISurvey2025(WEISurvey):
@transaction.atomic
def form_valid(self, form):
word = form.cleaned_data["word"]
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
setattr(self.information, "word" + str(self.information.step), word)
self.save()
@classmethod
@@ -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()
@@ -199,24 +365,41 @@ class WEISurvey2025(WEISurvey):
return sum([cls.get_algorithm_class().get_bus_information(bus).scores[word] for bus in buses]) / buses.count()
@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():
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
- self.word_mean(getattr(self.information, 'word' + str(i))) for i in range(1, 1 + NB_WORDS)) / self.get_algorithm_class().get_buses().count()
return s
@lru_cache()
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()
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.sort(key=lambda item: -item[1])
values.sort(key=lambda item: -item[1][0])
return values
@classmethod
@@ -243,10 +426,18 @@ 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.
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 = [s for s in surveys if s.is_complete()] # Don't consider invalid surveys
@@ -307,7 +498,7 @@ class WEISurveyAlgorithm2025(WEISurveyAlgorithm):
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:
for bus, current_scores 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)
@@ -322,8 +513,8 @@ class WEISurveyAlgorithm2025(WEISurveyAlgorithm):
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
score2 = survey2.score_questions(bus)
if current_scores[1] <= score2: # Ignore better students
continue
if least_preferred_survey is None or score2 < least_score:
least_preferred_survey = survey2

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-07-19 12:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wei', '0015_remove_weiclub_caution_amount_and_more'),
]
operations = [
migrations.AddField(
model_name='weiregistration',
name='fee',
field=models.PositiveIntegerField(blank=True, default=0, verbose_name='fee'),
),
migrations.AlterField(
model_name='weiclub',
name='fee_soge_credit',
field=models.PositiveIntegerField(default=2000, verbose_name='membership fee (soge credit)'),
),
]

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

@@ -0,0 +1,22 @@
# Generated by Django 5.2.4 on 2025-08-02 17:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wei', '0017_alter_weiclub_fee_soge_credit'),
]
operations = [
migrations.RemoveField(
model_name='weiregistration',
name='deposit_check',
),
migrations.AddField(
model_name='weiregistration',
name='deposit_given',
field=models.BooleanField(default=False, verbose_name='Deposit given'),
),
]

View File

@@ -40,7 +40,7 @@ class WEIClub(Club):
fee_soge_credit = models.PositiveIntegerField(
verbose_name=_("membership fee (soge credit)"),
default=2000,
default=0,
)
class Meta:
@@ -202,9 +202,9 @@ class WEIRegistration(models.Model):
verbose_name=_("Credit from Société générale"),
)
deposit_check = models.BooleanField(
deposit_given = models.BooleanField(
default=False,
verbose_name=_("Deposit check given")
verbose_name=_("Deposit given")
)
deposit_type = models.CharField(
@@ -285,6 +285,12 @@ class WEIRegistration(models.Model):
"encoded in JSON"),
)
fee = models.PositiveIntegerField(
default=0,
verbose_name=_('fee'),
blank=True,
)
class Meta:
unique_together = ('user', 'wei',)
verbose_name = _("WEI User")
@@ -309,7 +315,25 @@ class WEIRegistration(models.Model):
self.information_json = json.dumps(information, indent=2)
@property
def fee(self):
def is_validated(self):
try:
return self.membership is not None
except AttributeError:
return False
@property
def validation_status(self):
"""
Define an order to have easier access to validatable registrations
"""
if self.fee + (self.wei.deposit_amount if self.deposit_type == 'note' else 0) > self.user.note.balance:
return 2
elif self.first_year:
return 1
else:
return 0
def calculate_fee(self):
bde = Club.objects.get(pk=1)
kfet = Club.objects.get(pk=2)
@@ -336,12 +360,9 @@ class WEIRegistration(models.Model):
return fee
@property
def is_validated(self):
try:
return self.membership is not None
except AttributeError:
return False
def save(self, *args, **kwargs):
self.fee = self.calculate_fee()
super().save(*args, **kwargs)
class WEIMembership(Membership):

View File

@@ -58,8 +58,8 @@ class WEIRegistrationTable(tables.Table):
validate = tables.Column(
verbose_name=_("Validate"),
orderable=False,
accessor=A('pk'),
orderable=True,
accessor='validate_status',
attrs={
'th': {
'id': 'validate-membership-header'
@@ -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': {
@@ -84,6 +84,35 @@ class WEIRegistrationTable(tables.Table):
},
)
def render_deposit_type(self, record):
if record.first_year:
return format_html("")
if record.deposit_type == 'check':
# TODO Install Font Awesome 6 to acces more icons (and keep compaibility with current used v4)
return format_html("""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="1.5em" height="1.5em"
fill="currentColor" style="position: relative; left: -0.15em;">
<path d="
M128 128C92.7 128 64 156.7 64 192L64 448C64 483.3 92.7 512 128 512L512 512
C547.3 512 576 483.3 576 448L576 192C576 156.7 547.3 128 512 128L128 128z
M360 352L488 352C501.3 352 512 362.7 512 376C512 389.3 501.3 400 488 400L360 400
C346.7 400 336 389.3 336 376C336 362.7 346.7 352 360 352z
M336 264C336 250.7 346.7 240 360 240L488 240C501.3 240 512 250.7 512 264
C512 277.3 501.3 288 488 288L360 288C346.7 288 336 277.3 336 264z
M212 208C223 208 232 217 232 228L232 232L240 232C251 232 260 241 260 252
C260 263 251 272 240 272L192.5 272C185.6 272 180 277.6 180 284.5
C180 290.6 184.4 295.8 190.4 296.8L232.1 303.8C257.4 308 276 329.9 276 355.6
C276 381.7 257 403.3 232 407.4L232 412.1C232 423.1 223 432.1 212 432.1
C201 432.1 192 423.1 192 412.1L192 408.1L168 408.1C157 408.1 148 399.1 148 388.1
C148 377.1 157 368.1 168 368.1L223.5 368.1C230.4 368.1 236 362.5 236 355.6
C236 349.5 231.6 344.3 225.6 343.3L183.9 336.3C158.5 332 140 310.1 140 284.5
C140 255.7 163.2 232.3 192 232L192 228C192 217 201 208 212 208z
" />
</svg>
""")
if record.deposit_type == 'note':
return format_html("<i class=\"fa fa-exchange\"></i>")
def render_validate(self, record):
hasperm = PermissionBackend.check_perm(
get_current_request(), "wei.add_weimembership", WEIMembership(
@@ -98,12 +127,13 @@ class WEIRegistrationTable(tables.Table):
if not hasperm:
return format_html("<span class='no-perm'></span>")
url = reverse_lazy('wei:wei_update_registration', args=(record.pk,)) + '?validate=true'
url = reverse_lazy('wei:validate_registration', args=(record.pk,))
text = _('Validate')
if record.fee > record.user.note.balance and not record.soge_credit:
status = record.validation_status
if status == 2:
btn_class = 'btn-secondary'
tooltip = _("The user does not have enough money.")
elif record.first_year:
elif status == 1:
btn_class = 'btn-info'
tooltip = _("The user is in first year. You may validate the credit, the algorithm will run later.")
else:
@@ -121,10 +151,11 @@ class WEIRegistrationTable(tables.Table):
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
order_by = ('validate', 'user',)
model = WEIRegistration
template_name = 'django_tables2/bootstrap4.html'
fields = ('user', 'user__first_name', 'user__last_name', 'first_year', 'deposit_check',
'edit', 'validate', 'delete',)
fields = ('user', 'user__first_name', 'user__last_name', 'first_year', 'deposit_given',
'deposit_type', 'edit', 'validate', 'delete',)
row_attrs = {
'class': 'table-row',
'id': lambda record: "row-" + str(record.pk),
@@ -134,8 +165,8 @@ class WEIRegistrationTable(tables.Table):
class WEIMembershipTable(tables.Table):
user = tables.LinkColumn(
'wei:wei_update_registration',
args=[A('registration__pk')],
'wei:wei_update_membership',
args=[A('pk')],
)
year = tables.Column(
@@ -156,6 +187,35 @@ class WEIMembershipTable(tables.Table):
def render_year(self, record):
return str(record.user.profile.ens_year) + "A"
def render_registration__deposit_type(self, record):
if record.registration.first_year:
return format_html("")
if record.registration.deposit_type == 'check':
# TODO Install Font Awesome 6 to acces more icons (and keep compaibility with current used v4)
return format_html("""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="1.5em" height="1.5em"
fill="currentColor" style="position: relative; left: -0.15em;">
<path d="
M128 128C92.7 128 64 156.7 64 192L64 448C64 483.3 92.7 512 128 512L512 512
C547.3 512 576 483.3 576 448L576 192C576 156.7 547.3 128 512 128L128 128z
M360 352L488 352C501.3 352 512 362.7 512 376C512 389.3 501.3 400 488 400L360 400
C346.7 400 336 389.3 336 376C336 362.7 346.7 352 360 352z
M336 264C336 250.7 346.7 240 360 240L488 240C501.3 240 512 250.7 512 264
C512 277.3 501.3 288 488 288L360 288C346.7 288 336 277.3 336 264z
M212 208C223 208 232 217 232 228L232 232L240 232C251 232 260 241 260 252
C260 263 251 272 240 272L192.5 272C185.6 272 180 277.6 180 284.5
C180 290.6 184.4 295.8 190.4 296.8L232.1 303.8C257.4 308 276 329.9 276 355.6
C276 381.7 257 403.3 232 407.4L232 412.1C232 423.1 223 432.1 212 432.1
C201 432.1 192 423.1 192 412.1L192 408.1L168 408.1C157 408.1 148 399.1 148 388.1
C148 377.1 157 368.1 168 368.1L223.5 368.1C230.4 368.1 236 362.5 236 355.6
C236 349.5 231.6 344.3 225.6 343.3L183.9 336.3C158.5 332 140 310.1 140 284.5
C140 255.7 163.2 232.3 192 232L192 228C192 217 201 208 212 208z
" />
</svg>
""")
if record.registration.deposit_type == 'note':
return format_html("<i class=\"fa fa-exchange\"></i>")
class Meta:
attrs = {
'class': 'table table-condensed table-striped table-hover'
@@ -163,7 +223,7 @@ class WEIMembershipTable(tables.Table):
model = WEIMembership
template_name = 'django_tables2/bootstrap4.html'
fields = ('user', 'user__last_name', 'user__first_name', 'registration__gender', 'user__profile__department',
'year', 'bus', 'team', 'registration__deposit_check', )
'year', 'bus', 'team', 'registration__deposit_given', 'registration__deposit_type')
row_attrs = {
'class': 'table-row',
'id': lambda record: "row-" + str(record.pk),

View File

@@ -50,7 +50,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endif %}
{% if club.deposit_amount > 0 %}
<dt class="col-xl-6">{% trans 'Deposit amount'|capfirst %}</dt>
<dt class="col-xl-6">{% trans 'deposit amount'|capfirst %}</dt>
<dd class="col-xl-6">{{ club.deposit_amount|pretty_money }}</dd>
{% endif %}

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 %}
@@ -67,20 +72,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div>
{% endif %}
{% if history_list.data %}
<div class="card bg-white mb-3">
<div class="card-header position-relative" id="historyListHeading">
<a class="stretched-link font-weight-bold text-decoration-none" {% if "note.view_note"|has_perm:club.note %}
href="{% url 'note:transactions' pk=club.note.pk %}" {% endif %}>
<i class="fa fa-euro"></i> {% trans "Transaction history" %}
</a>
</div>
<div id="history_list">
{% render_table history_list %}
</div>
</div>
{% endif %}
{% if pre_registrations.data %}
<div class="card bg-white mb-3">
<div class="card-header position-relative" id="historyListHeading">
@@ -99,6 +90,19 @@ SPDX-License-Identifier: GPL-3.0-or-later
<a href="{% url 'wei:wei_1A_list' pk=object.pk %}" class="btn btn-block btn-info">{% trans "Attribute buses" %}</a>
{% endif %}
{% if history_list.data %}
<div class="card bg-white mt-3">
<div class="card-header position-relative" id="historyListHeading">
<a class="stretched-link font-weight-bold text-decoration-none" {% if "note.view_note"|has_perm:club.note %}
href="{% url 'note:transactions' pk=club.note.pk %}" {% endif %}>
<i class="fa fa-euro"></i> {% trans "Transaction history" %}
</a>
</div>
<div id="history_list">
{% render_table history_list %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -96,7 +96,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endif %}
{% else %}
<dt class="col-xl-6">{% trans 'Deposit check given'|capfirst %}</dt>
<dd class="col-xl-6">{{ registration.deposit_check|yesno }}</dd>
<dd class="col-xl-6">{{ registration.deposit_given|yesno }}</dd>
{% with information=registration.information %}
<dt class="col-xl-6">{% trans 'preferred bus'|capfirst %}</dt>
@@ -143,12 +143,13 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endblocktrans %}
</div>
{% 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>
<ul>
<li>{% blocktrans trimmed with amount=fee|pretty_money %}
Membership fees: {{ amount }}
{% endblocktrans %}</li>
{% if not registration.first_year %}
{% if registration.deposit_type == 'note' %}
<li>{% blocktrans trimmed with amount=club.deposit_amount|pretty_money %}
Deposit (by Note transaction): {{ amount }}
@@ -158,6 +159,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
Deposit (by check): {{ amount }}
{% endblocktrans %}</li>
{% endif %}
{% endif %}
<li><strong>{% blocktrans trimmed with total=total_needed|pretty_money %}
Total needed: {{ total }}
{% endblocktrans %}</strong></li>
@@ -167,9 +169,9 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endblocktrans %}</p>
</div>
{% if not registration.deposit_check and not registration.first_year and registration.caution_type == 'check' %}
{% if not registration.deposit_given and not registration.first_year and registration.caution_type == 'check' %}
<div class="alert alert-danger">
{% trans "The user didn't give her/his caution check." %}
{% trans "The user didn't give her/his caution." %}
</div>
{% endif %}
@@ -213,7 +215,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
$("input[name='bus']:checked").each(function (ignored) {
buses.push($(this).parent().text().trim());
});
console.log(buses);
$("input[name='team']").each(function () {
let label = $(this).parent();
$(this).parent().addClass('d-none');

View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body" id="form">
<form method="post">
{% csrf_token %}
{{ form | crispy }}
<button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
</form>
</div>
</div>
{% endblock %}
{% block extrajavascript %}
<script>
$(document).ready(function () {
function refreshTeams() {
let buses = [];
$("input[name='bus']:checked").each(function (ignored) {
buses.push($(this).parent().text().trim());
});
$("input[name='team']").each(function () {
let label = $(this).parent();
$(this).parent().addClass('d-none');
buses.forEach(function (bus) {
if (label.text().includes(bus))
label.removeClass('d-none');
});
});
}
$("input[name='bus']").change(refreshTeams);
refreshTeams();
});
</script>
{% endblock %}

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
@@ -30,12 +30,12 @@ class TestWEIAlgorithm(TestCase):
)
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)
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()
@@ -74,7 +74,7 @@ class TestWEIAlgorithm(TestCase):
Buses are full of first year people, ensure that they are happy
"""
# Add a lot of users
for i in range(95):
for i in range(80):
user = User.objects.create(username=f"user{i}")
registration = WEIRegistration.objects.create(
user=user,
@@ -83,11 +83,14 @@ 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()
survey = WEISurvey2025(registration)
# Run algorithm
WEISurvey2025.get_algorithm_class()().run_algorithm()
@@ -102,10 +105,25 @@ class TestWEIAlgorithm(TestCase):
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
'''print(buses)
print(chosen_bus)'''
self.assertIn(chosen_bus, [x[0] for x in buses])
score_questions, score_words = next(scores for bus, scores in buses if bus == chosen_bus)
max_score_questions = max(buses[i][1][0] for i in range(len(buses)))
max_score_words = max(buses[i][1][1] for i in range(len(buses)))
penalty += (max_score_words - score_words) ** 2
penalty += (max_score_questions - score_questions) ** 2
self.assertLessEqual(penalty / 100, 25) # Tolerance of 5 %
# There shouldn't be users who would prefer to switch buses
for r1 in WEIRegistration.objects.filter(wei=self.wei).all():
survey1 = WEISurvey2025(r1)
bus1 = survey1.information.get_selected_bus()
for r2 in WEIRegistration.objects.filter(wei=self.wei, pk__gt=r1.pk):
survey2 = WEISurvey2025(r2)
bus2 = survey2.information.get_selected_bus()
prefer_switch_bus_words = survey1.score_words(bus2) > survey1.score_words(bus1) and survey2.score_words(bus1) > survey2.score_words(bus2)
prefer_switch_bus_questions = survey1.score_questions(bus2) > survey1.score_questions(bus1) and\
survey2.score_questions(bus1) > survey2.score_questions(bus2)
self.assertFalse(prefer_switch_bus_words and prefer_switch_bus_questions)

View File

@@ -101,7 +101,7 @@ class TestWEIRegistration(TestCase):
user_id=self.user.id,
wei_id=self.wei.id,
soge_credit=True,
deposit_check=True,
deposit_given=True,
birth_date=date(2000, 1, 1),
gender="nonbinary",
clothing_cut="male",
@@ -642,7 +642,7 @@ class TestWEIRegistration(TestCase):
last_name="admin",
first_name="admin",
bank="Société générale",
deposit_check=True,
deposit_given=True,
))
self.assertEqual(response.status_code, 200)
self.assertFalse(response.context["form"].is_valid())
@@ -657,7 +657,7 @@ class TestWEIRegistration(TestCase):
last_name="admin",
first_name="admin",
bank="Société générale",
deposit_check=True,
deposit_given=True,
))
self.assertRedirects(response, reverse("wei:wei_registrations", kwargs=dict(pk=self.registration.wei.pk)), 302, 200)
@@ -813,7 +813,7 @@ class TestWeiAPI(TestAPI):
user_id=self.user.id,
wei_id=self.wei.id,
soge_credit=True,
deposit_check=True,
deposit_given=True,
birth_date=date(2000, 1, 1),
gender="nonbinary",
clothing_cut="male",

View File

@@ -7,7 +7,7 @@ from .views import CurrentWEIDetailView, WEI1AListView, WEIListView, WEICreateVi
WEIRegistrationsView, WEIMembershipsView, MemberListRenderView, BusInformationUpdateView, \
BusCreateView, BusManageView, BusUpdateView, BusTeamCreateView, BusTeamManageView, BusTeamUpdateView, \
WEIAttributeBus1AView, WEIAttributeBus1ANextView, WEIRegister1AView, WEIRegister2AView, WEIUpdateRegistrationView, \
WEIDeleteRegistrationView, WEIValidateRegistrationView, WEISurveyView, WEISurveyEndView, WEIClosedView
WEIDeleteRegistrationView, WEIValidateRegistrationView, WEISurveyView, WEISurveyEndView, WEIClosedView, WEIUpdateMembershipView
app_name = 'wei'
urlpatterns = [
@@ -43,4 +43,6 @@ urlpatterns = [
path('bus-1A/<int:pk>/', WEIAttributeBus1AView.as_view(), name="wei_bus_1A"),
path('bus-1A/next/<int:pk>/', WEIAttributeBus1ANextView.as_view(), name="wei_bus_1A_next"),
path('update-bus-info/<int:pk>/', BusInformationUpdateView.as_view(), name="update_bus_info"),
path('edit_membership/<int:pk>/', WEIUpdateMembershipView.as_view(), name="wei_update_membership"),
]

View File

@@ -13,7 +13,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.db.models import Q, Count
from django.db.models import Q, Count, Case, When, Value, IntegerField, F
from django.db.models.functions.text import Lower
from django import forms
from django.http import HttpResponse, Http404
@@ -35,7 +35,7 @@ from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from .forms.registration import WEIChooseBusForm
from .models import WEIClub, WEIRegistration, WEIMembership, Bus, BusTeam, WEIRole
from .forms import WEIForm, WEIRegistrationForm, WEIRegistration1AForm, WEIRegistration2AForm, BusForm, BusTeamForm, WEIMembership1AForm, \
from .forms import WEIForm, WEIRegistrationForm, BusForm, BusTeamForm, WEIMembership1AForm, \
WEIMembershipForm, CurrentSurvey
from .tables import BusRepartitionTable, BusTable, BusTeamTable, WEITable, WEIRegistrationTable, \
WEIRegistration1ATable, WEIMembershipTable
@@ -133,6 +133,23 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, D
membership=None,
wei=club
)
# Annotate the query to be able to sort registrations on validate status
pre_registrations = pre_registrations.annotate(
deposit=Case(
When(deposit_type='note', then=F('wei__deposit_amount')),
default=Value(0),
output_field=IntegerField()
)
).annotate(
total_fee=F('fee') + F('deposit')
).annotate(
validate_status=Case(
When(total_fee__gt=F('user__note__balance'), then=Value(2)),
When(first_year=True, then=Value(1)),
default=Value(0),
output_field=IntegerField(),
)
)
buses = Bus.objects.filter(PermissionBackend.filter_queryset(self.request, Bus, "view")) \
.filter(wei=self.object).annotate(count=Count("memberships")).order_by("name")
return [club_transactions, club_member, pre_registrations, buses, ]
@@ -260,6 +277,23 @@ class WEIRegistrationsView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTable
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs).filter(wei=self.club, membership=None).distinct()
# Annotate the query to be able to sort registrations on validate status
qs = qs.annotate(
deposit=Case(
When(deposit_type='note', then=F('wei__deposit_amount')),
default=Value(0),
output_field=IntegerField()
)
).annotate(
total_fee=F('fee') + F('deposit')
).annotate(
validate_status=Case(
When(total_fee__gt=F('user__note__balance'), then=Value(2)),
When(first_year=True, then=Value(1)),
default=Value(0),
output_field=IntegerField(),
)
)
pattern = self.request.GET.get("search", "")
@@ -510,7 +544,7 @@ class WEIRegister1AView(ProtectQuerysetMixin, ProtectedCreateView):
Register a new user to the WEI
"""
model = WEIRegistration
form_class = WEIRegistration1AForm
form_class = WEIRegistrationForm
extra_context = {"title": _("Register first year student to the WEI")}
def get_sample_object(self):
@@ -560,8 +594,8 @@ class WEIRegister1AView(ProtectQuerysetMixin, ProtectedCreateView):
# Cacher les champs pendant l'inscription initiale
if "first_year" in form.fields:
del form.fields["first_year"]
if "deposit_check" in form.fields:
del form.fields["deposit_check"]
if "deposit_given" in form.fields:
del form.fields["deposit_given"]
if "information_json" in form.fields:
del form.fields["information_json"]
if "deposit_type" in form.fields:
@@ -606,7 +640,7 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
Register an old user to the WEI
"""
model = WEIRegistration
form_class = WEIRegistration2AForm
form_class = WEIRegistrationForm
extra_context = {"title": _("Register old student to the WEI")}
def get_sample_object(self):
@@ -670,8 +704,8 @@ class WEIRegister2AView(ProtectQuerysetMixin, ProtectedCreateView):
# Cacher les champs pendant l'inscription initiale
if "first_year" in form.fields:
del form.fields["first_year"]
if "deposit_check" in form.fields:
del form.fields["deposit_check"]
if "deposit_given" in form.fields:
del form.fields["deposit_given"]
if "information_json" in form.fields:
del form.fields["information_json"]
@@ -739,14 +773,11 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
if today >= wei.date_start or today < wei.membership_start:
return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,)))
# Store the validate parameter in the view's state
self.should_validate = request.GET.get('validate', False)
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["club"] = self.object.wei
# Pass the validate parameter to the template
context["should_validate"] = self.should_validate
if self.object.is_validated:
membership_form = self.get_membership_form(instance=self.object.membership,
@@ -767,11 +798,6 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
choose_bus_form.fields["team"].queryset = BusTeam.objects.filter(bus__wei=context["club"])
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
def get_form(self, form_class=None):
@@ -780,15 +806,23 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
# The auto-json-format may cause issues with the default field remove
if "information_json" in form.fields:
del form.fields["information_json"]
# Masquer le champ deposit_check pour tout le monde dans le formulaire de modification
if "deposit_check" in form.fields:
del form.fields["deposit_check"]
# Masquer le champ deposit_given pour tout le monde dans le formulaire de modification
if "deposit_given" in form.fields:
form.fields["deposit_given"].help_text = _("Tick if the deposit check has been given")
if self.object.first_year or self.object.deposit_type == 'note':
del form.fields["deposit_given"]
# S'assurer que le champ deposit_type est obligatoire pour les 2A+
if not self.object.first_year and "deposit_type" in form.fields:
if "deposit_type" in form.fields:
if self.object.first_year:
del form.fields["deposit_type"]
else:
form.fields["deposit_type"].required = True
form.fields["deposit_type"].help_text = _("Choose how you want to pay the deposit")
form.fields["deposit_type"].widget = forms.RadioSelect(choices=form.fields["deposit_type"].choices)
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
@@ -849,7 +883,6 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
information["preferred_roles_name"] = [role.name for role in choose_bus_form.cleaned_data["roles"]]
form.instance.information = information
# Sauvegarder le type de caution pour les 2A+
if "deposit_type" in form.cleaned_data:
form.instance.deposit_type = form.cleaned_data["deposit_type"]
form.instance.save()
@@ -862,9 +895,6 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update
survey = CurrentSurvey(self.object)
if not survey.is_complete():
return reverse_lazy("wei:wei_survey", kwargs={"pk": self.object.pk})
# On redirige vers la validation uniquement si c'est explicitement demandé (et stocké dans la vue)
if self.should_validate and self.request.user.has_perm("wei.add_weimembership"):
return reverse_lazy("wei:validate_registration", kwargs={"pk": self.object.pk})
return reverse_lazy("wei:wei_detail", kwargs={"pk": self.object.wei.pk})
@@ -963,9 +993,9 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
form = context["form"]
if registration.soge_credit:
form.fields["credit_amount"].initial = registration.fee
form.fields["credit_amount"].initial = fee
else:
form.fields["credit_amount"].initial = max(0, registration.fee - registration.user.note.balance)
form.fields["credit_amount"].initial = max(0, fee - registration.user.note.balance)
return context
@@ -988,17 +1018,18 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
form.fields["last_name"].initial = registration.user.last_name
form.fields["first_name"].initial = registration.user.first_name
# Ajouter le champ deposit_check uniquement pour les non-première année et le rendre obligatoire
# Ajouter le champ deposit_given uniquement pour les non-première année et le rendre obligatoire
if not registration.first_year:
if registration.deposit_type == 'check':
form.fields["deposit_check"] = forms.BooleanField(
form.fields["deposit_given"] = forms.BooleanField(
required=True,
initial=registration.deposit_check,
disabled=True,
initial=registration.deposit_given,
label=_("Deposit check given"),
help_text=_("Please make sure the check is given before validating the registration")
help_text=_("Only treasurers can validate this field")
)
else:
form.fields["deposit_check"] = forms.BooleanField(
form.fields["deposit_given"] = forms.BooleanField(
required=True,
initial=False,
label=_("Create deposit transaction"),
@@ -1039,8 +1070,8 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
club = registration.wei
user = registration.user
if "deposit_check" in form.data:
registration.deposit_check = form.data["deposit_check"] == "on"
if "deposit_given" in form.data:
registration.deposit_given = form.data["deposit_given"] == "on"
registration.save()
membership = form.instance
membership.user = user
@@ -1052,7 +1083,7 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
fee = club.membership_fee_paid if user.profile.paid else club.membership_fee_unpaid
if registration.soge_credit:
fee = 2000
fee = registration.wei.fee_soge_credit
kfet = club.parent_club
bde = kfet.parent_club
@@ -1096,16 +1127,16 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
'credit': credit_amount,
'needed': total_needed}
)
return super().form_invalid(form)
return self.form_invalid(form)
if credit_amount:
if not last_name:
form.add_error('last_name', _("This field is required."))
return super().form_invalid(form)
return self.form_invalid(form)
if not first_name:
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
SpecialTransaction.objects.create(
@@ -1149,11 +1180,61 @@ class WEIValidateRegistrationView(ProtectQuerysetMixin, ProtectedCreateView):
return super().form_valid(form)
def form_invalid(self, form):
registration = getattr(form.instance, "registration", None)
if registration is not None:
registration.deposit_given = False
registration.save()
return super().form_invalid(form)
def get_success_url(self):
self.object.refresh_from_db()
return reverse_lazy("wei:wei_registrations", kwargs={"pk": self.object.club.pk})
class WEIUpdateMembershipView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
"""
Update a membership for the WEI
"""
model = WEIMembership
context_object_name = "membership"
template_name = "wei/weimembership_update.html"
extra_context = {"title": _("Update WEI Membership")}
def dispatch(self, request, *args, **kwargs):
wei = self.get_object().registration.wei
today = date.today()
# We can't update a registration once the WEI is started and before the membership start date
if today >= wei.date_start or today < wei.membership_start:
return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,)))
# Store the validate parameter in the view's state
return super().dispatch(request, *args, **kwargs)
def get_form(self):
form = WEIMembershipForm(
self.request.POST or None,
self.request.FILES or None,
instance=self.object,
wei=self.object.registration.wei,
)
form.fields["roles"].initial = self.object.roles.all()
form.fields["bus"].initial = self.object.bus
form.fields["team"].initial = self.object.team
del form.fields["credit_type"]
del form.fields["credit_amount"]
del form.fields["first_name"]
del form.fields["last_name"]
del form.fields["bank"]
return form
def get_success_url(self):
print("get_success_url")
return reverse_lazy("wei:wei_detail", kwargs={"pk": self.object.registration.wei.pk})
class WEISurveyView(LoginRequiredMixin, BaseFormView, DetailView):
"""
Display the survey for the WEI for first year members.
@@ -1176,6 +1257,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,)))

File diff suppressed because it is too large Load Diff

View File

@@ -151,3 +151,33 @@ msgid "An error occured while (in)validating this transaction:"
msgstr ""
"Une erreur est survenue lors de la validation/dévalidation de cette "
"transaction :"
msgid "Recent achievements history"
msgstr "Historique des derniers succès"
msgid "Family"
msgstr "Famille"
msgid "obtained at"
msgstr "Réalisé le"
msgid "Return to the family list"
msgstr "Retour à la liste des familles"
msgid "rank"
msgstr "rang"
msgid "Challenge"
msgstr "Défis"
msgid "Invalid achievements history"
msgstr "Historique des défis invalides"
msgid "Valid achievements history"
msgstr "Historique des défis valides"
msgid "Return to management page"
msgstr "Retour à la page de gestion"