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

Compare commits

..

32 Commits

Author SHA1 Message Date
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
quark
8c3ae338ea fix organizer field error 2025-07-22 18:20:05 +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
57 changed files with 981 additions and 2434 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

@@ -19,10 +19,6 @@ if "activity" in settings.INSTALLED_APPS:
from activity.api.urls import register_activity_urls
register_activity_urls(router, 'activity')
if "family" in settings.INSTALLED_APPS:
from family.api.urls import register_family_urls
register_family_urls(router, 'family')
if "food" in settings.INSTALLED_APPS:
from food.api.urls import register_food_urls
register_food_urls(router, 'food')

View File

@@ -1,46 +0,0 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from rest_framework import serializers
from ..models import Family, FamilyMembership, Challenge, Achievement
class FamilySerializer(serializers.ModelSerializer):
"""
REST API Serializer for Family.
The djangorestframework plugin will analyse the model `Family` and parse all fields in the API.
"""
class Meta:
model = Family
fields = '__all__'
class FamilyMembershipSerializer(serializers.ModelSerializer):
"""
REST API Serializer for FamilyMembership.
The djangorestframework plugin will analyse the model `FamilyMembership` and parse all fields in the API.
"""
class Meta:
model = FamilyMembership
fields = '__all__'
class ChallengeSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Challenge.
The djangorestframework plugin will analyse the model `Challenge` and parse all fields in the API.
"""
class Meta:
model = Challenge
fields = '__all__'
class AchievementSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Achievement.
The djangorestframework plugin will analyse the model `Achievement` and parse all fields in the API.
"""
class Meta:
model = Achievement
fields = '__all__'

View File

@@ -1,20 +0,0 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path
from .views import FamilyViewSet, FamilyMembershipViewSet, ChallengeViewSet, AchievementViewSet, BatchAchievementsAPIView
def register_family_urls(router, path):
"""
Configure router for Family REST API
"""
router.register(path + '/family', FamilyViewSet)
router.register(path + '/familymembership', FamilyMembershipViewSet)
router.register(path + '/challenge', ChallengeViewSet)
router.register(path + '/achievement', AchievementViewSet)
urlpatterns = [
path('achievements/batch/', BatchAchievementsAPIView.as_view(), name='batch_achievements')
]

View File

@@ -1,88 +0,0 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from api.viewsets import ReadProtectedModelViewSet
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from .serializers import FamilySerializer, FamilyMembershipSerializer, ChallengeSerializer, AchievementSerializer
from ..models import Family, FamilyMembership, Challenge, Achievement
class FamilyViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Family` objects, serialize it to JSON with the given serializer,
then render it on /api/family/family/
"""
queryset = Family.objects.order_by('id')
serializer_class = FamilySerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['name', ]
search_fields = ['$name', ]
class FamilyMembershipViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `FamilyMembership` objects, serialize it to JSON with the given serializer,
then render it on /api/family/familymembership/
"""
queryset = FamilyMembership.objects.order_by('id')
serializer_class = FamilyMembershipSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['name', ]
search_fields = ['$name', ]
class ChallengeViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Challenge` objects, serialize it to JSON with the given serializer,
then render it on /api/family/challenge/
"""
queryset = Challenge.objects.order_by('id')
serializer_class = ChallengeSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['name', ]
search_fields = ['$name', ]
class AchievementViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Achievement` objects, serialize it to JSON with the given serializer,
then render it on /api/family/achievement/
"""
queryset = Achievement.objects.order_by('id')
serializer_class = AchievementSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['name', ]
search_fields = ['$name', ]
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', [])
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)
a.save(update_score=False)
for family in families:
family.update_score()
Family.update_ranking()
return Response({'status': 'ok'}, status=status.HTTP_201_CREATED)

View File

@@ -1,11 +0,0 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.utils.translation import gettext_lazy as _
from django.apps import AppConfig
class FamilyConfig(AppConfig):
name = 'family'
verbose_name = _('family')

View File

@@ -1,44 +0,0 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django import forms
from django.forms.widgets import NumberInput
from note_kfet.inputs import Autocomplete
from .models import Challenge, FamilyMembership, User, Family
class ChallengeForm(forms.ModelForm):
"""
To update a challenge
"""
class Meta:
model = Challenge
fields = ('name', 'description', 'points',)
widgets = {
"points": NumberInput()
}
class FamilyForm(forms.ModelForm):
class Meta:
model = Family
fields = ('name', 'description', )
class FamilyMembershipForm(forms.ModelForm):
class Meta:
model = FamilyMembership
fields = ('user', )
widgets = {
"user":
Autocomplete(
User,
attrs={
'api_url': '/api/user/',
'name_field': 'username',
'placeholder': 'Nom ...',
},
)
}

View File

@@ -1,73 +0,0 @@
# Generated by Django 4.2.21 on 2025-07-06 16:07
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Challenge',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='name')),
('description', models.CharField(max_length=255, verbose_name='description')),
('points', models.PositiveIntegerField(verbose_name='points')),
('obtained', models.PositiveIntegerField(default=0, verbose_name='obtained')),
],
options={
'verbose_name': 'challenge',
'verbose_name_plural': 'challenges',
},
),
migrations.CreateModel(
name='Family',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True, verbose_name='name')),
('description', models.CharField(max_length=255, verbose_name='description')),
('score', models.PositiveIntegerField(default=0, verbose_name='score')),
('rank', models.PositiveIntegerField(verbose_name='rank')),
],
options={
'verbose_name': 'Family',
'verbose_name_plural': 'Families',
},
),
migrations.CreateModel(
name='Achievement',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('obtained_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='obtained at')),
('challenge', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='family.challenge')),
('family', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='family.family', verbose_name='family')),
],
options={
'verbose_name': 'achievement',
'verbose_name_plural': 'achievements',
},
),
migrations.CreateModel(
name='FamilyMembership',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('year', models.PositiveIntegerField(default=2025, verbose_name='year')),
('family', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='members', to='family.family', verbose_name='family')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='family_memberships', to=settings.AUTH_USER_MODEL, verbose_name='user')),
],
options={
'verbose_name': 'family membership',
'verbose_name_plural': 'family memberships',
'unique_together': {('user', 'year')},
},
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 4.2.23 on 2025-07-17 15:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('family', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='family',
name='display_image',
field=models.ImageField(default='pic/default.png', max_length=255, upload_to='pic/', verbose_name='display image'),
),
]

View File

@@ -1,207 +0,0 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.db import models, transaction
from django.utils import timezone
from django.contrib.auth.models import User
from django.utils.translation import gettext_lazy as _
class Family(models.Model):
name = models.CharField(
max_length=255,
verbose_name=_('name'),
unique=True,
)
description = models.CharField(
max_length=255,
verbose_name=_('description'),
)
score = models.PositiveIntegerField(
verbose_name=_('score'),
default=0,
)
rank = models.PositiveIntegerField(
verbose_name=_('rank'),
)
display_image = models.ImageField(
verbose_name=_('display image'),
max_length=255,
blank=False,
null=False,
upload_to='pic/',
default='pic/default.png'
)
class Meta:
verbose_name = _('Family')
verbose_name_plural = _('Families')
def __str__(self):
return self.name
def update_score(self, *args, **kwargs):
challenge_set = Challenge.objects.select_for_update().filter(achievement__family=self)
points_sum = challenge_set.aggregate(models.Sum("points"))
self.score = points_sum["points__sum"]
self.save()
self.update_ranking()
@staticmethod
def update_ranking(*args, **kwargs):
"""
Update ranking when adding or removing points
"""
family_set = Family.objects.select_for_update().all().order_by("-score")
for i in range(family_set.count()):
if i == 0 or family_set[i].score != family_set[i - 1].score:
new_rank = i + 1
family = family_set[i]
family.rank = new_rank
family._force_save = True
family.save()
def save(self, *args, **kwargs):
if self.rank is None:
last_family = Family.objects.order_by("rank").last()
if last_family is None or last_family.score > self.score:
self.rank = Family.objects.count() + 1
else:
self.rank = last_family.rank
super().save(*args, **kwargs)
class FamilyMembership(models.Model):
user = models.OneToOneField(
User,
on_delete=models.PROTECT,
related_name=_('family_memberships'),
verbose_name=_('user'),
)
family = models.ForeignKey(
Family,
on_delete=models.PROTECT,
related_name=_('members'),
verbose_name=_('family'),
)
year = models.PositiveIntegerField(
verbose_name=_('year'),
default=timezone.now().year,
)
class Meta:
unique_together = ('user', 'year',)
verbose_name = _('family membership')
verbose_name_plural = _('family memberships')
def __str__(self):
return _('Family membership of {user} to {family}').format(user=self.user.username, family=self.family.name, )
class Challenge(models.Model):
name = models.CharField(
max_length=255,
verbose_name=_('name'),
)
description = models.CharField(
max_length=255,
verbose_name=_('description'),
)
points = models.PositiveIntegerField(
verbose_name=_('points'),
)
obtained = models.PositiveIntegerField(
verbose_name=_('obtained'),
default=0,
)
@transaction.atomic
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# Update families who already obtained this challenge
achievements = Achievement.objects.filter(challenge=self)
for achievement in achievements:
achievement.save()
class Meta:
verbose_name = _('challenge')
verbose_name_plural = _('challenges')
def __str__(self):
return self.name
class Achievement(models.Model):
challenge = models.ForeignKey(
Challenge,
on_delete=models.PROTECT,
)
family = models.ForeignKey(
Family,
on_delete=models.PROTECT,
verbose_name=_('family'),
)
obtained_at = models.DateTimeField(
verbose_name=_('obtained at'),
default=timezone.now,
)
class Meta:
verbose_name = _('achievement')
verbose_name_plural = _('achievements')
def __str__(self):
return _('Challenge {challenge} carried out by Family {family}').format(challenge=self.challenge.name, family=self.family.name, )
@transaction.atomic
def save(self, *args, update_score=True, **kwargs):
"""
When saving, also grants points to the family
"""
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)
if update_score:
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):
"""
When deleting, also removes points from the family
"""
# Get the family and challenge before deletion
self.family = Family.objects.select_for_update().get(pk=self.family_id)
# Delete the achievement
super().delete(*args, **kwargs)
# 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()

View File

@@ -1,449 +0,0 @@
// Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
// SPDX-License-Identifier: GPL-3.0-or-later
// When a transaction is performed, lock the interface to prevent spam clicks.
var LOCK = false
/**
* Refresh the history table on the consumptions page.
*/
function refreshHistory () {
$('#history').load('/family/manage/ #history')
}
$(document).ready(function () {
// If hash of a category in the URL, then select this category
// else select the first one
if (location.hash) {
$("a[href='" + location.hash + "']").tab('show')
} else {
$("a[data-toggle='tab']").first().tab('show')
}
// When selecting a category, change URL
$(document.body).on('click', "a[data-toggle='tab']", function () {
location.hash = this.getAttribute('href')
})
// Ensure we begin in single consumption. Fix issue with TurboLinks and BootstrapJS
document.getElementById("consume_all").addEventListener('click', consumeAll)
})
notes = []
notes_display = []
buttons = []
// When the user searches an alias, we update the auto-completion
autoCompleteFamily('note', 'note_list', notes, notes_display,
'note', 'user_note', 'profile_pic', function () {
return true
})
/**
* Add a transaction from a button.
* @param fam Where the money goes
* @param amount The price of the item
* @param type The type of the transaction (content type id for RecurrentTransaction)
* @param category_id The category identifier
* @param category_name The category name
* @param template_id The identifier of the button
* @param template_name The name of the button
*/
function addChallenge (id, name, amount) {
var challenge = null
/** Ajout de 1 à chaque clic d'un bouton déjà choisi */
buttons.forEach(function (b) {
if (b.id === id) {
b.quantity += 1
challenge = b
}
})
if (challenge == null) {
challenge = {
id: id,
name: name,
quantity: 1,
amount: amount,
}
buttons.push(challenge)
}
const dc_obj = true
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>')
})
document.getElementById(list).innerHTML = html
buttons.forEach((button) => {
document.getElementById(`conso_button_${button.id}`).addEventListener('click', () => {
if (LOCK) { return }
removeNote(button, 'conso_button', buttons, list)()
})
})
}
/**
* Reset the page as its initial state.
*/
function reset () {
console.log("reset lancée")
notes_display.length = 0
notes.length = 0
buttons.length = 0
document.getElementById('note_list').innerHTML = ''
document.getElementById('consos_list').innerHTML = ''
document.getElementById('note').value = ''
document.getElementById('note').dataset.originTitle = ''
$('#note').tooltip('hide')
document.getElementById('profile_pic').src = '/static/member/img/default_picture.png'
document.getElementById('profile_pic_link').href = '#'
refreshHistory()
LOCK = false
}
/**
* Apply all transactions: all notes in `notes` buy each item in `buttons`
*/
function consumeAll () {
if (LOCK) { return }
LOCK = true
let error = false
if (notes_display.length === 0) {
// ... gestion erreur ...
error = true
}
if (buttons.length === 0) {
// ... gestion erreur ...
error = true
}
if (error) {
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)
$.ajax({
url: '/family/api/family/achievements/batch/',
type: 'POST',
data: JSON.stringify({
families: family_ids,
challenges: challenge_ids
}),
contentType: 'application/json',
headers: {
'X-CSRFToken': CSRF_TOKEN
},
success: function () {
reset()
addMsg("Défis validés pour les familles!", 'success', 5000)
},
error: function (e) {
reset()
addMsg("Erreur lors de la création des achievements.",'danger',5000)
}
})
}
/**
* 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")
var old_pattern = null;
var firstMatch = null;
/**
* Updates the button search tab
* @param force Forces the update even if the pattern didn't change
*/
function updateSearch(force = false) {
let pattern = searchbar.value
if (pattern === "")
firstMatch = null;
if ((pattern === old_pattern || pattern === "") && !force)
return;
firstMatch = null;
const re = new RegExp(pattern, "i");
Array.from(search_results.children).forEach(function(b) {
if (re.test(b.innerText)) {
b.hidden = false;
if (firstMatch === null) {
firstMatch = b;
}
} else
b.hidden = true;
});
}
searchbar.addEventListener("input", function (e) {
debounce(updateSearch)()
});
searchbar.addEventListener("keyup", function (e) {
if (firstMatch && e.key === "Enter")
firstMatch.click()
});
function createshiny() {
const list_btn = document.querySelectorAll('.btn-outline-dark')
const shiny_class = list_btn[Math.floor(Math.random() * list_btn.length)].classList
shiny_class.replace('btn-outline-dark', 'btn-outline-dark-shiny')
}
createshiny()
/**
* Query the 20 first matched notes with a given pattern
* @param pattern The pattern that is queried
* @param fun For each found note with the matched alias `alias`, fun(note, alias) is called.
*/
function getMatchedFamilies (pattern, fun) {
$.getJSON('/api/family/family/?format=json&alias=' + pattern + '&search=family', fun)
}
/**
* Generate a <li> entry with a given id and text
*/
function li (id, text, extra_css) {
return '<li class="list-group-item py-1 px-2 d-flex justify-content-between align-items-center text-truncate ' +
(extra_css || '') + '"' + ' id="' + id + '">' + text + '</li>\n'
}
/**
* Génère un champ d'auto-complétion pour rechercher une famille par son nom (version simplifiée sans alias)
* @param field_id L'identifiant du champ texte où le nom est saisi
* @param family_list_id L'identifiant du bloc div où les familles sélectionnées sont affichées
* @param families Un tableau contenant les objets famille sélectionnés
* @param families_display Un tableau contenant les infos des familles sélectionnées : [nom, id, objet famille, quantité]
* @param family_prefix Le préfixe des <li> pour les familles sélectionnées
* @param user_family_field L'identifiant du champ qui affiche la famille survolée (optionnel)
* @param profile_pic_field L'identifiant du champ qui affiche la photo de la famille survolée (optionnel)
* @param family_click Fonction appelée lors du clic sur un nom. Si elle existe et ne retourne pas true, la famille n'est pas affichée.
*/
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,
placement: 'bottom',
title: 'Chargement...',
trigger: 'manual',
container: field.parent(),
fallbackPlacement: 'clockwise'
})
// Masquer le tooltip lors d'un clic ailleurs
$(document).click(function (e) {
if (!e.target.id.startsWith(family_prefix)) {
field.tooltip('hide')
}
})
let old_pattern = null
// Réinitialiser la recherche au clic
field.click(function () {
field.tooltip('hide')
field.removeClass('is-invalid')
field.val('')
old_pattern = ''
})
// Sur "Entrée", sélectionner la première famille
field.keypress(function (event) {
if (event.originalEvent.charCode === 13 && families.length > 0) {
const li_obj = field.parent().find('ul li').first()
displayFamily(families[0], families[0].name, user_family_field, profile_pic_field)
li_obj.trigger('click')
}
})
// Mise à jour des suggestions lors de la saisie
field.keyup(function (e) {
field.removeClass('is-invalid')
if (e.originalEvent.charCode === 13) { return }
const pattern = field.val()
if (pattern === old_pattern) { return }
old_pattern = pattern
families.length = 0
if (pattern === '') {
field.tooltip('hide')
families.length = 0
return
}
// Appel à l'API pour récupérer les familles correspondantes
$.getJSON('/api/family/family/?format=json&search=' + pattern,
function (results) {
if (pattern !== $('#' + field_id).val()) { return }
let matched_html = '<ul class="list-group list-group-flush">'
results.results.forEach(function (family) {
matched_html += li(family_prefix + '_' + family.id,
family.name,
'')
families.push(family)
})
matched_html += '</ul>'
field.attr('data-original-title', matched_html).tooltip('show')
results.results.forEach(function (family) {
const family_obj = $('#' + family_prefix + '_' + family.id)
family_obj.hover(function () {
displayFamily(family, family.name, user_family_field, profile_pic_field)
})
family_obj.click(function () {
var disp = null
families_display.forEach(function (d) {
if (d.id === family.id) {
d.quantity += 1
disp = d
}
})
if (disp == null) {
disp = {
name: family.name,
id: family.id,
family: family,
quantity: 1
}
families_display.push(disp)
}
if (family_click && !family_click()) { return }
const family_list = $('#' + family_list_id)
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>',
'')
})
family_list.html(html)
field.tooltip('update')
families_display.forEach(function (disp) {
const line_obj = $('#' + family_prefix + '_' + disp.id)
line_obj.hover(function () {
displayFamily(disp.family, disp.name, user_family_field, profile_pic_field)
})
line_obj.click(removeFamily(disp, family_prefix, families_display, family_list_id, user_family_field,
profile_pic_field))
})
})
})
})
})
}
/**
* Affiche le nom et la photo d'une famille
* @param family L'objet famille à afficher
* @param user_family_field L'identifiant du champ où afficher le nom (optionnel)
* @param profile_pic_field L'identifiant du champ où afficher la photo (optionnel)
*/
function displayFamily(family, user_family_field = null, profile_pic_field = null) {
if (!family.display_image) {
family.display_image = '/static/member/img/default_picture.png'
}
if (user_family_field !== null) {
$('#' + user_family_field).removeAttr('class')
$('#' + user_family_field).text(family.name)
if (profile_pic_field != null) {
$('#' + profile_pic_field).attr('src', family.display_image)
// Si tu veux un lien vers la page famille :
$('#' + profile_pic_field + '_link').attr('href', '/family/detail/' + family.id + '/')
}
}
}
/**
* Retire une famille de la liste sélectionnée.
* @param d La famille à retirer
* @param family_prefix Le préfixe des <li>
* @param families_display Le tableau des familles sélectionnées
* @param family_list_id L'id du bloc où sont affichées les familles
* @param user_family_field Champ d'affichage (optionnel)
* @param profile_pic_field Champ photo (optionnel)
* @returns une fonction compatible avec les événements jQuery
*/
function removeFamily(d, family_prefix, families_display, family_list_id, user_family_field = null, profile_pic_field = null) {
return function () {
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
new_families_display.forEach(function (disp) {
families_display.push(disp)
})
$('#' + family_list_id).html(html)
families_display.forEach(function (disp) {
const obj = $('#' + family_prefix + '_' + disp.id)
obj.click(removeFamily(disp, family_prefix, families_display, family_list_id, user_family_field, profile_pic_field))
obj.hover(function () {
displayFamily(disp.family, user_family_field, profile_pic_field)
})
})
}
}

View File

@@ -1,105 +0,0 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import django_tables2 as tables
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django_tables2 import A
from .models import Family, Challenge, FamilyMembership, Achievement
class FamilyTable(tables.Table):
"""
List all families
"""
class Meta:
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
model = Family
template_name = 'django_tables2/bootstrap4.html'
fields = ('name', 'score', 'rank',)
order_by = ('rank',)
row_attrs = {
'class': 'table-row',
'data-href': lambda record: reverse('family:family_detail', args=[record.pk]),
'style': 'cursor:pointer',
}
class ChallengeTable(tables.Table):
"""
List all challenges
"""
class Meta:
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
order_by = ('id',)
model = Challenge
template_name = 'django_tables2/bootstrap4.html'
fields = ('name', 'description', 'points',)
row_attrs = {
'class': 'table-row',
'data-href': lambda record: reverse('family:challenge_detail', args=[record.pk]),
'style': 'cursor:pointer',
}
class FamilyMembershipTable(tables.Table):
"""
List all family memberships.
"""
class Meta:
attrs = {
'class': 'table table-condensed table-striped',
'style': 'table-layout: fixed;'
}
template_name = 'django_tables2/bootstrap4.html'
fields = ('user',)
model = FamilyMembership
class AchievementTable(tables.Table):
"""
List recent achievements.
"""
delete = tables.LinkColumn(
'family:achievement_delete',
args=[A('id')],
verbose_name=_("Delete"),
text=_("Delete"),
orderable=False,
attrs={
'th': {
'id': 'delete-membership-header'
},
'a': {
'class': 'btn btn-danger',
'data-type': 'delete-membership'
}
},
)
class Meta:
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
model = Achievement
fields = ('family', 'challenge', 'challenge__points', 'obtained_at', )
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.
"""
class Meta:
model = Achievement
template_name = 'django_tables2/bootstrap4.html'
fields = ('challenge', 'challenge__points', 'obtained_at',)
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
order_by = ('-obtained_at',)

View File

@@ -1,27 +0,0 @@
{% 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 "Delete achievement" %}</h4>
</div>
<div class="card-body">
<div class="alert alert-warning">
{% blocktrans %}Are you sure you want to delete 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>
{% if not object.locked %}
<button class="btn btn-danger" type="submit">{% trans "Delete" %}</button>
{% endif %}
</form>
</div>
</div>
{% endblock %}

View File

@@ -1,22 +0,0 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n django_tables2 %}
{% block content %}
<div class="card mb-4" id="history">
<div class="card-header">
<p class="card-text font-weight-bold">
{% trans "Recent 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 %}
</div>
{% endblock %}

View File

@@ -1,60 +0,0 @@
{% extends "family/base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load crispy_forms_tags i18n pretty_money %}
{% block profile_content %}
<div class="card bg-light">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body">
<form method="post" action="">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
</form>
</div>
</div>
{% endblock %}
{% block extrajavascript %}
<script>
function autocompleted(user) {
$("#id_last_name").val(user.last_name);
$("#id_first_name").val(user.first_name);
$.getJSON("/api/members/profile/" + user.id + "/", function (profile) {
let fee = profile.paid ? "{{ club.membership_fee_paid }}" : "{{ club.membership_fee_unpaid }}";
$("#id_credit_amount").val((Number(fee) / 100).toFixed(2));
});
}
soge_field = $("#id_soge");
function fillFields() {
let checked = soge_field.is(':checked');
if (!checked) {
$("input").attr('disabled', false);
$("#id_user").attr('disabled', true);
$("select").attr('disabled', false);
return;
}
let credit_type = $("#id_credit_type");
credit_type.attr('disabled', true);
credit_type.val(4);
let credit_amount = $("#id_credit_amount");
credit_amount.attr('disabled', true);
credit_amount.val('{{ total_fee }}');
let bank = $("#id_bank");
bank.attr('disabled', true);
bank.val('Société générale');
}
soge_field.change(fillFields);
</script>
{% endblock %}

View File

@@ -1,52 +0,0 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n perms %}
{# Use a fluid-width container #}
{% block containertype %}container-fluid{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-xl-4">
{% block profile_info %}
<div class="card bg-light" id="card-infos">
<h4 class="card-header text-center">
{{ family.name }}
</h4>
<div class="text-center">
<a href="{% url 'family:update_pic' family.pk %}">
<img src="{{ family.display_image.url }}" class="img-thumbnail mt-2">
</a>
</div>
<div class="card-body" id="profile_infos">
{% include "family/family_info.html" %}
</div>
<div class="card-footer">
{% if can_add_members %}
<a class="btn btn-sm btn-success" href="{% url 'family:family_add_member' family_pk=family.pk %}"
data-turbolinks="false"> {% trans "Add member" %}</a>
{% endif %}
{% if ".change_"|has_perm:family %}
<a class="btn btn-sm btn-secondary" href="{% url 'family:family_update' pk=family.pk %}"
data-turbolinks="false">
<i class="fa fa-edit"></i> {% trans 'Update Profile' %}
</a>
{% endif %}
{% url 'family:family_detail' family.pk as family_detail_url %}
{% if request.path_info != family_detail_url %}
<a class="btn btn-sm btn-primary" href="{{ family_detail_url }}">{% trans 'View Profile' %}</a>
{% endif %}
<a class="btn btn-sm btn-primary" href="{% url "family:family_list" %}">
{% trans "Return to the family list" %}
</a>
</div>
</div>
{% endblock %}
</div>
<div class="col-xl-8">
{% block profile_content %}{% endblock %}
</div>
</div>
{% endblock %}

View File

@@ -1,36 +0,0 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 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 }} {{ challenge.name }}
</h3>
<div class="card-body">
<ul>
{% for field, value in fields %}
<li> {{ field }} : {{ value }}</li>
{% endfor %}
<li> {% trans "Obtained by " %} {{obtained}}
{% if obtained > 1 %}
{% trans "families" %}
{% else %}
{% trans "family" %}
{% endif %}
</li>
</ul>
<a class="btn btn-sm btn-primary" href="{% url "family:challenge_list" %}">
{% trans "Return to the challenge list" %}
</a>
{% if update %}
<a class="btn btn-sm btn-secondary" href="{% url "family:challenge_update" pk=challenge.pk %}">
{% trans "Update" %}
</a>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -1,21 +0,0 @@
{% 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 %}

View File

@@ -1,40 +0,0 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col-xl-12">
<div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0">
<a href="{% url "family:family_list" %}" class="btn btn-sm btn-outline-primary">
{% trans "Families" %}
</a>
<a href="#" class="btn btn-sm btn-outline-primary active">
{% trans "Challenges" %}
</a>
<a href="{% url "family:manage" %}" class="btn btn-sm btn-outline-primary">
{% trans "Manage" %}
</a>
</div>
</div>
</div>
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
{% render_table table %}
</div>
{% endblock %}
{% block extrajavascript %}
<script type="text/javascript">
$(".table-row").click(function () {
window.document.location = $(this).data("href");
});
</script>
{% endblock %}

View File

@@ -1,25 +0,0 @@
{% extends "family/base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load render_table from django_tables2 %}
{% load i18n perms %}
{% block profile_content %}
<div class="card">
<div class="card-header position-relative" id="clubListHeading">
<i class="fa fa-users"></i> {% trans "Family members" %}
</div>
{% render_table member_list %}
</div>
<div class="my-4"></div>
<div class="card">
<div class="card-header position-relative">
<i class="fa fa-trophy"></i> {% trans "Completed challenges" %}
</div>
{% render_table achievement_list %}
</div>
{% endblock %}

View File

@@ -1,21 +0,0 @@
{% 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 %}

View File

@@ -1,15 +0,0 @@
{% load i18n pretty_money perms %}
<dl class="row">
<dt class="col-xl-6">{% trans 'name'|capfirst %}</dt>
<dd class="col-xl-6">{{ family.name }}</dd>
<dt class="col-xl-6">{% trans 'description'|capfirst %}</dt>
<dd class="col-xl-6">{{ family.description }}</dd>
<dt class="col-xl-6">{% trans 'score'|capfirst %}</dt>
<dd class="col-xl-6">{{ family.score }}</dd>
<dt class="col-xl-6">{% trans 'rank'|capfirst %}</dt>
<dd class="col-xl-6">{{ family.rank }}</dd>
</dl>

View File

@@ -1,41 +0,0 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col-xl-12">
<div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0">
<a href="#" class="btn btn-sm btn-outline-primary active">
{% trans "Families" %}
</a>
<a href="{% url "family:challenge_list" %}" class="btn btn-sm btn-outline-primary">
{% trans "Challenges" %}
</a>
<a href="{% url "family:manage" %}" class="btn btn-sm btn-outline-primary">
{% trans "Manage" %}
</a>
</div>
</div>
</div>
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
{% render_table table %}
</div>
{% endblock %}
{% block extrajavascript %}
<script type="text/javascript">
$(".table-row").click(function () {
window.document.location = $(this).data("href");
});
</script>
{% endblock %}

View File

@@ -1,181 +0,0 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n static django_tables2 %}
{% block containertype %}container-fluid{% endblock %}
{% block content %}
<div class="row">
<div class="col-xl-12">
<div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0">
<a href="{% url "family:family_list" %}" class="btn btn-sm btn-outline-primary">
{% trans "Families" %}
</a>
<a href="{% url "family:challenge_list" %}" class="btn btn-sm btn-outline-primary">
{% trans "Challenges" %}
</a>
<a href="#" class="btn btn-sm btn-outline-primary active">
{% trans "Manage" %}
</a>
</div>
</div>
</div>
<div class="row mb-3">
<div class='col-sm-5 col-xl-6' id="infos_div">
<div class="row justify-content-center justify-content-md-end">
{# Family details column #}
<div class="col picture-col">
<div class="card bg-light mb-4 text-center">
<a id="profile_pic_link" href="#">
<img src="{% static "member/img/default_picture.png" %}" id="profile_pic" alt="" class="card-img-top d-none d-sm-block">
</a>
<div class="card-body text-center text-break p-2">
<span id="user_note"><i class="small">{% trans "Please select a family" %}</i></span>
</div>
</div>
</div>
{# Family selection column #}
<div class="col-xl" id="user_select_div">
<div class="card bg-light border-success mb-4">
<div class="card-header">
<p class="card-text font-weight-bold">
{% trans "Families" %}
</p>
</div>
<div class="card-body p-0" style="min-height:125px;">
<ul class="list-group list-group-flush" id="note_list"></ul>
</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 />
</div>
</div>
</div>
{# Summary of challenges and validate button #}
<div class="col-xl-5" id="consos_list_div">
<div class="card bg-light border-info mb-4">
<div class="card-header">
<p class="card-text font-weight-bold">
{% trans "Challenges" %}
</p>
</div>
<div class="card-body p-0" style="min-height:125px;">
<ul class="list-group list-group-flush" id="consos_list"></ul>
</div>
<div class="card-footer text-center">
<span id="consume_all" class="btn btn-primary">
{% trans "Validate!" %}
</span>
</div>
</div>
</div>
</div>
{# Create family/challenge buttons #}
<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" %}">
{% trans "Add a family" %}
</a>
{% endif %}
{% if can_add_challenge %}
<a class="btn btn-sm btn-primary mx-2" href="{% url "family:add_challenge" %}">
{% trans "Add a challenge" %}
</a>
{% endif %}
</div>
</div>
</div>
{# Buttons column #}
<div class="col">
<div class="card bg-light border-primary text-center mb-4">
{# Tabs for list and search #}
<div class="card-header">
<ul class="nav nav-tabs nav-fill card-header-tabs">
<li class="nav-item">
<a class="nav-link font-weight-bold" data-toggle="tab" href="#list">
{% trans "List" %}
</a>
</li>
<li class="nav-item">
<a class="nav-link font-weight-bold" data-toggle="tab" href="#search">
{% trans "Search" %}
</a>
</li>
</ul>
</div>
{# Tabs content #}
<div class="card-body">
<div class="tab-content">
<div class="tab-pane" id="list">
<div class="d-inline-flex flex-wrap justify-content-center">
{% for challenge in all_challenges %}
<button class="btn btn-outline-dark rounded-0 flex-fill"
id="challenge{{ challenge.id }}" name="button" value="{{ challenge.name }}">
{{ challenge.name }} ({{ challenge.points }} {% trans "points" %})
</button>
{% endfor %}
</div>
</div>
<div class="tab-pane" id="search">
<input class="form-control mx-auto d-block mb-3" placeholder="{% trans "Search challenge..." %}" type="search" id="search-input"/>
<div class="d-inline-flex flex-wrap justify-content-center" id="search-results">
{% for challenge in all_challenges %}
<button class="btn btn-outline-dark rounded-0 flex-fill" hidden
id="search_challenge{{ challenge.id }}" name="button" value="{{ challenge.name }}">
{{ challenge.name }} ({{ challenge.points }} {% trans "points" %})
</button>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{# transaction history #}
<div class="card">
<div class="card-header position-relative" id="historyListHeading">
<a class="stretched-link font-weight-bold"
href="{% url 'family:achievement_list' %}" >
{% trans "Recent achievements history" %}
</a>
</div>
<div id="history_list">
{% render_table table %}
</div>
</div>
{% endblock %}
{% block extrajavascript %}
<script type="text/javascript" src="{% static "family/js/achievements.js" %}"></script>
<script type="text/javascript">
{% for challenge in all_challenges %}
document.getElementById("challenge{{ challenge.id }}").addEventListener("click", function() {
addChallenge({{ challenge.id}}, "{{ challenge.name|escapejs }}", {{ challenge.points }});
});
{% endfor %}
{% for challenge in all_challenges %}
document.getElementById("search_challenge{{ challenge.id }}").addEventListener("click", function() {
addChallenge({{ challenge.id}}, "{{ challenge.name|escapejs }}", {{ challenge.points }});
});
{% endfor %}
</script>
{% endblock %}

View File

@@ -1,118 +0,0 @@
{% extends "family/base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block profile_content %}
<div class="card bg-light">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body">
<div class="text-center">
<form method="post" enctype="multipart/form-data" id="formUpload">
{% csrf_token %}
{{ form |crispy }}
{% if user.note.display_image != "pic/default.png" %}
<input type="submit" class="btn btn-primary" value="{% trans "Remove" %}">
{% endif %}
</form>
</div>
<!-- MODAL TO CROP THE IMAGE -->
<div class="modal fade" id="modalCrop" data-backdrop="static">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body-wrapper" style="width: 500px; height: 500px; padding: 16px;">
<div class="modal-body" style="width: 100%; height: 100%; padding: 0">
<img src="" id="modal-image" style="display: block; max-width: 100%;">
</div>
</div>
<div class="modal-footer">
<div class="btn-group pull-left" role="group">
<button type="button" class="btn btn-default" id="js-zoom-in">
<span class="glyphicon glyphicon-zoom-in"></span>
</button>
<button type="button" class="btn btn-default js-zoom-out">
<span class="glyphicon glyphicon-zoom-out"></span>
</button>
</div>
<button type="button" class="btn btn-default" data-dismiss="modal">{% trans "Nevermind" %}</button>
<button type="button" class="btn btn-primary js-crop-and-upload">{% trans "Crop and upload" %}</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extracss %}
<link href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.6/cropper.min.css" rel="stylesheet">
{% endblock %}
{% block extrajavascript%}
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.6/cropper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery-cropper@1.0.1/dist/jquery-cropper.min.js"></script>
<script>
$(function () {
/* SCRIPT TO OPEN THE MODAL WITH THE PREVIEW */
$("#id_image").change(function (e) {
if (this.files && this.files[0]) {
// Check the image size
if (this.files[0].size > 2*1024*1024) {
alert("Ce fichier est trop volumineux.")
} else {
// Read the selected image file
var reader = new FileReader();
reader.onload = function (e) {
$("#modal-image").attr("src", e.target.result);
$("#modalCrop").modal("show");
}
reader.readAsDataURL(this.files[0]);
}
}
});
/* SCRIPTS TO HANDLE THE CROPPER BOX */
var $image = $("#modal-image");
var cropBoxData;
var canvasData;
$("#modalCrop").on("shown.bs.modal", function () {
$image.cropper({
viewMode: 1,
aspectRatio: 1 / 1,
minCropBoxWidth: 200,
minCropBoxHeight: 200,
ready: function () {
$image.cropper("setCanvasData", canvasData);
$image.cropper("setCropBoxData", cropBoxData);
}
});
}).on("hidden.bs.modal", function () {
cropBoxData = $image.cropper("getCropBoxData");
canvasData = $image.cropper("getCanvasData");
$image.cropper("destroy");
});
$(".js-zoom-in").click(function () {
$image.cropper("zoom", 0.1);
});
$(".js-zoom-out").click(function () {
$image.cropper("zoom", -0.1);
});
/* SCRIPT TO COLLECT THE DATA AND POST TO THE SERVER */
$(".js-crop-and-upload").click(function () {
var cropData = $image.cropper("getData");
$("#id_x").val(cropData["x"]);
$("#id_y").val(cropData["y"]);
$("#id_height").val(cropData["height"]);
$("#id_width").val(cropData["width"]);
$("#formUpload").submit();
});
});
</script>
{% endblock %}

View File

@@ -1,24 +0,0 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path, include
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('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('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')),
]

View File

@@ -1,316 +0,0 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import date
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import transaction
from django.views.generic import DetailView, UpdateView
from django.views.generic.edit import DeleteView
from django.utils.translation import gettext_lazy as _
from django_tables2 import SingleTableView
from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from django.urls import reverse_lazy
from member.views import PictureUpdateView
from .models import Family, Challenge, FamilyMembership, User, Achievement
from .tables import FamilyTable, ChallengeTable, FamilyMembershipTable, AchievementTable, FamilyAchievementTable
from .forms import ChallengeForm, FamilyMembershipForm, FamilyForm
class FamilyCreateView(ProtectQuerysetMixin, ProtectedCreateView):
"""
Create family
"""
model = Family
extra_context = {"title": _('Create family')}
form_class = FamilyForm
def get_sample_object(self):
return Family(
name="",
description="Sample family",
score=0,
rank=0,
)
def get_success_url(self):
self.object.refresh_from_db()
return reverse_lazy("family:manage")
class FamilyListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
"""
List existing Families
"""
model = Family
table_class = FamilyTable
extra_context = {"title": _('Families list')}
class FamilyDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
Display details of a family
"""
model = Family
context_object_name = "family"
extra_context = {"title": _('Family detail')}
def get_context_data(self, **kwargs):
"""
Add members list
"""
context = super().get_context_data(**kwargs)
family = self.object
# member list
family_member = FamilyMembership.objects.filter(
family=family,
year=date.today().year,
).filter(PermissionBackend.filter_queryset(self.request, FamilyMembership, "view"))\
.order_by("user__username")
family_member = family_member.distinct("user__username")\
if settings.DATABASES["default"]["ENGINE"] == 'django.db.backends.postgresql' else family_member
membership_table = FamilyMembershipTable(data=family_member, prefix="membership-")
membership_table.paginate(per_page=5, page=self.request.GET.get('membership-page', 1))
context['member_list'] = membership_table
# Check if the user has the right to create a membership, to display the button.
empty_membership = FamilyMembership(
family=family,
user=User.objects.first(),
year=date.today().year,
)
context["can_add_members"] = PermissionBackend()\
.has_perm(self.request.user, "family.add_membership", empty_membership)
# Défis réalisé par la famille
achievements = Achievement.objects.filter(family=family)
achievements_table = FamilyAchievementTable(data=achievements, prefix="achievement-")
achievements_table.paginate(per_page=5, page=self.request.GET.get('achievement-page', 1))
context["achievement_list"] = achievements_table
return context
class FamilyUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
"""
Update the information of a family.
"""
model = Family
context_object_name = "family"
form_class = FamilyForm
extra_context = {"title": _('Update family')}
def get_success_url(self):
return reverse_lazy('family:family_detail', kwargs={'pk': self.object.pk})
class FamilyPictureUpdateView(PictureUpdateView):
"""
Update profile picture of the family
"""
model = Family
extra_context = {"title": _("Update family picture")}
template_name = 'family/picture_update.html'
def get_success_url(self):
"""Redirect to family page after upload"""
return reverse_lazy('family:family_detail', kwargs={'pk': self.object.id})
@transaction.atomic
def form_valid(self, form):
"""
Save the image
"""
image = form.cleaned_data['image']
if image is None:
image = "pic/default.png"
else:
# Rename as PNG or GIF
extension = image.name.split(".")[-1]
if extension == "gif":
image.name = "{}_pic.gif".format(self.object.pk)
else:
image.name = "{}_pic.png".format(self.object.pk)
class FamilyAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
"""
Add a membership to a family
"""
model = FamilyMembership
form_class = FamilyMembershipForm
template_name = 'family/add_member.html'
extra_context = {"title": _("Add a new member to the family")}
def get_sample_object(self):
if "family_pk" in self.kwargs:
family = Family.objects.get(pk=self.kwargs["family_pk"])
else:
family = FamilyMembership.objects.get(pk=self.kwargs["pk"]).family
return FamilyMembership(
user=self.request.user,
family=family,
year=date.today().year,
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
family = Family.objects.filter(PermissionBackend.filter_queryset(self.request, Family, "view"))\
.get(pk=self.kwargs['family_pk'])
context['family'] = family
return context
@transaction.atomic
def form_valid(self, form):
"""
Create family membership, check that everythinf is good
"""
family = Family.objects.filter(PermissionBackend.filter_queryset(self.request, Family, "view")) \
.get(pk=self.kwargs["family_pk"])
form.instance.family = family
return super().form_valid(form)
def get_success_url(self):
return reverse_lazy('family:family_detail', kwargs={'pk': self.object.family.id})
class ChallengeCreateView(ProtectQuerysetMixin, ProtectedCreateView):
"""
Create challenge
"""
model = Challenge
extra_context = {"title": _('Create challenge')}
form_class = ChallengeForm
def get_sample_object(self):
return Challenge(
name="",
description="Sample challenge",
points=0,
)
def get_success_url(self):
return reverse_lazy('family:manage')
class ChallengeListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
"""
List all challenges
"""
model = Challenge
table_class = ChallengeTable
extra_context = {"title": _('Challenges list')}
class ChallengeDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
Display details of a challenge
"""
model = Challenge
context_object_name = "challenge"
extra_context = {"title": _('Details of:')}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
fields = ["name", "description", "points",]
fields = dict([(field, getattr(self.object, field)) for field in fields])
context["fields"] = [(
Challenge._meta.get_field(field).verbose_name.capitalize(),
value) for field, value in fields.items()]
context["obtained"] = self.object.obtained
context["update"] = PermissionBackend.check_perm(self.request, "family.change_challenge")
return context
class ChallengeUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
"""
Update the information of a challenge
"""
model = Challenge
context_object_name = "challenge"
extra_context = {"title": _('Update challenge')}
form_class = ChallengeForm
def get_success_url(self, **kwargs):
self.object.refresh_from_db()
return reverse_lazy('family:challenge_detail', kwargs={'pk': self.object.pk})
class FamilyManageView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
"""
Manage families and challenges
"""
model = Achievement
template_name = 'family/manage.html'
table_class = AchievementTable
extra_context = {'title': _('Manage families and challenges')}
def dispatch(self, request, *args, **kwargs):
# Check that the user is authenticated
if not request.user.is_authenticated:
return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs)
def get_queryset(self, **kwargs):
# retrieves only Transaction that user has the right to see.
return Achievement.objects.filter(
PermissionBackend.filter_queryset(self.request, Achievement, "view")
).order_by("-obtained_at").all()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['all_challenges'] = Challenge.objects.filter(
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")
return context
def get_table(self, **kwargs):
table = super().get_table(**kwargs)
table.exclude = ('delete',)
table.orderable = False
return table
class AchievementsView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
"""
List all achievements
"""
model = Achievement
table_class = AchievementTable
extra_context = {'title': _('Achievement list')}
def get_table(self, **kwargs):
table = super().get_table(**kwargs)
table.orderable = True
return table
class AchievementDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView):
"""
Delete an Achievement
"""
model = Achievement
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,28 @@ 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:
print('update_wei_registration_fee_on_membership_creation')
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

@@ -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,87 @@
"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"
}
},
{
@@ -4444,7 +4524,8 @@
159,
160,
212,
222
222,
297
]
}
},
@@ -4631,7 +4712,10 @@
176,
177,
178,
183
183,
294,
295,
296
]
}
},
@@ -4764,7 +4848,6 @@
"name": "Chef\u22c5fe de bus",
"permissions": [
22,
84,
115,
117,
118,
@@ -4778,7 +4861,8 @@
287,
289,
290,
291
291,
293
]
}
},
@@ -4790,7 +4874,6 @@
"name": "Chef\u22c5fe d'\u00e9quipe",
"permissions": [
22,
84,
116,
123,
124,
@@ -4805,8 +4888,7 @@
"for_club": null,
"name": "\u00c9lectron libre",
"permissions": [
22,
84
22
]
}
},
@@ -4957,7 +5039,6 @@
"name": "Référent⋅e Bus",
"permissions": [
22,
84,
115,
117,
118,
@@ -4971,7 +5052,8 @@
287,
289,
290,
291
291,
293
]
}
},

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

@@ -168,7 +168,7 @@ class InvoiceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
class InvoiceDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView):
"""
Delete a non-locked Invoice
Delete a non-validated WEI registration
"""
model = Invoice
extra_context = {"title": _("Delete invoice")}

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,16 +14,139 @@ from django.utils.translation import gettext_lazy as _
from .base import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, WEIBusInformation
from ...models import WEIMembership, Bus
WORDS = [
'13 organisé', '3ième mi temps', 'Années 2000', 'Apéro', 'BBQ', 'BP', 'Beauf', 'Binge drinking', 'Bon enfant',
'Cartouche', 'Catacombes', 'Chansons paillardes', 'Chansons populaires', 'Chanteur', 'Chartreuse', 'Chill',
'Core', 'DJ', 'Dancefloor', 'Danse', 'David Guetta', 'Disco', 'Eau de vie', 'Électro', 'Escalade', 'Familial',
'Fanfare', 'Fracassage', 'Féria', 'Hard rock', 'Hoeggarden', 'House', 'Huit-six', 'IPA', 'Inclusif', 'Inferno',
'Introverti', 'Jager bomb', 'Jazz', 'Jeux d\'alcool', 'Jeux de rôles', 'Jeux vidéo', 'Jul', 'Jus de fruit',
'Karaoké', 'LGBTQI+', 'Lady Gaga', 'Loup garou', 'Morning beer', 'Métal', 'Nuit blanche', 'Ovalie', 'Psychedelic',
'Pétanque', 'Rave', 'Reggae', 'Rhum', 'Ricard', 'Rock', 'Rosé', 'Rétro', 'Séducteur', 'Techno', 'Thérapie taxi',
'Théâtre', 'Trap', 'Turn up', 'Underground', 'Volley', 'Wati B', 'Zinédine Zidane',
]
WORDS = {
'list': [
'13 organisé', '3ième mi temps', 'Années 2000', 'Apéro', 'BBQ', 'BP', 'Beauf', 'Binge drinking', 'Bon enfant',
'Cartouche', 'Catacombes', 'Chansons paillardes', 'Chansons populaires', 'Chanteur', 'Chartreuse', 'Chill',
'Core', 'DJ', 'Dancefloor', 'Danse', 'David Guetta', 'Disco', 'Eau de vie', 'Électro', 'Escalade', 'Familial',
'Fanfare', 'Fracassage', 'Féria', 'Hard rock', 'Hoeggarden', 'House', 'Huit-six', 'IPA', 'Inclusif', 'Inferno',
'Introverti', 'Jager bomb', 'Jazz', 'Jeux d\'alcool', 'Jeux de rôles', 'Jeux vidéo', 'Jul', 'Jus de fruit',
'Karaoké', 'LGBTQI+', 'Lady Gaga', 'Loup garou', 'Morning beer', 'Métal', 'Nuit blanche', 'Ovalie', 'Psychedelic',
'Pétanque', 'Rave', 'Reggae', 'Rhum', 'Ricard', 'Rock', 'Rosé', 'Rétro', 'Séducteur', 'Techno', 'Thérapie taxi',
'Théâtre', 'Trap', 'Turn up', 'Underground', 'Volley', 'Wati B', 'Zinédine Zidane',
],
'questions': {
'Question 1': [
'Description 1',
{
3: 'Réponse 1 Madagas[car]',
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,34 +166,56 @@ class WEISurveyForm2025(forms.Form):
registration._force_save = True
registration.save()
if self.data:
self.fields["word"].choices = [(w, w) for w in WORDS]
rng = Random((information.step + 1) * information.seed)
if information.step == 0:
self.fields["words"] = forms.MultipleChoiceField(
label=_(f"Choose {NB_WORDS} words:"),
choices=[(w, w) for w in WORDS['list']],
widget=forms.CheckboxSelectMultiple(),
required=True,
)
if self.is_valid():
return
rng = Random((information.step + 1) * information.seed)
buses = WEISurveyAlgorithm2025.get_buses()
informations = {bus: WEIBusInformation2025(bus) for bus in buses}
scores = sum((list(informations[bus].scores.values()) for bus in buses), [])
if scores:
average_score = sum(scores) / len(scores)
else:
average_score = 0
buses = WEISurveyAlgorithm2025.get_buses()
informations = {bus: WEIBusInformation2025(bus) for bus in buses}
scores = sum((list(informations[bus].scores.values()) for bus in buses), [])
if scores:
average_score = sum(scores) / len(scores)
preferred_words = {
bus: [word for word in WORDS['list'] if informations[bus].scores[word] >= average_score]
for bus in buses
}
all_preferred_words = set()
for bus_words in preferred_words.values():
all_preferred_words.update(bus_words)
all_preferred_words = list(all_preferred_words)
rng.shuffle(all_preferred_words)
self.fields["words"].choices = [(w, w) for w in all_preferred_words]
else:
average_score = 0
questions = list(WORDS['questions'].items())
idx = information.step - 1
if idx < len(questions):
q, (desc, answers) = questions[idx]
choices = [(k, v) for k, v in answers.items()]
rng.shuffle(choices)
self.fields[q] = forms.ChoiceField(
label=desc,
choices=choices,
widget=forms.RadioSelect,
required=True,
)
preferred_words = {bus: [word for word in WORDS
if informations[bus].scores[word] >= average_score]
for bus in buses}
# Correction : proposer plusieurs mots différents à chaque étape
n_choices = 4 # Nombre de mots à proposer à chaque étape
all_preferred_words = set()
for bus_words in preferred_words.values():
all_preferred_words.update(bus_words)
all_preferred_words = list(all_preferred_words)
rng.shuffle(all_preferred_words)
words = all_preferred_words[:n_choices]
self.fields["word"].choices = [(w, w) for w in words]
def clean_words(self):
data = self.cleaned_data['words']
if len(data) != NB_WORDS:
raise forms.ValidationError(_(f"Please choose exactly {NB_WORDS} words"))
return data
class WEIBusInformation2025(WEIBusInformation):
@@ -86,7 +226,7 @@ class WEIBusInformation2025(WEIBusInformation):
def __init__(self, bus):
self.scores = {}
for word in WORDS:
for word in WORDS['list']:
self.scores[word] = 0
super().__init__(bus)
@@ -108,7 +248,7 @@ class BusInformationForm2025(forms.ModelForm):
except (json.JSONDecodeError, TypeError, AttributeError):
initial_scores = {}
if words is None:
words = WORDS
words = WORDS['list']
self.words = words
choices = [(i, str(i)) for i in range(6)] # [(0, '0'), (1, '1'), ..., (5, '5')]
@@ -145,10 +285,26 @@ class WEISurveyInformation2025(WEISurveyInformation):
step = 0
def __init__(self, registration):
for i in range(1, 21):
for i in range(1, 5):
setattr(self, "word" + str(i), None)
for q in WORDS['questions']:
setattr(self, q, None)
super().__init__(registration)
def reset(self, registration):
"""
Réinitialise complètement le questionnaire : step, seed, mots choisis et réponses aux questions.
"""
self.step = 0
self.seed = 0
for i in range(1, 5):
setattr(self, f"word{i}", None)
for q in WORDS['questions']:
setattr(self, q, None)
self.save(registration)
registration._force_save = True
registration.save()
class WEISurvey2025(WEISurvey):
"""
@@ -174,10 +330,20 @@ class WEISurvey2025(WEISurvey):
@transaction.atomic
def form_valid(self, form):
word = form.cleaned_data["word"]
self.information.step += 1
setattr(self.information, "word" + str(self.information.step), word)
self.save()
if self.information.step == 0:
words = form.cleaned_data['words']
for i, word in enumerate(words, 1):
setattr(self.information, "word" + str(i), word)
self.information.step += 1
self.save()
else:
questions = list(WORDS['questions'].keys())
idx = self.information.step - 1
if idx < len(questions):
q = questions[idx]
setattr(self.information, q, form.cleaned_data[q])
self.information.step += 1
self.save()
@classmethod
def get_algorithm_class(cls):
@@ -187,7 +353,7 @@ class WEISurvey2025(WEISurvey):
"""
The survey is complete once the bus is chosen.
"""
return self.information.step == 20
return self.information.step > len(WORDS['questions'])
@classmethod
@lru_cache()
@@ -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:
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 "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")
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,)))

View File

@@ -7,9 +7,9 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-15 18:17+0200\n"
"POT-Creation-Date: 2025-08-03 12:32+0200\n"
"PO-Revision-Date: 2022-04-11 22:05+0200\n"
"Last-Translator: bleizi <bleizi@crans.org>\n"
"Last-Translator: ehouarn <ehouarn@crans.org>\n"
"Language-Team: French <http://translate.ynerant.fr/projects/nk20/nk20/fr/>\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
@@ -19,10 +19,8 @@ msgstr ""
"X-Generator: Poedit 3.0\n"
#: apps/activity/api/serializers.py:77
#, fuzzy
#| msgid "This friendship already exists"
msgid "This opener already exists"
msgstr "Cette amitié existe déjà"
msgstr "Cette personne est déjà ouvreur⋅se"
#: apps/activity/apps.py:10 apps/activity/models.py:129
#: apps/activity/models.py:169 apps/activity/models.py:329
@@ -66,7 +64,7 @@ msgstr "Vous ne pouvez pas inviter plus de 3 personnes à cette activité."
#: apps/note/models/transactions.py:46 apps/note/models/transactions.py:299
#: apps/permission/models.py:329
#: apps/registration/templates/registration/future_profile_detail.html:16
#: apps/wei/models.py:77 apps/wei/models.py:150 apps/wei/tables.py:282
#: apps/wei/models.py:77 apps/wei/models.py:150 apps/wei/tables.py:342
#: apps/wei/templates/wei/base.html:26
#: apps/wei/templates/wei/weimembership_form.html:14 apps/wrapped/models.py:16
msgid "name"
@@ -291,14 +289,14 @@ msgstr "Type"
#: apps/activity/tables.py:86 apps/member/forms.py:199
#: apps/registration/forms.py:91 apps/treasury/forms.py:131
#: apps/wei/forms/registration.py:129
#: apps/wei/forms/registration.py:117
msgid "Last name"
msgstr "Nom de famille"
#: apps/activity/tables.py:88 apps/member/forms.py:204
#: apps/note/templates/note/transaction_form.html:138
#: apps/registration/forms.py:96 apps/treasury/forms.py:133
#: apps/wei/forms/registration.py:134
#: apps/wei/forms/registration.py:122
msgid "First name"
msgstr "Prénom"
@@ -315,7 +313,7 @@ msgstr "Solde du compte"
#: apps/note/tables.py:281 apps/treasury/tables.py:39
#: apps/treasury/templates/treasury/invoice_confirm_delete.html:30
#: apps/treasury/templates/treasury/sogecredit_detail.html:65
#: apps/wei/tables.py:75 apps/wei/tables.py:118
#: apps/wei/tables.py:74 apps/wei/tables.py:75 apps/wei/tables.py:148
#: apps/wei/templates/wei/weiregistration_confirm_delete.html:31
#: note_kfet/templates/oauth2_provider/application_confirm_delete.html:18
#: note_kfet/templates/oauth2_provider/application_detail.html:39
@@ -406,6 +404,7 @@ msgstr "Entrée effectuée !"
#: apps/wei/templates/wei/bus_form.html:17
#: apps/wei/templates/wei/busteam_form.html:18
#: apps/wei/templates/wei/weiclub_form.html:17
#: apps/wei/templates/wei/weimembership_update.html:17
#: apps/wei/templates/wei/weiregistration_form.html:18
msgid "Submit"
msgstr "Envoyer"
@@ -462,7 +461,6 @@ msgstr "modifier"
#: apps/activity/templates/activity/includes/activity_info.html:74
#: apps/logs/models.py:65 apps/note/tables.py:230 apps/note/tables.py:279
#: apps/permission/models.py:126 apps/treasury/tables.py:38
#: apps/wei/tables.py:74
msgid "delete"
msgstr "supprimer"
@@ -537,7 +535,7 @@ msgstr "Pâtes METRO 5kg"
#: apps/food/forms.py:53 apps/food/forms.py:81
msgid "Specific order given to GCKs"
msgstr ""
msgstr "Instruction donnée aux GCKs"
#: apps/food/forms.py:77
msgid "Lasagna"
@@ -598,7 +596,7 @@ msgid "order"
msgstr "consigne"
#: apps/food/models.py:107 apps/food/views.py:35
#: note_kfet/templates/base.html:72
#: note_kfet/templates/base.html:73
msgid "Food"
msgstr "Bouffe"
@@ -687,45 +685,45 @@ msgstr "Retour à la liste de nourriture"
msgid "View food"
msgstr "Voir l'aliment"
#: apps/food/templates/food/food_list.html:37
#: apps/food/templates/food/food_list.html:38
#: note_kfet/templates/base_search.html:15
msgid "Search by attribute such as name..."
msgstr "Chercher par un attribut tel que le nom..."
#: apps/food/templates/food/food_list.html:49
#: apps/food/templates/food/food_list.html:50
#: note_kfet/templates/base_search.html:23
msgid "There is no results."
msgstr "Il n'y a pas de résultat."
#: apps/food/templates/food/food_list.html:58
#: apps/food/templates/food/food_list.html:59
msgid "Meal served"
msgstr "Plat servis"
#: apps/food/templates/food/food_list.html:63
#: apps/food/templates/food/food_list.html:64
msgid "New meal"
msgstr "Nouveau plat"
#: apps/food/templates/food/food_list.html:72
#: apps/food/templates/food/food_list.html:73
msgid "There is no meal served."
msgstr "Il n'y a pas de plat servi."
#: apps/food/templates/food/food_list.html:79
#: apps/food/templates/food/food_list.html:80
msgid "Free food"
msgstr "Open"
#: apps/food/templates/food/food_list.html:86
#: apps/food/templates/food/food_list.html:87
msgid "There is no free food."
msgstr "Il n'y a pas de bouffe en open"
#: apps/food/templates/food/food_list.html:94
#: apps/food/templates/food/food_list.html:95
msgid "Food of your clubs"
msgstr "Bouffe de tes clubs"
#: apps/food/templates/food/food_list.html:100
#: apps/food/templates/food/food_list.html:101
msgid "Food of club"
msgstr "Bouffe du club"
#: apps/food/templates/food/food_list.html:107
#: apps/food/templates/food/food_list.html:108
msgid "Yours club has not food yet."
msgstr "Ton club n'a pas de bouffe pour l'instant"
@@ -807,41 +805,41 @@ msgstr "Ajouter un nouveau QR-code"
msgid "Add an aliment"
msgstr "Ajouter un nouvel aliment"
#: apps/food/views.py:228
#: apps/food/views.py:237
msgid "Add a meal"
msgstr "Ajouter un plat"
#: apps/food/views.py:259
#: apps/food/views.py:277
msgid "Manage ingredients of:"
msgstr "Gestion des ingrédienrs de :"
#: apps/food/views.py:273 apps/food/views.py:281
#: apps/food/views.py:291 apps/food/views.py:299
#, python-brace-format
msgid "Fully used in {meal}"
msgstr "Aliment entièrement utilisé dans : {meal}"
#: apps/food/views.py:320
#: apps/food/views.py:346
msgid "Add the ingredient:"
msgstr "Ajouter l'ingrédient"
#: apps/food/views.py:346
#: apps/food/views.py:372
#, python-brace-format
msgid "Food fully used in : {meal.name}"
msgstr "Aliment entièrement utilisé dans : {meal.name}"
#: apps/food/views.py:365
#: apps/food/views.py:391
msgid "Update an aliment"
msgstr "Modifier un aliment"
#: apps/food/views.py:413
#: apps/food/views.py:439
msgid "Details of:"
msgstr "Détails de :"
#: apps/food/views.py:423 apps/treasury/tables.py:149
#: apps/food/views.py:449 apps/treasury/tables.py:149
msgid "Yes"
msgstr "Oui"
#: apps/food/views.py:425 apps/member/models.py:99 apps/treasury/tables.py:149
#: apps/food/views.py:451 apps/member/models.py:99 apps/treasury/tables.py:149
msgid "No"
msgstr "Non"
@@ -912,11 +910,11 @@ msgstr "cotisation pour adhérer (normalien·ne étudiant·e)"
msgid "roles"
msgstr "rôles"
#: apps/member/admin.py:66 apps/member/models.py:351
#: apps/member/admin.py:66 apps/member/models.py:351 apps/wei/models.py:290
msgid "fee"
msgstr "cotisation"
#: apps/member/apps.py:14 apps/wei/tables.py:226 apps/wei/tables.py:257
#: apps/member/apps.py:14 apps/wei/tables.py:286 apps/wei/tables.py:317
msgid "member"
msgstr "adhérent·e"
@@ -977,12 +975,12 @@ msgid "Check this case if the Société Générale paid the inscription."
msgstr "Cochez cette case si la Société Générale a payé l'inscription."
#: apps/member/forms.py:185 apps/registration/forms.py:78
#: apps/wei/forms/registration.py:116
#: apps/wei/forms/registration.py:104
msgid "Credit type"
msgstr "Type de rechargement"
#: apps/member/forms.py:186 apps/registration/forms.py:79
#: apps/wei/forms/registration.py:117
#: apps/wei/forms/registration.py:105
msgid "No credit"
msgstr "Pas de rechargement"
@@ -991,13 +989,13 @@ msgid "You can credit the note of the user."
msgstr "Vous pouvez créditer la note de l'utilisateur⋅rice avant l'adhésion."
#: apps/member/forms.py:192 apps/registration/forms.py:84
#: apps/wei/forms/registration.py:122
#: apps/wei/forms/registration.py:110
msgid "Credit amount"
msgstr "Montant à créditer"
#: apps/member/forms.py:209 apps/note/templates/note/transaction_form.html:144
#: apps/registration/forms.py:101 apps/treasury/forms.py:135
#: apps/wei/forms/registration.py:139
#: apps/wei/forms/registration.py:127
msgid "Bank"
msgstr "Banque"
@@ -1422,7 +1420,7 @@ msgstr "Membres du club"
#: apps/member/templates/member/club_detail.html:40
#: apps/member/templates/member/profile_detail.html:32
#: apps/wei/templates/wei/weiclub_detail.html:75
#: apps/wei/templates/wei/weiclub_detail.html:98
msgid "Transaction history"
msgstr "Historique des transactions"
@@ -1976,8 +1974,8 @@ msgstr ""
"mode de paiement et un⋅e utilisateur⋅rice ou un club"
#: apps/note/models/transactions.py:357 apps/note/models/transactions.py:360
#: apps/note/models/transactions.py:363 apps/wei/views.py:1103
#: apps/wei/views.py:1107
#: apps/note/models/transactions.py:363 apps/wei/views.py:1134
#: apps/wei/views.py:1138
msgid "This field is required."
msgstr "Ce champ est requis."
@@ -2079,8 +2077,6 @@ msgstr "Historique des transactions récentes"
#: apps/note/templates/note/mails/weekly_report.txt:32
#: apps/registration/templates/registration/mails/email_validation_email.html:40
#: apps/registration/templates/registration/mails/email_validation_email.txt:16
#: apps/scripts/templates/scripts/food_report.html:48
#: apps/scripts/templates/scripts/food_report.txt:14
msgid "Mail generated by the Note Kfet on the"
msgstr "Mail généré par la Note Kfet le"
@@ -2484,7 +2480,7 @@ msgstr ""
#: apps/registration/templates/registration/future_profile_detail.html:73
#: apps/wei/templates/wei/weimembership_form.html:127
#: apps/wei/templates/wei/weimembership_form.html:192
#: apps/wei/templates/wei/weimembership_form.html:194
msgid "Validate registration"
msgstr "Valider l'inscription"
@@ -2761,7 +2757,7 @@ msgstr "Crédits de la Société générale"
msgid "Soge credit for {user}"
msgstr "Crédit de la société générale pour l'utilisateur·rice {user}"
#: apps/treasury/models.py:446
#: apps/treasury/models.py:444
msgid ""
"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."
@@ -2943,7 +2939,7 @@ msgstr ""
"supprimer la demande de crédit."
#: apps/treasury/templates/treasury/sogecredit_detail.html:63
#: apps/wei/tables.py:60 apps/wei/tables.py:102
#: apps/wei/tables.py:60 apps/wei/tables.py:131
msgid "Validate"
msgstr "Valider"
@@ -3012,22 +3008,21 @@ msgstr "Gérer les crédits de la Société générale"
#: apps/wei/apps.py:10 apps/wei/models.py:47 apps/wei/models.py:48
#: apps/wei/models.py:72 apps/wei/models.py:197
#: note_kfet/templates/base.html:108
#: note_kfet/templates/base.html:109
msgid "WEI"
msgstr "WEI"
#: apps/wei/forms/registration.py:37
#: apps/wei/forms/registration.py:38
msgid "The selected user is not validated. Please validate its account first"
msgstr ""
"L'utilisateur·rice sélectionné·e n'est pas validé·e. Merci de d'abord "
"valider son compte"
#: apps/wei/forms/registration.py:84 apps/wei/models.py:145
#: apps/wei/models.py:354
msgid "bus"
msgstr "bus"
#: apps/wei/forms/registration.py:72 apps/wei/models.py:107
msgid "Bus"
msgstr "Bus"
#: apps/wei/forms/registration.py:85
#: apps/wei/forms/registration.py:73
msgid ""
"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."
@@ -3036,11 +3031,11 @@ msgstr ""
"vous attribuer un bus et une équipe, en particulier si vous êtes un·e "
"électron libre."
#: apps/wei/forms/registration.py:92
#: apps/wei/forms/registration.py:80
msgid "Team"
msgstr "Équipe"
#: apps/wei/forms/registration.py:94
#: apps/wei/forms/registration.py:82
msgid ""
"Leave this field empty if you won't be in a team (staff, bus chief, free "
"electron)"
@@ -3048,25 +3043,33 @@ msgstr ""
"Laissez ce champ vide si vous ne serez pas dans une équipe (staff, chef de "
"bus ou électron libre)"
#: apps/wei/forms/registration.py:100 apps/wei/forms/registration.py:110
#: apps/wei/forms/registration.py:88 apps/wei/forms/registration.py:98
#: apps/wei/models.py:179
msgid "WEI Roles"
msgstr "Rôles au WEI"
#: apps/wei/forms/registration.py:101
#: apps/wei/forms/registration.py:89
msgid "Select the roles that you are interested in."
msgstr "Sélectionnez les rôles qui vous intéressent."
#: apps/wei/forms/registration.py:160
#: apps/wei/forms/registration.py:148
msgid "This team doesn't belong to the given bus."
msgstr "Cette équipe n'appartient pas à ce bus."
#: apps/wei/forms/surveys/wei2021.py:35 apps/wei/forms/surveys/wei2022.py:38
#: apps/wei/forms/surveys/wei2025.py:36
msgid "Choose a word:"
msgstr "Choisissez un mot :"
#: apps/wei/forms/surveys/wei2025.py:123
#: apps/wei/forms/surveys/wei2025.py:173
msgid "Choose {NB_WORDS} words:"
msgstr "Choisissez {NB_WORDS} mots :"
#: apps/wei/forms/surveys/wei2025.py:217
#, python-brace-format
msgid "Please choose exactly {NB_WORDS} words"
msgstr ""
#: apps/wei/forms/surveys/wei2025.py:263
msgid "Rate between 0 and 5."
msgstr "Note entre 0 et 5."
@@ -3084,7 +3087,7 @@ msgstr "début"
msgid "date end"
msgstr "fin"
#: apps/wei/models.py:37
#: apps/wei/models.py:37 apps/wei/templates/wei/base.html:53
msgid "deposit amount"
msgstr "montant de la caution"
@@ -3092,7 +3095,7 @@ msgstr "montant de la caution"
msgid "membership fee (soge credit)"
msgstr "Cotisation pour adhérer (crédit sogé)"
#: apps/wei/models.py:81 apps/wei/tables.py:305
#: apps/wei/models.py:81 apps/wei/tables.py:365
msgid "seat count in the bus"
msgstr "nombre de sièges dans le bus"
@@ -3105,14 +3108,14 @@ msgid "Information about the survey for new members, encoded in JSON"
msgstr ""
"Informations sur le sondage pour les nouveaux membres, encodées en JSON"
#: apps/wei/models.py:107
msgid "Bus"
msgstr "Bus"
#: apps/wei/models.py:108 apps/wei/templates/wei/weiclub_detail.html:51
#: apps/wei/models.py:108 apps/wei/templates/wei/weiclub_detail.html:56
msgid "Buses"
msgstr "Bus"
#: apps/wei/models.py:145 apps/wei/models.py:375
msgid "bus"
msgstr "bus"
#: apps/wei/models.py:154
msgid "color"
msgstr "couleur"
@@ -3138,10 +3141,9 @@ msgstr "Rôle au WEI"
msgid "Credit from Société générale"
msgstr "Crédit de la Société générale"
#: apps/wei/models.py:207 apps/wei/templates/wei/weimembership_form.html:98
#: apps/wei/views.py:997
msgid "Deposit check given"
msgstr "Chèque de caution donné"
#: apps/wei/models.py:207
msgid "Deposit given"
msgstr "Caution donnée"
#: apps/wei/models.py:213
msgid "Check"
@@ -3152,10 +3154,8 @@ msgid "Note transaction"
msgstr "Transaction Note"
#: apps/wei/models.py:217
#, fuzzy
#| msgid "Credit type"
msgid "deposit type"
msgstr "Type de rechargement"
msgstr "type de caution"
#: apps/wei/models.py:221 apps/wei/templates/wei/weimembership_form.html:64
msgid "birth date"
@@ -3228,35 +3228,35 @@ msgstr ""
"Informations sur l'inscription (bus pour les 2A+, questionnaire pour les "
"1A), encodées en JSON"
#: apps/wei/models.py:290
#: apps/wei/models.py:296
msgid "WEI User"
msgstr "Participant·e au WEI"
#: apps/wei/models.py:291
#: apps/wei/models.py:297
msgid "WEI Users"
msgstr "Participant·e·s au WEI"
#: apps/wei/models.py:364
#: apps/wei/models.py:385
msgid "team"
msgstr "équipe"
#: apps/wei/models.py:374
#: apps/wei/models.py:395
msgid "WEI registration"
msgstr "Inscription au WEI"
#: apps/wei/models.py:378
#: apps/wei/models.py:399
msgid "WEI membership"
msgstr "Adhésion au WEI"
#: apps/wei/models.py:379
#: apps/wei/models.py:400
msgid "WEI memberships"
msgstr "Adhésions au WEI"
#: apps/wei/tables.py:105
#: apps/wei/tables.py:135
msgid "The user does not have enough money."
msgstr "L'utilisateur⋅rice n'a pas assez d'argent."
#: apps/wei/tables.py:108
#: apps/wei/tables.py:138
msgid ""
"The user is in first year. You may validate the credit, the algorithm will "
"run later."
@@ -3264,44 +3264,44 @@ msgstr ""
"L'utilisateur·rice est en première année, vous pouvez valider le crédit, "
"l'algorithme tournera plus tard."
#: apps/wei/tables.py:111
#: apps/wei/tables.py:141
msgid "The user has enough money, you can validate the registration."
msgstr "L'utilisateur⋅rice a assez d'argent, l'inscription est possible."
#: apps/wei/tables.py:143
#: apps/wei/tables.py:174
msgid "Year"
msgstr "Année"
#: apps/wei/tables.py:180 apps/wei/templates/wei/weimembership_form.html:102
#: apps/wei/tables.py:240 apps/wei/templates/wei/weimembership_form.html:102
msgid "preferred bus"
msgstr "bus préféré"
#: apps/wei/tables.py:210 apps/wei/templates/wei/bus_detail.html:38
#: apps/wei/tables.py:270 apps/wei/templates/wei/bus_detail.html:38
#: apps/wei/templates/wei/busteam_detail.html:52
msgid "Teams"
msgstr "Équipes"
#: apps/wei/tables.py:219 apps/wei/tables.py:260
#: apps/wei/tables.py:279 apps/wei/tables.py:320
msgid "Members count"
msgstr "Nombre de membres"
#: apps/wei/tables.py:226 apps/wei/tables.py:257
#: apps/wei/tables.py:286 apps/wei/tables.py:317
msgid "members"
msgstr "adhérent·es"
#: apps/wei/tables.py:287
#: apps/wei/tables.py:347
msgid "suggested first year"
msgstr "1A suggéré·es"
#: apps/wei/tables.py:293
#: apps/wei/tables.py:353
msgid "validated first year"
msgstr "1A validé·es"
#: apps/wei/tables.py:299
#: apps/wei/tables.py:359
msgid "validated staff"
msgstr "2A+ validé·es"
#: apps/wei/tables.py:310
#: apps/wei/tables.py:370
msgid "free seats"
msgstr "sièges libres"
@@ -3342,19 +3342,15 @@ msgstr "Prix du WEI (élèves)"
msgid "WEI fee (unpaid students)"
msgstr "Prix du WEI (étudiant⋅es)"
#: apps/wei/templates/wei/base.html:53
msgid "Deposit amount"
msgstr "Caution"
#: apps/wei/templates/wei/base.html:74
msgid "WEI list"
msgstr "Liste des WEI"
#: apps/wei/templates/wei/base.html:79 apps/wei/views.py:550
#: apps/wei/templates/wei/base.html:79 apps/wei/views.py:584
msgid "Register 1A"
msgstr "Inscrire un⋅e 1A"
#: apps/wei/templates/wei/base.html:83 apps/wei/views.py:646
#: apps/wei/templates/wei/base.html:83 apps/wei/views.py:680
msgid "Register 2A+"
msgstr "Inscrire un⋅e 2A+"
@@ -3391,8 +3387,8 @@ msgstr "Télécharger au format PDF"
#: apps/wei/templates/wei/survey.html:11
#: apps/wei/templates/wei/survey_closed.html:11
#: apps/wei/templates/wei/survey_end.html:11 apps/wei/views.py:1165
#: apps/wei/views.py:1220 apps/wei/views.py:1267
#: apps/wei/templates/wei/survey_end.html:11 apps/wei/views.py:1246
#: apps/wei/views.py:1305 apps/wei/views.py:1352
msgid "Survey WEI"
msgstr "Questionnaire WEI"
@@ -3425,15 +3421,19 @@ msgstr "M'inscrire au WEI ! 2A+"
msgid "Update my registration"
msgstr "Modifier mon inscription"
#: apps/wei/templates/wei/weiclub_detail.html:63
#: apps/wei/templates/wei/weiclub_detail.html:44
msgid "Restart survey"
msgstr "Recommencer le questionnaire"
#: apps/wei/templates/wei/weiclub_detail.html:68
msgid "Members of the WEI"
msgstr "Membres du WEI"
#: apps/wei/templates/wei/weiclub_detail.html:89
#: apps/wei/templates/wei/weiclub_detail.html:80
msgid "Unvalidated registrations"
msgstr "Inscriptions non validées"
#: apps/wei/templates/wei/weiclub_detail.html:99
#: apps/wei/templates/wei/weiclub_detail.html:90
msgid "Attribute buses"
msgstr "Répartition dans les bus"
@@ -3469,6 +3469,10 @@ msgstr "Informations brutes du sondage"
msgid "The algorithm didn't run."
msgstr "L'algorithme n'a pas été exécuté."
#: apps/wei/templates/wei/weimembership_form.html:98 apps/wei/views.py:1028
msgid "Deposit check given"
msgstr "Chèque de caution donné"
#: apps/wei/templates/wei/weimembership_form.html:105
msgid "preferred team"
msgstr "équipe préférée"
@@ -3524,33 +3528,31 @@ msgstr "Paiements requis"
msgid "Membership fees: %(amount)s"
msgstr "Frais d'inscription : %(amount)s"
#: apps/wei/templates/wei/weimembership_form.html:153
#: apps/wei/templates/wei/weimembership_form.html:154
#, python-format
msgid "Deposit (by Note transaction): %(amount)s"
msgstr "Caution (par transaction) : %(amount)s"
#: apps/wei/templates/wei/weimembership_form.html:157
#: apps/wei/templates/wei/weimembership_form.html:158
#, python-format
msgid "Deposit (by check): %(amount)s"
msgstr "Caution (par chèque) : %(amount)s"
#: apps/wei/templates/wei/weimembership_form.html:161
#: apps/wei/templates/wei/weimembership_form.html:163
#, python-format
msgid "Total needed: %(total)s"
msgstr "Total nécessaire : %(total)s"
#: apps/wei/templates/wei/weimembership_form.html:165
#: apps/wei/templates/wei/weimembership_form.html:167
#, python-format
msgid "Current balance: %(balance)s"
msgstr "Solde actuel : %(balance)s"
#: apps/wei/templates/wei/weimembership_form.html:172
#, fuzzy
#| msgid "The user didn't give her/his deposit check."
msgid "The user didn't give her/his caution check."
msgstr "L'utilisateur⋅rice n'a pas donné son chèque de caution."
#: apps/wei/templates/wei/weimembership_form.html:174
msgid "The user didn't give her/his caution."
msgstr "L'utilisateur⋅rice n'a pas donné sa caution."
#: apps/wei/templates/wei/weimembership_form.html:180
#: apps/wei/templates/wei/weimembership_form.html:182
msgid ""
"This user is not a member of the Kfet club for the coming year. The "
"membership will be processed automatically, the WEI registration includes "
@@ -3593,63 +3595,63 @@ msgstr "Chercher un WEI"
msgid "WEI Detail"
msgstr "Détails du WEI"
#: apps/wei/views.py:212
#: apps/wei/views.py:229
msgid "View members of the WEI"
msgstr "Voir les membres du WEI"
#: apps/wei/views.py:245
#: apps/wei/views.py:262
msgid "Find WEI Membership"
msgstr "Trouver une adhésion au WEI"
#: apps/wei/views.py:255
#: apps/wei/views.py:272
msgid "View registrations to the WEI"
msgstr "Voir les inscriptions au WEI"
#: apps/wei/views.py:284
#: apps/wei/views.py:318
msgid "Find WEI Registration"
msgstr "Trouver une inscription au WEI"
#: apps/wei/views.py:295
#: apps/wei/views.py:329
msgid "Update the WEI"
msgstr "Modifier le WEI"
#: apps/wei/views.py:316
#: apps/wei/views.py:350
msgid "Create new bus"
msgstr "Ajouter un nouveau bus"
#: apps/wei/views.py:354
#: apps/wei/views.py:388
msgid "Update bus"
msgstr "Modifier le bus"
#: apps/wei/views.py:386
#: apps/wei/views.py:420
msgid "Manage bus"
msgstr "Gérer le bus"
#: apps/wei/views.py:413
#: apps/wei/views.py:447
msgid "Create new team"
msgstr "Créer une nouvelle équipe"
#: apps/wei/views.py:457
#: apps/wei/views.py:491
msgid "Update team"
msgstr "Modifier l'équipe"
#: apps/wei/views.py:492
#: apps/wei/views.py:526
msgid "Manage WEI team"
msgstr "Gérer l'équipe WEI"
#: apps/wei/views.py:514
#: apps/wei/views.py:548
msgid "Register first year student to the WEI"
msgstr "Inscrire un⋅e 1A au WEI"
#: apps/wei/views.py:571 apps/wei/views.py:664
#: apps/wei/views.py:605 apps/wei/views.py:698
msgid "Check if you will open a Société Générale account"
msgstr "Cochez cette case si vous ouvrez un compte à la Société Générale."
#: apps/wei/views.py:582 apps/wei/views.py:694
#: apps/wei/views.py:616 apps/wei/views.py:728
msgid "This user is already registered to this WEI."
msgstr "Cette personne est déjà inscrite au WEI."
#: apps/wei/views.py:587
#: apps/wei/views.py:621
msgid ""
"This user can't be in her/his first year since he/she has already "
"participated to a WEI."
@@ -3657,65 +3659,67 @@ msgstr ""
"Cet⋅te utilisateur⋅rice ne peut pas être en première année puisqu'iel a déjà "
"participé à un WEI."
#: apps/wei/views.py:610
#: apps/wei/views.py:644
msgid "Register old student to the WEI"
msgstr "Inscrire un⋅e 2A+ au WEI"
#: apps/wei/views.py:668 apps/wei/views.py:773
#: apps/wei/views.py:702 apps/wei/views.py:825
msgid "You already opened an account in the Société générale."
msgstr "Vous avez déjà ouvert un compte auprès de la société générale."
#: apps/wei/views.py:681 apps/wei/views.py:790
#: apps/wei/views.py:715 apps/wei/views.py:821
msgid "Choose how you want to pay the deposit"
msgstr "Choisissez comment payer la caution"
#: apps/wei/views.py:733
#: apps/wei/views.py:767
msgid "Update WEI Registration"
msgstr "Modifier l'inscription WEI"
#: apps/wei/views.py:816
#: apps/wei/views.py:811
msgid "Tick if the deposit check has been given"
msgstr "Cochez si le chèque de caution a été donné"
#: apps/wei/views.py:850
msgid "No membership found for this registration"
msgstr "Pas d'adhésion trouvée pour cette inscription"
#: apps/wei/views.py:825
#: apps/wei/views.py:859
msgid "You don't have the permission to update memberships"
msgstr "Vous n'avez pas la permission de modifier une inscription"
#: apps/wei/views.py:831
#: apps/wei/views.py:865
#, python-format
msgid "You don't have the permission to update the field %(field)s"
msgstr "Vous n'avez pas la permission de modifier le champ %(field)s"
#: apps/wei/views.py:876
#: apps/wei/views.py:906
msgid "Delete WEI registration"
msgstr "Supprimer l'inscription WEI"
#: apps/wei/views.py:887
#: apps/wei/views.py:917
msgid "You don't have the right to delete this WEI registration."
msgstr "Vous n'avez pas la permission de supprimer cette inscription au WEI."
#: apps/wei/views.py:905
#: apps/wei/views.py:935
msgid "Validate WEI registration"
msgstr "Valider l'inscription WEI"
#: apps/wei/views.py:998
msgid "Please make sure the check is given before validating the registration"
msgstr ""
"Merci de vous assurer que le chèque a bien été donné avant de valider "
"l'adhésion"
#: apps/wei/views.py:1029
msgid "Only treasurers can validate this field"
msgstr "Seul·e·s les trésorier·ère·s peuvent valider ce champ"
#: apps/wei/views.py:1004
#: apps/wei/views.py:1035
msgid "Create deposit transaction"
msgstr "Créer une transaction de caution"
#: apps/wei/views.py:1005
#: apps/wei/views.py:1036
#, python-format
msgid ""
"A transaction of %(amount).2f€ will be created from the user's Note account"
msgstr ""
"Un transaction de %(amount).2f€ va être créée depuis la note de l'utilisateur"
#: apps/wei/views.py:1093
#: apps/wei/views.py:1124
#, python-format
msgid ""
"This user doesn't have enough money to join this club and pay the deposit. "
@@ -3725,20 +3729,24 @@ msgstr ""
"payer la caution. Solde actuel : %(balance)d€, crédit : %(credit)d€, "
"requis : %(needed)d€"
#: apps/wei/views.py:1146
#: apps/wei/views.py:1177
#, python-format
msgid "Deposit %(name)s"
msgstr "Caution %(name)s"
#: apps/wei/views.py:1360
#: apps/wei/views.py:1202
msgid "Update WEI Membership"
msgstr "Modifier une adhésion au WEI"
#: apps/wei/views.py:1445
msgid "Attribute buses to first year members"
msgstr "Répartir les 1A dans les bus"
#: apps/wei/views.py:1386
#: apps/wei/views.py:1471
msgid "Attribute bus"
msgstr "Attribuer un bus"
#: apps/wei/views.py:1426
#: apps/wei/views.py:1511
msgid ""
"No first year student without a bus found. Either all of them have a bus, or "
"none has filled the survey yet."
@@ -4095,14 +4103,6 @@ msgstr "La note est indisponible pour le moment"
msgid "Thank you for your understanding -- The Respos Info of BDE"
msgstr "Merci de votre compréhension -- Les Respos Info du BDE"
#: note_kfet/templates/base_search.html:15
msgid "Search by attribute such as name..."
msgstr "Chercher par un attribut tel que le nom..."
#: note_kfet/templates/base_search.html:23
msgid "There is no results."
msgstr "Il n'y a pas de résultat."
#: note_kfet/templates/cas/logged.html:8
msgid ""
"<h3>Log In Successful</h3>You have successfully logged into the Central "
@@ -4110,9 +4110,10 @@ msgid ""
"your web browser when you are done accessing services that require "
"authentication!"
msgstr ""
"<h3>Connection réussie</h3>Vous vous êtes bien connecté au Service Central d'Authentification."
"<br/>Pour des raisons de sécurité, veuillez vous déconnecter et fermer votre navigateur internet "
"une fois que vous aurez fini d'accéder aux services qui requiert une authentification !"
"<h3>Connection réussie</h3>Vous vous êtes bien connecté au Service Central "
"d'Authentification.<br/>Pour des raisons de sécurité, veuillez vous "
"déconnecter et fermer votre navigateur internet une fois que vous aurez fini "
"d'accéder aux services qui requiert une authentification !"
#: note_kfet/templates/cas/logged.html:14
msgid "Log me out from all my sessions"
@@ -4358,6 +4359,15 @@ msgstr ""
"d'adhésion. Vous devez également valider votre adresse email en suivant le "
"lien que vous avez reçu."
#~ msgid "Deposit amount"
#~ msgstr "Caution"
#~ msgid ""
#~ "Please make sure the check is given before validating the registration"
#~ msgstr ""
#~ "Merci de vous assurer que le chèque a bien été donné avant de valider "
#~ "l'adhésion"
#~ msgid "caution amount"
#~ msgstr "montant de la caution"

View File

@@ -72,7 +72,6 @@ INSTALLED_APPS = [
# Note apps
'api',
'activity',
'family',
'food',
'logs',
'member',

View File

@@ -79,13 +79,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
<a class="nav-link {% if request.path_info == url %}active{% endif %}" href="{{ url }}"><i class="fa fa-exchange"></i> {% trans 'Transfer' %}</a>
</li>
{% endif %}
{% if user.is_authenticated %}
<li class="nav-item">
{% url 'family:family_list' as url %}
<a class="nav-link {% if request.path_info == url %}active{% endif %}" href="{{ url }}"><i class="fa fa-users"></i> {% trans 'Families' %}</a>
</li>
{% endif %}
{% if "auth.user"|model_list_length >= 2 %}
<li class="nav-item">
{% url 'member:user_list' as url %}

View File

@@ -21,9 +21,8 @@ urlpatterns = [
path('activity/', include('activity.urls')),
path('treasury/', include('treasury.urls')),
path('wei/', include('wei.urls')),
path('food/', include('food.urls')),
path('wrapped/', include('wrapped.urls')),
path('family/', include('family.urls')),
path('food/',include('food.urls')),
path('wrapped/',include('wrapped.urls')),
# Include Django Contrib and Core routers
path('i18n/', include('django.conf.urls.i18n')),