diff --git a/apps/api/urls.py b/apps/api/urls.py
index c9e0dfa4..4452e4d7 100644
--- a/apps/api/urls.py
+++ b/apps/api/urls.py
@@ -19,6 +19,10 @@ 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')
diff --git a/apps/family/__init__.py b/apps/family/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/apps/family/api/__init__.py b/apps/family/api/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/apps/family/api/serializers.py b/apps/family/api/serializers.py
new file mode 100644
index 00000000..c902a9c5
--- /dev/null
+++ b/apps/family/api/serializers.py
@@ -0,0 +1,46 @@
+# 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__'
diff --git a/apps/family/api/urls.py b/apps/family/api/urls.py
new file mode 100644
index 00000000..35cf1409
--- /dev/null
+++ b/apps/family/api/urls.py
@@ -0,0 +1,20 @@
+# 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')
+]
diff --git a/apps/family/api/views.py b/apps/family/api/views.py
new file mode 100644
index 00000000..603a98ca
--- /dev/null
+++ b/apps/family/api/views.py
@@ -0,0 +1,98 @@
+# 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 api.filters import RegexSafeSearchFilter
+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, RegexSafeSearchFilter]
+ filterset_fields = ['name', 'description', 'score', 'rank', ]
+ search_fields = ['$name', '$description', ]
+
+
+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, RegexSafeSearchFilter]
+ filterset_fields = ['user__username', 'user__first_name', 'user__last_name', 'user__email', 'user__note__alias__name',
+ 'user__note__alias__normalized_name', 'family__name', 'family__description', 'year', ]
+ search_fields = ['$user__username', '$user__first_name', '$user__last_name', '$user__email', '$user__note__alias__name',
+ '$user__note__alias__normalized_name', '$family__name', '$family__description', '$year', ]
+
+
+class ChallengeViewSet(ReadProtectedModelViewSet):
+ """
+ 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, RegexSafeSearchFilter]
+ filterset_fields = ['name', 'description', 'points', ]
+ search_fields = ['$name', '$description', '$points', ]
+
+
+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, RegexSafeSearchFilter]
+ filterset_fields = ['family__name', 'family__description', 'challenge__name', 'challenge__description', 'obtained_at', 'valid', ]
+ search_fields = ['$family__name', '$family__description', '$challenge__name', '$challenge__description', ]
+
+
+class BatchAchievementsAPIView(APIView):
+ permission_classes = [IsAuthenticated]
+
+ def post(self, request, format=None):
+ 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)
+ results = []
+ for family in families:
+ for challenge in challenges:
+ a, created = Achievement.objects.get_or_create(family=family, challenge=challenge)
+ if created:
+ results.append({
+ 'family': family.name,
+ 'challenge': challenge.name,
+ 'status': 'created'
+ })
+ else:
+ results.append({
+ 'family': family.name,
+ 'challenge': challenge.name,
+ 'status': 'existed',
+ })
+ for family in families:
+ family.update_score()
+ Family.update_ranking()
+
+ return Response({'results': results}, status=status.HTTP_201_CREATED)
diff --git a/apps/family/apps.py b/apps/family/apps.py
new file mode 100644
index 00000000..47007531
--- /dev/null
+++ b/apps/family/apps.py
@@ -0,0 +1,11 @@
+# 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')
diff --git a/apps/family/forms.py b/apps/family/forms.py
new file mode 100644
index 00000000..dbc26ad3
--- /dev/null
+++ b/apps/family/forms.py
@@ -0,0 +1,44 @@
+# 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 ...',
+ },
+ )
+ }
diff --git a/apps/family/migrations/0001_initial.py b/apps/family/migrations/0001_initial.py
new file mode 100644
index 00000000..6a9c2357
--- /dev/null
+++ b/apps/family/migrations/0001_initial.py
@@ -0,0 +1,73 @@
+# 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')},
+ },
+ ),
+ ]
diff --git a/apps/family/migrations/0002_family_display_image.py b/apps/family/migrations/0002_family_display_image.py
new file mode 100644
index 00000000..d2cf118b
--- /dev/null
+++ b/apps/family/migrations/0002_family_display_image.py
@@ -0,0 +1,18 @@
+# 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'),
+ ),
+ ]
diff --git a/apps/family/migrations/0003_achievement_valid_alter_familymembership_family.py b/apps/family/migrations/0003_achievement_valid_alter_familymembership_family.py
new file mode 100644
index 00000000..9121a1ff
--- /dev/null
+++ b/apps/family/migrations/0003_achievement_valid_alter_familymembership_family.py
@@ -0,0 +1,24 @@
+# Generated by Django 5.2.4 on 2025-07-21 21:02
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('family', '0002_family_display_image'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='achievement',
+ name='valid',
+ field=models.BooleanField(default=False, verbose_name='valid'),
+ ),
+ migrations.AlterField(
+ model_name='familymembership',
+ name='family',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to='family.family', verbose_name='family'),
+ ),
+ ]
diff --git a/apps/family/migrations/0004_remove_challenge_obtained.py b/apps/family/migrations/0004_remove_challenge_obtained.py
new file mode 100644
index 00000000..2831bac1
--- /dev/null
+++ b/apps/family/migrations/0004_remove_challenge_obtained.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.2.4 on 2025-07-22 14:33
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('family', '0003_achievement_valid_alter_familymembership_family'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='challenge',
+ name='obtained',
+ ),
+ ]
diff --git a/apps/family/migrations/0005_alter_achievement_unique_together.py b/apps/family/migrations/0005_alter_achievement_unique_together.py
new file mode 100644
index 00000000..69e659b4
--- /dev/null
+++ b/apps/family/migrations/0005_alter_achievement_unique_together.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.2.4 on 2025-08-13 20:26
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('family', '0004_remove_challenge_obtained'),
+ ]
+
+ operations = [
+ migrations.AlterUniqueTogether(
+ name='achievement',
+ unique_together={('challenge', 'family')},
+ ),
+ ]
diff --git a/apps/family/migrations/__init__.py b/apps/family/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/apps/family/models.py b/apps/family/models.py
new file mode 100644
index 00000000..3ce27748
--- /dev/null
+++ b/apps/family/models.py
@@ -0,0 +1,207 @@
+# 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.urls import reverse_lazy
+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 get_absolute_url(self):
+ return reverse_lazy('family:family_detail', args=(self.pk,))
+
+ def update_score(self, *args, **kwargs):
+ challenge_set = Challenge.objects.select_for_update().filter(achievement__family=self, achievement__valid=True)
+ points_sum = challenge_set.aggregate(models.Sum("points"))
+ self.score = points_sum["points__sum"] if points_sum["points__sum"] else 0
+ 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=_('memberships'),
+ 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'),
+ )
+
+ @property
+ def obtained(self):
+ achievements = Achievement.objects.filter(challenge=self, valid=True)
+ return achievements.count()
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse_lazy('family:challenge_detail', args=(self.pk,))
+
+ @transaction.atomic
+ def save(self, *args, **kwargs):
+ 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')
+
+
+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,
+ )
+
+ valid = models.BooleanField(
+ verbose_name=_('valid'),
+ default=False,
+ )
+
+ class Meta:
+ unique_together = ('challenge', 'family',)
+ 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)
+
+ super().save(*args, **kwargs)
+
+ if update_score:
+ self.family.refresh_from_db()
+ self.family.update_score()
+
+ @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()
diff --git a/apps/family/static/family/img/default_picture.png b/apps/family/static/family/img/default_picture.png
new file mode 100644
index 00000000..41a31a1c
Binary files /dev/null and b/apps/family/static/family/img/default_picture.png differ
diff --git a/apps/family/static/family/js/achievements.js b/apps/family/static/family/js/achievements.js
new file mode 100644
index 00000000..50cd5935
--- /dev/null
+++ b/apps/family/static/family/js/achievements.js
@@ -0,0 +1,411 @@
+// 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')
+ })
+
+})
+
+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) {
+ challenge = b
+ }
+ })
+ if (challenge == null) {
+ challenge = {
+ id: id,
+ name: name,
+ }
+ 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)
+ })
+ 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 () {
+ 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 (data) {
+ reset()
+ data.results.forEach(function (result) {
+ if (result.status === 'created') {
+ addMsg(
+ interpolate(gettext('Invalid achievement for challenge %s ' +
+ 'and family %s created.'), [result.challenge, result.family]),
+ 'success',
+ 5000
+ )
+ } else {
+ addMsg(
+ interpolate(gettext('An achievement for challenge %s ' +
+ 'and family %s already exists.'), [result.challenge, result.family]),
+ 'danger',
+ 8000
+ )
+ }
+ })
+ }
+ })
+}
+
+
+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
entry with a given id and text
+ */
+function li (id, text, extra_css) {
+ return ' \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 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)
+ // 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 = ''
+ results.results.forEach(function (family) {
+ matched_html += li(family_prefix + '_' + family.id,
+ family.name,
+ '')
+ families.push(family)
+ })
+ matched_html += ' '
+
+ 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) {
+ disp = d
+ }
+ })
+ if (disp == null) {
+ disp = {
+ name: family.name,
+ id: family.id,
+ family: family,
+ }
+ 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,
+ '')
+ })
+
+ 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
+ * @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) {
+ })
+
+ 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)
+ })
+ })
+ }
+}
\ No newline at end of file
diff --git a/apps/family/tables.py b/apps/family/tables.py
new file mode 100644
index 00000000..36f257df
--- /dev/null
+++ b/apps/family/tables.py
@@ -0,0 +1,149 @@
+# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+import django_tables2 as tables
+from django.utils.html import format_html
+from django.utils.translation import gettext_lazy as _
+from django_tables2 import A
+from django.urls import reverse, reverse_lazy
+from note_kfet.middlewares import get_current_request
+from permission.backends import PermissionBackend
+
+from .models import Achievement, Challenge, Family, FamilyMembership
+
+
+class FamilyTable(tables.Table):
+ """
+ List all families
+ """
+
+ description = tables.Column(verbose_name=_("Description"))
+
+ class Meta:
+ attrs = {
+ 'class': 'table table-condensed table-striped table-hover'
+ }
+ 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
+ """
+
+ name = tables.Column(verbose_name=_("Name"))
+ description = tables.Column(verbose_name=_("Description"))
+ points = tables.Column(verbose_name=_("Points"))
+
+ class Meta:
+ attrs = {
+ 'class': 'table table-condensed table-striped table-hover'
+ }
+ 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.
+ """
+
+ def render_user(self, value):
+ # Display user's name, clickable if permission is granted
+ s = value.username
+ if PermissionBackend.check_perm(get_current_request(), "auth.view_user", value):
+ s = format_html("{name} ",
+ url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s)
+ return s
+
+ 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.
+ """
+
+ challenge = tables.Column(verbose_name=_("Challenge"))
+
+ validate = tables.LinkColumn(
+ 'family:achievement_validate',
+ args=[A('id')],
+ verbose_name=_("Validate"),
+ text=_("Validate"),
+ orderable=False,
+ attrs={
+ 'th': {
+ 'id': 'validate-achievement-header'
+ },
+ 'a': {
+ 'class': 'btn btn-success',
+ 'data-type': 'validate-achievement'
+ }
+ },
+ )
+
+ delete = tables.LinkColumn(
+ 'family:achievement_delete',
+ args=[A('id')],
+ verbose_name=_("Delete"),
+ text=_("Delete"),
+ orderable=False,
+ attrs={
+ 'th': {
+ 'id': 'delete-achievement-header'
+ },
+ 'a': {
+ 'class': 'btn btn-danger',
+ 'data-type': 'delete-achievement'
+ }
+ },
+ )
+
+ class Meta:
+ attrs = {
+ 'class': 'table table-condensed table-striped table-hover'
+ }
+ model = Achievement
+ fields = ('family', 'challenge', 'challenge__points', 'obtained_at', 'valid')
+ template_name = 'django_tables2/bootstrap4.html'
+ order_by = ('-obtained_at',)
+
+
+class FamilyAchievementTable(tables.Table):
+ """
+ Table des défis réalisés par une famille spécifique.
+ """
+
+ challenge = tables.Column(verbose_name=_("Challenge"))
+
+ class Meta:
+ model = Achievement
+ template_name = 'django_tables2/bootstrap4.html'
+ fields = ('challenge', 'challenge__points', 'obtained_at', 'valid')
+ attrs = {
+ 'class': 'table table-condensed table-striped table-hover'
+ }
+ order_by = ('-obtained_at',)
diff --git a/apps/family/templates/family/achievement_confirm_delete.html b/apps/family/templates/family/achievement_confirm_delete.html
new file mode 100644
index 00000000..3b378fa5
--- /dev/null
+++ b/apps/family/templates/family/achievement_confirm_delete.html
@@ -0,0 +1,25 @@
+{% extends "base.html" %}
+{% comment %}
+SPDX-License-Identifier: GPL-3.0-or-later
+{% endcomment %}
+{% load i18n crispy_forms_tags %}
+
+{% block content %}
+
+
+
+
+ {% blocktrans %}Are you sure you want to delete this achievement? This action can't be undone.{% endblocktrans %}
+
+
+
+
+{% endblock %}
diff --git a/apps/family/templates/family/achievement_confirm_validate.html b/apps/family/templates/family/achievement_confirm_validate.html
new file mode 100644
index 00000000..e417480a
--- /dev/null
+++ b/apps/family/templates/family/achievement_confirm_validate.html
@@ -0,0 +1,28 @@
+{% extends "base.html" %}
+{% comment %}
+SPDX-License-Identifier: GPL-3.0-or-later
+{% endcomment %}
+{% load i18n crispy_forms_tags %}
+
+{% block content %}
+
+
+
+
+ {% blocktrans %}Are you sure you want to validate this achievement? This action can't be undone.{% endblocktrans %}
+
+
+
+
+{% endblock %}
diff --git a/apps/family/templates/family/achievement_list.html b/apps/family/templates/family/achievement_list.html
new file mode 100644
index 00000000..63cd6255
--- /dev/null
+++ b/apps/family/templates/family/achievement_list.html
@@ -0,0 +1,33 @@
+{% 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 %}
+
+
+
+ {% render_table invalid %}
+
+
+
+
+ {% render_table valid %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/apps/family/templates/family/add_member.html b/apps/family/templates/family/add_member.html
new file mode 100644
index 00000000..6f77283d
--- /dev/null
+++ b/apps/family/templates/family/add_member.html
@@ -0,0 +1,60 @@
+{% extends "family/base.html" %}
+{% comment %}
+SPDX-License-Identifier: GPL-3.0-or-later
+{% endcomment %}
+{% load crispy_forms_tags i18n pretty_money %}
+
+{% block profile_content %}
+
+{% endblock %}
+
+{% block extrajavascript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/apps/family/templates/family/base.html b/apps/family/templates/family/base.html
new file mode 100644
index 00000000..444dffed
--- /dev/null
+++ b/apps/family/templates/family/base.html
@@ -0,0 +1,52 @@
+{% 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 %}
+
+
+ {% block profile_info %}
+
+
+
+
+
+
+
+
+ {% include "family/family_info.html" %}
+
+
+
+ {% endblock %}
+
+
+ {% block profile_content %}{% endblock %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/apps/family/templates/family/challenge_detail.html b/apps/family/templates/family/challenge_detail.html
new file mode 100644
index 00000000..23cb3f93
--- /dev/null
+++ b/apps/family/templates/family/challenge_detail.html
@@ -0,0 +1,36 @@
+{% 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 %}
+
+{% endblock %}
diff --git a/apps/family/templates/family/challenge_form.html b/apps/family/templates/family/challenge_form.html
new file mode 100644
index 00000000..27c7bed2
--- /dev/null
+++ b/apps/family/templates/family/challenge_form.html
@@ -0,0 +1,21 @@
+{% 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 %}
+
+{% endblock %}
diff --git a/apps/family/templates/family/challenge_list.html b/apps/family/templates/family/challenge_list.html
new file mode 100644
index 00000000..e7b554a2
--- /dev/null
+++ b/apps/family/templates/family/challenge_list.html
@@ -0,0 +1,42 @@
+{% 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 %}
+
+
+
+
+ {% render_table table %}
+
+{% endblock %}
+
+{% block extrajavascript %}
+
+{% endblock %}
diff --git a/apps/family/templates/family/family_detail.html b/apps/family/templates/family/family_detail.html
new file mode 100644
index 00000000..b81fa955
--- /dev/null
+++ b/apps/family/templates/family/family_detail.html
@@ -0,0 +1,29 @@
+{% 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 %}
+{% if member_list.data %}
+
+
+ {% render_table member_list %}
+
+
+
+{% endif %}
+
+{% if achievement_list.data %}
+
+
+ {% render_table achievement_list %}
+
+{% endif %}
+{% endblock %}
\ No newline at end of file
diff --git a/apps/family/templates/family/family_form.html b/apps/family/templates/family/family_form.html
new file mode 100644
index 00000000..27c7bed2
--- /dev/null
+++ b/apps/family/templates/family/family_form.html
@@ -0,0 +1,21 @@
+{% 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 %}
+
+{% endblock %}
diff --git a/apps/family/templates/family/family_info.html b/apps/family/templates/family/family_info.html
new file mode 100644
index 00000000..359fe6ef
--- /dev/null
+++ b/apps/family/templates/family/family_info.html
@@ -0,0 +1,15 @@
+{% load i18n pretty_money perms %}
+
+
+ {% trans 'name'|capfirst %}
+ {{ family.name }}
+
+ {% trans 'description'|capfirst %}
+ {{ family.description }}
+
+ {% trans 'score'|capfirst %}
+ {{ family.score }}
+
+ {% trans 'rank'|capfirst %}
+ {{ family.rank }}
+
diff --git a/apps/family/templates/family/family_list.html b/apps/family/templates/family/family_list.html
new file mode 100644
index 00000000..f06eba6e
--- /dev/null
+++ b/apps/family/templates/family/family_list.html
@@ -0,0 +1,43 @@
+{% 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 %}
+
+
+
+
+ {% render_table table %}
+
+
+{% endblock %}
+
+{% block extrajavascript %}
+
+{% endblock %}
diff --git a/apps/family/templates/family/manage.html b/apps/family/templates/family/manage.html
new file mode 100644
index 00000000..596fcb3a
--- /dev/null
+++ b/apps/family/templates/family/manage.html
@@ -0,0 +1,287 @@
+{% 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 %}
+
+
+
+
+ {% if can_add_achievement %}
+
+ {# Family details column #}
+
+
+
+
+
+
+ {% trans "Please select a family" %}
+
+
+
+
+ {# Family selection column #}
+
+
+
+
+ {# User search with autocompletion #}
+
+
+
+
+ {# Summary of challenges and validate button #}
+
+
+ {% endif %}
+
+ {# Create family/challenge buttons #}
+ {% if can_add_family or can_add_challenge %}
+
+ {% endif %}
+
+
+ {# Buttons column #}
+
+ {% if can_add_achievement %}
+
+ {# Tabs for list and search #}
+
+
+ {# Tabs content #}
+
+
+
+
+ {% for challenge in all_challenges %}
+
+ {{ challenge.name }} ({{ challenge.points }} {% trans "points" %})
+
+ {% endfor %}
+
+
+
+
+
+ {% for challenge in all_challenges %}
+
+ {{ challenge.name }} ({{ challenge.points }} {% trans "points" %})
+
+ {% endfor %}
+
+
+
+
+
+
+ {% endif %}
+
+
+
+{# achievement history #}
+{% if table.data %}
+
+
+
+ {% render_table table %}
+
+
+
+
+
+
+
+
+
+
{% trans "Are you sure you want to validate this challenge?" %}
+
{% trans "To have your challenge officially validated, please send a message with:" %}
+
+ {% trans "The name of the family" %}
+ {% trans "The name of the challenge" %}
+ {% trans "A photo or video as proof" %}
+
+
+ {% trans "Send it via WhatsApp to:" %}
+ {% if phone_numbers %}"
+ {% for num in phone_numbers %}
+ {{ num }} {% if not forloop.last %}, {% endif %}
+ {% endfor %}
+ {% endif %}
+
+
+
+
+
+
+{% endif %}
+{% endblock %}
+
+
+
+{% block extrajavascript %}
+
+
+
+{% endblock %}
diff --git a/apps/family/templates/family/picture_update.html b/apps/family/templates/family/picture_update.html
new file mode 100644
index 00000000..e5c6749c
--- /dev/null
+++ b/apps/family/templates/family/picture_update.html
@@ -0,0 +1,118 @@
+{% extends "family/base.html" %}
+{% comment %}
+SPDX-License-Identifier: GPL-3.0-or-later
+{% endcomment %}
+{% load i18n crispy_forms_tags %}
+
+{% block profile_content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block extracss %}
+
+{% endblock %}
+
+{% block extrajavascript%}
+
+
+
+{% endblock %}
diff --git a/apps/family/tests/__init__.py b/apps/family/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/apps/family/tests/test_family.py b/apps/family/tests/test_family.py
new file mode 100644
index 00000000..1dea7937
--- /dev/null
+++ b/apps/family/tests/test_family.py
@@ -0,0 +1,328 @@
+# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+import os
+
+from api.tests import TestAPI
+from django.contrib.auth.models import User
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.test import TestCase
+from rest_framework.test import APITestCase
+from django.urls import reverse
+from django.utils import timezone
+
+from ..api.views import FamilyViewSet, FamilyMembershipViewSet, ChallengeViewSet, AchievementViewSet
+from ..models import Family, FamilyMembership, Challenge, Achievement
+
+
+class TestFamily(TestCase):
+ """
+ Test family
+ """
+
+ def setUp(self):
+ self.user = User.objects.create_superuser(
+ username='admintoto',
+ password='toto1234',
+ email='toto@example.com',
+ )
+ self.client.force_login(self.user)
+
+ sess = self.client.session
+ sess['permission_mask'] = 42
+ sess.save()
+
+ self.family = Family.objects.create(
+ name='Test family',
+ description='',
+ )
+
+ self.challenge = Challenge.objects.create(
+ name='Test challenge',
+ description='',
+ points=100,
+ )
+
+ self.achievement = Achievement.objects.create(
+ family=self.family,
+ challenge=self.challenge,
+ valid=False,
+ )
+
+ def test_family_list(self):
+ """
+ Test display family list
+ """
+ response = self.client.get(reverse("family:family_list"))
+ self.assertEqual(response.status_code, 200)
+
+ def test_family_create(self):
+ """
+ Test create a family
+ """
+ response = self.client.get(reverse("family:family_create"))
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.post(reverse("family:family_create"), data={
+ "name": "Family toto",
+ "description": "A test family",
+ })
+ self.assertTrue(Family.objects.filter(name="Family toto").exists())
+ self.assertRedirects(response, reverse("family:manage"), 302, 200)
+
+ def test_family_detail(self):
+ """
+ Test display the detail of a family
+ """
+ response = self.client.get(reverse("family:family_detail", args=(self.family.pk,)))
+ self.assertEqual(response.status_code, 200)
+
+ def test_family_update(self):
+ """
+ Test update a family
+ """
+ response = self.client.get(reverse("family:family_update", args=(self.family.pk,)))
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.post(reverse("family:family_update", args=(self.family.pk,)), data=dict(
+ name="Toto family updated",
+ description="A larger description for the test family"
+ ))
+ self.assertRedirects(response, self.family.get_absolute_url(), 302, 200)
+ self.assertTrue(Family.objects.filter(name="Toto family updated").exists())
+
+ def test_family_update_picture(self):
+ """
+ Test update the picture of a family
+ """
+ response = self.client.get(reverse("family:update_pic", args=(self.family.pk,)))
+ self.assertEqual(response.status_code, 200)
+
+ old_pic = self.family.display_image
+
+ with open("apps/family/static/family/img/default_picture.png", "rb") as f:
+ image = SimpleUploadedFile("image.png", f.read(), "image/png")
+ response = self.client.post(reverse("family:update_pic", args=(self.family.pk,)), dict(
+ image=image,
+ x=0,
+ y=0,
+ width=200,
+ height=200,
+ ))
+ self.assertRedirects(response, self.family.get_absolute_url(), 302, 200)
+
+ self.family.refresh_from_db()
+ self.assertTrue(os.path.exists(self.family.display_image.path))
+ os.remove(self.family.display_image.path)
+
+ self.family.display_image = old_pic
+ self.family.save()
+
+ def test_family_add_member(self):
+ """
+ Test add memberships to a family
+ """
+ response = self.client.get(reverse("family:family_add_member", args=(self.family.pk,)))
+ self.assertEqual(response.status_code, 200)
+
+ user = User.objects.create(username="totototo")
+ user.profile.registration_valid = True
+ user.profile.email_confirmed = True
+ user.profile.save()
+ user.save()
+
+ response = self.client.post(reverse("family:family_add_member", args=(self.family.pk,)), data=dict(
+ user=user.pk,
+ ))
+ self.assertRedirects(response, self.family.get_absolute_url(), 302, 200)
+
+ self.assertTrue(FamilyMembership.objects.filter(user=user, family=self.family, year=timezone.now().year).exists())
+
+ def test_challenge_list(self):
+ """
+ Test display challenge list
+ """
+ response = self.client.get(reverse('family:challenge_list'))
+ self.assertEqual(response.status_code, 200)
+
+ def test_challenge_create(self):
+ """
+ Test create a challenge
+ """
+ response = self.client.get(reverse("family:challenge_create"))
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.post(reverse("family:challenge_create"), data={
+ "name": "Challenge for toto",
+ "description": "A test challenge",
+ "points": 50,
+ })
+ self.assertTrue(Challenge.objects.filter(name="Challenge for toto").exists())
+ self.assertRedirects(response, reverse("family:manage"), 302, 200)
+
+ def test_challenge_detail(self):
+ """
+ Test display the detail of a challenge
+ """
+ response = self.client.get(reverse("family:challenge_detail", args=(self.challenge.pk,)))
+ self.assertEqual(response.status_code, 200)
+
+ def test_challenge_update(self):
+ """
+ Test update a challenge
+ """
+ response = self.client.get(reverse("family:challenge_update", args=(self.challenge.pk,)))
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.post(reverse("family:challenge_update", args=(self.challenge.pk,)), data=dict(
+ name="Challenge updated",
+ description="Another description",
+ points=10,
+ ))
+ self.assertRedirects(response, self.challenge.get_absolute_url(), 302, 200)
+ self.assertTrue(Challenge.objects.filter(name="Challenge updated").exists())
+
+ def test_render_manage_page(self):
+ """
+ Test render manage page
+ """
+ response = self.client.get(reverse("family:manage"))
+ self.assertEqual(response.status_code, 200)
+
+ def test_validate_achievement(self):
+ """
+ Test validate an achievement
+ """
+ old_family_score = self.family.score
+
+ response = self.client.get(reverse("family:achievement_validate", args=(self.achievement.pk,)))
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.post(reverse("family:achievement_validate", args=(self.achievement.pk,)))
+ self.assertRedirects(response, reverse("family:achievement_list"), 302, 200)
+
+ self.achievement.refresh_from_db()
+ self.assertIs(self.achievement.valid, True)
+
+ self.family.refresh_from_db()
+ self.assertEqual(self.family.score, old_family_score + self.achievement.challenge.points)
+
+ def test_delete_achievement(self):
+ """
+ Test delete an achievement
+ """
+ response = self.client.get(reverse("family:achievement_delete", args=(self.achievement.pk,)))
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.delete(reverse("family:achievement_delete", args=(self.achievement.pk,)))
+ self.assertRedirects(response, reverse("family:achievement_list"), 302, 200)
+ self.assertFalse(Achievement.objects.filter(pk=self.achievement.pk).exists())
+
+
+class TestBatchAchievements(APITestCase):
+ def setUp(self):
+ self.user = User.objects.create_superuser(
+ username='admintoto',
+ password='toto1234',
+ email='toto@example.com',
+ )
+ self.client.force_login(self.user)
+
+ sess = self.client.session
+ sess['permission_mask'] = 42
+ sess.save()
+
+ self.families = [
+ Family.objects.create(name=f'Famille {i}', description='') for i in range(2)
+ ]
+ self.challenges = [
+ Challenge.objects.create(name=f'Challenge {i}', description='', points=50) for i in range(3)
+ ]
+
+ self.achievement = Achievement.objects.create(
+ family=self.families[0],
+ challenge=self.challenges[0],
+ valid=False,
+ )
+
+ self.url = reverse("family:api:batch_achievements")
+
+ def test_batch_achievement_creation(self):
+ family_ids = [f.id for f in self.families]
+ challenge_ids = [c.id for c in self.challenges]
+ response = self.client.post(
+ self.url,
+ data={
+ 'families': family_ids,
+ 'challenges': challenge_ids
+ },
+ format='json'
+ )
+
+ self.assertEqual(response.status_code, 201)
+ for result in response.data['results']:
+ if result['family'] == self.families[0].name and result['challenge'] == self.challenges[0].name:
+ self.assertEqual(result['status'], 'existed')
+ else:
+ self.assertEqual(result['status'], 'created')
+
+ expected_count = len(family_ids) * len(challenge_ids)
+ self.assertEqual(Achievement.objects.count(), expected_count)
+
+ # Check that correct couples family/challenge exist
+ for f in self.families:
+ for c in self.challenges:
+ self.assertTrue(
+ Achievement.objects.filter(family=f, challenge=c).exists()
+ )
+
+
+class TestFamilyAPI(TestAPI):
+ def setUp(self):
+ super().setUp()
+
+ self.family = Family.objects.create(
+ name='Test family',
+ description='',
+ )
+
+ self.familymembership = FamilyMembership.objects.create(
+ user=self.user,
+ family=self.family,
+ )
+
+ self.challenge = Challenge.objects.create(
+ name='Test challenge',
+ description='',
+ points=100,
+ )
+
+ self.achievement = Achievement.objects.create(
+ family=self.family,
+ challenge=self.challenge,
+ valid=False,
+ )
+
+ def test_family_api(self):
+ """
+ Load Family API page and test all filters and permissions
+ """
+ self.check_viewset(FamilyViewSet, '/api/family/family/')
+
+ def test_familymembership_api(self):
+ """
+ Load FamilyMembership API page and test all filters and permissions
+ """
+ self.check_viewset(FamilyMembershipViewSet, '/api/family/familymembership/')
+
+ def test_challenge_api(self):
+ """
+ Load Challenge API page and test all filters and permissions
+ """
+ self.check_viewset(ChallengeViewSet, '/api/family/challenge/')
+
+ def test_achievement_api(self):
+ """
+ Load Achievement API page and test all filters and permissions
+ """
+ self.check_viewset(AchievementViewSet, '/api/family/achievement/')
diff --git a/apps/family/urls.py b/apps/family/urls.py
new file mode 100644
index 00000000..edb0d18a
--- /dev/null
+++ b/apps/family/urls.py
@@ -0,0 +1,25 @@
+# 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('create/', views.FamilyCreateView.as_view(), name="family_create"),
+ path('/detail/', views.FamilyDetailView.as_view(), name="family_detail"),
+ path('/update/', views.FamilyUpdateView.as_view(), name="family_update"),
+ path('/update_pic/', views.FamilyPictureUpdateView.as_view(), name="update_pic"),
+ path('/add_member/', views.FamilyAddMemberView.as_view(), name="family_add_member"),
+ path('challenge/list/', views.ChallengeListView.as_view(), name="challenge_list"),
+ path('challenge/create/', views.ChallengeCreateView.as_view(), name="challenge_create"),
+ path('challenge//detail/', views.ChallengeDetailView.as_view(), name="challenge_detail"),
+ path('challenge//update/', views.ChallengeUpdateView.as_view(), name="challenge_update"),
+ path('manage/', views.FamilyManageView.as_view(), name="manage"),
+ path('achievement/list/', views.AchievementListView.as_view(), name="achievement_list"),
+ path('achievement//validate/', views.AchievementValidateView.as_view(), name="achievement_validate"),
+ path('achievement//delete/', views.AchievementDeleteView.as_view(), name="achievement_delete"),
+ path('api/family/', include(('family.api.urls', 'family_api'), namespace='api')),
+]
diff --git a/apps/family/views.py b/apps/family/views.py
new file mode 100644
index 00000000..cdbb7a17
--- /dev/null
+++ b/apps/family/views.py
@@ -0,0 +1,469 @@
+# 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.shortcuts import redirect
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.core.exceptions import PermissionDenied
+from django.db import transaction
+from django.views.generic import DetailView, UpdateView, ListView
+from django.views.generic.edit import DeleteView, FormMixin
+from django.views.generic.base import TemplateView
+from django.utils.translation import gettext_lazy as _
+from django_tables2 import SingleTableView, MultiTableMixin
+from permission.backends import PermissionBackend
+from permission.views import ProtectQuerysetMixin, ProtectedCreateView
+from django.urls import reverse_lazy
+from member.forms import ImageForm
+import phonenumbers
+
+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')}
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+
+ fake_family = Family(name="", description="")
+ fake_challenge = Challenge(name="", description="", points=0)
+ can_add_family = PermissionBackend.check_perm(self.request, "family.add_family", fake_family)
+ can_add_challenge = PermissionBackend.check_perm(self.request, "family.add_challenge", fake_challenge)
+
+ if Family.objects.exists() and Challenge.objects.exists():
+ fake_achievement = Achievement(family=Family.objects.first(), challenge=Challenge.objects.first(), valid=False)
+ can_add_achievement = PermissionBackend.check_perm(self.request, "family.add_achievement", fake_achievement)
+ else:
+ can_add_achievement = False
+
+ context["can_manage"] = can_add_family or can_add_challenge or can_add_achievement
+
+ return context
+
+
+class FamilyDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
+ """
+ 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(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin, DetailView):
+ """
+ Update profile picture of the family
+ """
+ model = Family
+ extra_context = {"title": _("Update family picture")}
+ template_name = 'family/picture_update.html'
+ form_class = ImageForm
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context['form'] = self.form_class(self.request.POST, self.request.FILES)
+ return context
+
+ def get_success_url(self):
+ """Redirect to family page after upload"""
+ return reverse_lazy('family:family_detail', kwargs={'pk': self.object.pk})
+
+ def post(self, request, *args, **kwargs):
+ form = self.get_form()
+ self.object = self.get_object()
+ return self.form_valid(form) if form.is_valid() else self.form_invalid(form)
+
+ @transaction.atomic
+ def form_valid(self, form):
+ """
+ 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)
+
+ # Save
+ self.object.display_image = image
+ self.object.save()
+ return super().form_valid(form)
+
+
+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')}
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+
+ fake_family = Family(name="", description="")
+ fake_challenge = Challenge(name="", description="", points=0)
+ can_add_family = PermissionBackend.check_perm(self.request, "family.add_family", fake_family)
+ can_add_challenge = PermissionBackend.check_perm(self.request, "family.add_challenge", fake_challenge)
+
+ if Family.objects.exists() and Challenge.objects.exists():
+ fake_achievement = Achievement(family=Family.objects.first(), challenge=Challenge.objects.first(), valid=False)
+ can_add_achievement = PermissionBackend.check_perm(self.request, "family.add_achievement", fake_achievement)
+ else:
+ can_add_achievement = False
+
+ context["can_manage"] = can_add_family or can_add_challenge or can_add_achievement
+
+ return context
+
+
+class ChallengeDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
+ """
+ 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()
+
+ perm = PermissionBackend.has_model_perm(self.request, Achievement(), "add")
+ perm = perm or PermissionBackend.has_model_perm(self.request, Challenge(), "add")
+ perm = perm or PermissionBackend.has_model_perm(self.request, Family(), "add")
+ if not perm:
+ raise PermissionDenied(_("You are not able to manage families and challenges."))
+ return super().dispatch(request, *args, **kwargs)
+
+ def get_queryset(self, **kwargs):
+ # 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.has_model_perm(self.request, Family(), "add")
+ context["can_add_challenge"] = PermissionBackend.has_model_perm(self.request, Challenge(), "add")
+ context["can_add_achievement"] = PermissionBackend.has_model_perm(self.request, Achievement(), "add")
+
+ # Get the user's family if they have one
+ try:
+ user_family_membership = FamilyMembership.objects.get(user=self.request.user)
+ context["user_family"] = user_family_membership.family
+ except FamilyMembership.DoesNotExist:
+ context["user_family"] = None
+
+ phone_numbers = [
+ u.profile.phone_number for u in User.objects.filter(
+ memberships__roles__id=35,
+ memberships__date_end__gte=date.today(),
+ profile__phone_number__isnull=False
+ ).distinct()
+ ]
+ formatted_phone_numbers = [phonenumbers.format_number(num, phonenumbers.PhoneNumberFormat.INTERNATIONAL) for num in phone_numbers if num]
+ context["phone_numbers"] = formatted_phone_numbers
+
+ return context
+
+ def get_table(self, **kwargs):
+ table = super().get_table(**kwargs)
+ table.exclude = ('delete', 'validate',)
+ table.orderable = False
+ return table
+
+ def get_table_data(self, **kwargs):
+ qs = super().get_queryset(**kwargs)
+
+ qs = qs.filter(PermissionBackend.filter_queryset(self.request, Achievement, "view"))
+
+ return qs
+
+
+class AchievementListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView):
+ """
+ List all achievements
+ """
+ model = Achievement
+ tables = [AchievementTable, AchievementTable, ]
+ extra_context = {'title': _('Achievement list')}
+
+ def dispatch(self, request, *args, **kwargs):
+ if not request.user.is_authenticated:
+ return self.handle_no_permission()
+
+ if not PermissionBackend.has_model_perm(self.request, Achievement(), "change"):
+ raise PermissionDenied(_("You are not able to see the achievement validation interface."))
+ return super().dispatch(request, *args, **kwargs)
+
+ def get_tables(self, **kwargs):
+ tables = super().get_tables(**kwargs)
+
+ tables[0].prefix = 'invalid-'
+ tables[1].prefix = 'valid-'
+ tables[1].exclude = ('validate', 'delete',)
+
+ return tables
+
+ def get_tables_data(self):
+ table_valid = self.get_queryset().filter(valid=True)
+ table_invalid = self.get_queryset().filter(valid=False)
+ return [table_invalid, table_valid, ]
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+
+ tables = context['tables']
+
+ context['invalid'] = tables[0]
+ context['valid'] = tables[1]
+ return context
+
+
+class AchievementValidateView(ProtectQuerysetMixin, LoginRequiredMixin, TemplateView):
+ """
+ Validate an achievement obtained by a family
+ """
+ template_name = 'family/achievement_confirm_validate.html'
+
+ def dispatch(self, request, *args, **kwargs):
+ if not request.user.is_authenticated:
+ return self.handle_no_permission()
+
+ fake_achievement = Achievement(
+ family=Family.objects.first(),
+ challenge=Challenge.objects.first(),
+ valid=False,
+ )
+ if not PermissionBackend.check_perm(self.request, "family.change_achievement_valid", fake_achievement):
+ raise PermissionDenied()
+ return super().dispatch(request, *args, **kwargs)
+
+ def post(self, request, pk):
+ achievement = Achievement.objects.get(pk=pk)
+
+ achievement.valid = True
+ achievement.save()
+
+ return redirect(reverse_lazy('family:achievement_list'))
+
+
+class AchievementDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView):
+ """
+ Delete an Achievement
+ """
+ model = Achievement
+
+ def dispatch(self, request, *args, **kwargs):
+ if not request.user.is_authenticated:
+ return self.handle_no_permission()
+
+ fake_achievement = Achievement(
+ family=Family.objects.first(),
+ challenge=Challenge.objects.first(),
+ valid=False,
+ )
+ if not PermissionBackend.check_perm(self.request, "family.change_achievement_valid", fake_achievement):
+ raise PermissionDenied()
+ return super().dispatch(request, *args, **kwargs)
+
+ def get_success_url(self):
+ return reverse_lazy('family:achievement_list')
diff --git a/apps/member/templates/member/includes/profile_info.html b/apps/member/templates/member/includes/profile_info.html
index 3a927c9f..3ea525d5 100644
--- a/apps/member/templates/member/includes/profile_info.html
+++ b/apps/member/templates/member/includes/profile_info.html
@@ -7,6 +7,17 @@
{% trans 'username'|capfirst %}
{{ user_object.username }}
+ {% trans 'family'|capfirst %}
+
+ {% if families %}
+ {% for fam in families %}
+ {{ fam.name }} {% if not forloop.last %}, {% endif %}
+ {% endfor %}
+ {% else %}
+ Aucune
+ {% endif %}
+
+
{% if user_object.pk == user.pk %}
{% trans 'password'|capfirst %}
diff --git a/apps/member/views.py b/apps/member/views.py
index 19f9b46f..3cf3cd32 100644
--- a/apps/member/views.py
+++ b/apps/member/views.py
@@ -26,6 +26,7 @@ from note_kfet.middlewares import _set_current_request
from permission.backends import PermissionBackend
from permission.models import Role
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
+from family.models import Family
from django import forms
from .forms import UserForm, ProfileForm, ImageForm, ClubForm, MembershipForm, \
@@ -207,6 +208,9 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context["can_unlock_note"] = not user.note.is_active and PermissionBackend\
.check_perm(self.request, "note.change_noteuser_is_active", modified_note)
+ families = Family.objects.filter(memberships__user=user).distinct()
+ context["families"] = families
+
return context
diff --git a/apps/permission/fixtures/initial.json b/apps/permission/fixtures/initial.json
index 248574e1..e642c4e6 100644
--- a/apps/permission/fixtures/initial.json
+++ b/apps/permission/fixtures/initial.json
@@ -4430,6 +4430,262 @@
"description": "Modifier le type de caution de mon inscription WEI tant qu'elle n'est pas validée"
}
},
+ {
+ "model": "permission.permission",
+ "pk": 311,
+ "fields": {
+ "model": [
+ "family",
+ "family"
+ ],
+ "query": "{}",
+ "type": "view",
+ "mask": 1,
+ "field": "",
+ "permanent": false,
+ "description": "Voir toutes les familles"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 312,
+ "fields": {
+ "model": [
+ "family",
+ "family"
+ ],
+ "query": "{}",
+ "type": "add",
+ "mask": 2,
+ "field": "",
+ "permanent": false,
+ "description": "Créer une famille"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 313,
+ "fields": {
+ "model": [
+ "family",
+ "family"
+ ],
+ "query": "{}",
+ "type": "change",
+ "mask": 2,
+ "field": "",
+ "permanent": false,
+ "description": "Modifier n'importe quelle famille"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 314,
+ "fields": {
+ "model": [
+ "family",
+ "family"
+ ],
+ "query": "{\"pk\": [\"user\", \"family_memberships\", \"family\", \"pk\"]}",
+ "type": "change",
+ "mask": 2,
+ "field": "",
+ "permanent": false,
+ "description": "Modifier ma famille"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 315,
+ "fields": {
+ "model": [
+ "family",
+ "familymembership"
+ ],
+ "query": "{}",
+ "type": "view",
+ "mask": 2,
+ "field": "",
+ "permanent": false,
+ "description": "Voir les membres de n'importe quelle famille"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 316,
+ "fields": {
+ "model": [
+ "family",
+ "familymembership"
+ ],
+ "query": "{\"family\": [\"user\", \"family_memberships\", \"family\"]}",
+ "type": "view",
+ "mask": 1,
+ "field": "",
+ "permanent": false,
+ "description": "Voir les membres de ma famille"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 317,
+ "fields": {
+ "model": [
+ "family",
+ "familymembership"
+ ],
+ "query": "{}",
+ "type": "add",
+ "mask": 2,
+ "field": "",
+ "permanent": false,
+ "description": "Ajouter un membre à n'importe quelle famille"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 318,
+ "fields": {
+ "model": [
+ "family",
+ "familymembership"
+ ],
+ "query": "{\"family\": [\"user\", \"family_memberships\", \"family\"]}",
+ "type": "add",
+ "mask": 2,
+ "field": "",
+ "permanent": false,
+ "description": "Ajouter un membre à ma famille"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 319,
+ "fields": {
+ "model": [
+ "family",
+ "challenge"
+ ],
+ "query": "{}",
+ "type": "view",
+ "mask": 1,
+ "field": "",
+ "permanent": false,
+ "description": "Voir tous les défis"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 320,
+ "fields": {
+ "model": [
+ "family",
+ "challenge"
+ ],
+ "query": "{}",
+ "type": "add",
+ "mask": 2,
+ "field": "",
+ "permanent": false,
+ "description": "Créer un défi"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 321,
+ "fields": {
+ "model": [
+ "family",
+ "challenge"
+ ],
+ "query": "{}",
+ "type": "change",
+ "mask": 2,
+ "field": "",
+ "permanent": false,
+ "description": "Modifier un défi"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 322,
+ "fields": {
+ "model": [
+ "family",
+ "challenge"
+ ],
+ "query": "{}",
+ "type": "delete",
+ "mask": 2,
+ "field": "{}",
+ "permanent": false,
+ "description": "Supprimer un défi"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 323,
+ "fields": {
+ "model": [
+ "family",
+ "achievement"
+ ],
+ "query": "{}",
+ "type": "view",
+ "mask": 1,
+ "field": "",
+ "permanent": false,
+ "description": "Voir tous les succès"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 324,
+ "fields": {
+ "model": [
+ "family",
+ "achievement"
+ ],
+ "query": "{}",
+ "type": "add",
+ "mask": 2,
+ "field": "",
+ "permanent": false,
+ "description": "Créer un succès"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 325,
+ "fields": {
+ "model": [
+ "family",
+ "achievement"
+ ],
+ "query": "{}",
+ "type": "change",
+ "mask": 1,
+ "field": "valid",
+ "permanent": false,
+ "description": "Valider un succès"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 326,
+ "fields": {
+ "model": [
+ "family",
+ "achievement"
+ ],
+ "query": "{}",
+ "type": "delete",
+ "mask": 1,
+ "field": "",
+ "permanent": false,
+ "description": "Supprimer un succès"
+ }
+ },
{
"model": "permission.role",
"pk": 1,
@@ -4482,9 +4738,13 @@
206,
248,
249,
- 255,
- 256,
- 257
+ 255,
+ 256,
+ 257,
+ 311,
+ 316,
+ 319,
+ 323
]
}
},
@@ -5008,7 +5268,7 @@
216
]
}
- },
+ },
{
"model": "permission.role",
"pk": 23,
@@ -5021,7 +5281,7 @@
32
]
}
- },
+ },
{
"model": "permission.role",
"pk": 24,
@@ -5030,7 +5290,7 @@
"name": "Staffeur⋅euse (S&L,Respo Tech,...)",
"permissions": []
}
- },
+ },
{
"model": "permission.role",
"pk": 25,
@@ -5056,7 +5316,7 @@
293
]
}
- },
+ },
{
"model": "permission.role",
"pk": 28,
@@ -5086,7 +5346,7 @@
269
]
}
- },
+ },
{
"model": "permission.role",
"pk": 30,
@@ -5094,15 +5354,15 @@
"for_club": 10,
"name": "Respo sorties",
"permissions": [
- 49,
- 62,
- 141,
- 241,
- 242,
+ 49,
+ 62,
+ 141,
+ 241,
+ 242,
243
]
}
- },
+ },
{
"model": "permission.role",
"pk": 31,
@@ -5114,7 +5374,7 @@
244
]
}
- },
+ },
{
"model": "permission.role",
"pk": 32,
@@ -5126,7 +5386,7 @@
245
]
}
- },
+ },
{
"model": "permission.role",
"pk": 33,
@@ -5134,15 +5394,48 @@
"for_club": 10,
"name": "Respo Jam",
"permissions": [
- 247,
- 250,
- 251,
- 252,
- 253,
+ 247,
+ 250,
+ 251,
+ 252,
+ 253,
254
]
}
- },
+ },
+ {
+ "model": "permission.role",
+ "pk": 34,
+ "fields": {
+ "for_club": 1,
+ "name": "Chef·fe de famille",
+ "permissions": [
+ 314,
+ 318,
+ 324
+ ]
+ }
+ },
+ {
+ "model": "permission.role",
+ "pk": 35,
+ "fields": {
+ "for_club": 1,
+ "name": "Respo familles",
+ "permissions": [
+ 312,
+ 313,
+ 315,
+ 317,
+ 320,
+ 321,
+ 322,
+ 324,
+ 325,
+ 326
+ ]
+ }
+ },
{
"model": "wei.weirole",
"pk": 12,
diff --git a/apps/treasury/views.py b/apps/treasury/views.py
index eab144c3..eb2fd0d7 100644
--- a/apps/treasury/views.py
+++ b/apps/treasury/views.py
@@ -168,7 +168,7 @@ class InvoiceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
class InvoiceDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView):
"""
- Delete a non-validated WEI registration
+ Delete a non-locked Invoice
"""
model = Invoice
extra_context = {"title": _("Delete invoice")}
diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po
index 2669da4c..72699854 100644
--- a/locale/fr/LC_MESSAGES/django.po
+++ b/locale/fr/LC_MESSAGES/django.po
@@ -56,7 +56,9 @@ msgstr "Cette personne est déjà invitée."
msgid "You can't invite more than 3 people to this activity."
msgstr "Vous ne pouvez pas inviter plus de 3 personnes à cette activité."
-#: apps/activity/models.py:28 apps/activity/models.py:63 apps/food/models.py:18
+#: apps/activity/models.py:28 apps/activity/models.py:63
+#: apps/family/models.py:14 apps/family/models.py:114
+#: apps/family/templates/family/family_info.html:4 apps/food/models.py:18
#: apps/food/models.py:35 apps/member/models.py:203
#: apps/member/templates/member/includes/club_info.html:4
#: apps/member/templates/member/includes/profile_info.html:4
@@ -98,6 +100,8 @@ msgstr "types d'activité"
#: apps/activity/models.py:68
#: apps/activity/templates/activity/includes/activity_info.html:19
+#: apps/family/models.py:20 apps/family/models.py:119
+#: apps/family/templates/family/family_info.html:7
#: apps/note/models/transactions.py:82 apps/permission/models.py:109
#: apps/permission/models.py:188 apps/wei/models.py:97 apps/wei/models.py:161
msgid "description"
@@ -118,9 +122,10 @@ msgstr "Lieu où l'activité est organisée, par exemple la Kfet."
msgid "type"
msgstr "type"
-#: apps/activity/models.py:91 apps/logs/models.py:22 apps/member/models.py:325
-#: apps/note/models/notes.py:148 apps/treasury/models.py:294
-#: apps/wei/models.py:190 apps/wei/templates/wei/attribute_bus_1A.html:13
+#: apps/activity/models.py:91 apps/family/models.py:87 apps/logs/models.py:22
+#: apps/member/models.py:325 apps/note/models/notes.py:148
+#: apps/treasury/models.py:294 apps/wei/models.py:190
+#: apps/wei/templates/wei/attribute_bus_1A.html:13
#: apps/wei/templates/wei/survey.html:15
msgid "user"
msgstr "utilisateur⋅rice"
@@ -157,7 +162,7 @@ msgstr "date de fin"
#: apps/activity/models.py:120
#: apps/activity/templates/activity/includes/activity_info.html:50
-#: apps/note/models/transactions.py:149
+#: apps/family/models.py:168 apps/note/models/transactions.py:149
msgid "valid"
msgstr "valide"
@@ -270,6 +275,7 @@ msgid "The validation of the activity is pending."
msgstr "La validation de cette activité est en attente."
#: apps/activity/tables.py:45
+#: apps/family/templates/family/picture_update.html:18
#: apps/member/templates/member/picture_update.html:18
#: apps/treasury/tables.py:110
msgid "Remove"
@@ -309,6 +315,8 @@ msgid "Balance"
msgstr "Solde du compte"
#: apps/activity/tables.py:141 apps/activity/tables.py:148
+#: apps/family/tables.py:111 apps/family/tables.py:112
+#: apps/family/templates/family/achievement_confirm_delete.html:21
#: apps/note/tables.py:166 apps/note/tables.py:173 apps/note/tables.py:234
#: apps/note/tables.py:281 apps/treasury/tables.py:39
#: apps/treasury/templates/treasury/invoice_confirm_delete.html:30
@@ -392,6 +400,9 @@ msgid "Entry done!"
msgstr "Entrée effectuée !"
#: apps/activity/templates/activity/activity_form.html:16
+#: apps/family/templates/family/add_member.html:17
+#: apps/family/templates/family/challenge_form.html:17
+#: apps/family/templates/family/family_form.html:17
#: apps/food/templates/food/food_update.html:17
#: apps/food/templates/food/manage_ingredients.html:48
#: apps/food/templates/food/qrcode.html:18
@@ -472,7 +483,7 @@ msgstr "Inviter"
msgid "Create new activity"
msgstr "Créer une nouvelle activité"
-#: apps/activity/views.py:71 note_kfet/templates/base.html:97
+#: apps/activity/views.py:71 note_kfet/templates/base.html:104
msgid "Activities"
msgstr "Activités"
@@ -525,6 +536,363 @@ msgstr "Entrées pour l'activité « {} »"
msgid "API"
msgstr "API"
+#: apps/family/apps.py:11 apps/family/models.py:94 apps/family/models.py:159
+#: apps/family/templates/family/challenge_detail.html:22
+#: apps/member/templates/member/includes/profile_info.html:10
+msgid "family"
+msgstr "famille"
+
+#: apps/family/models.py:24 apps/family/templates/family/family_info.html:10
+msgid "score"
+msgstr ""
+
+#: apps/family/models.py:29 apps/family/templates/family/family_info.html:13
+#: apps/permission/models.py:103
+msgid "rank"
+msgstr "rang"
+
+#: apps/family/models.py:33 apps/note/models/notes.py:44
+msgid "display image"
+msgstr "image affichée"
+
+#: apps/family/models.py:42
+msgid "Family"
+msgstr "Famille"
+
+#: apps/family/models.py:43 apps/family/templates/family/challenge_list.html:14
+#: apps/family/templates/family/family_list.html:14
+#: apps/family/templates/family/manage.html:15
+#: apps/family/templates/family/manage.html:48 note_kfet/templates/base.html:85
+msgid "Families"
+msgstr "Familles"
+
+#: apps/family/models.py:86
+msgid "family_memberships"
+msgstr "adhésions"
+
+#: apps/family/models.py:93 apps/member/models.py:356
+msgid "memberships"
+msgstr "adhésions"
+
+#: apps/family/models.py:98 apps/wei/models.py:25
+#: apps/wei/templates/wei/base.html:36
+msgid "year"
+msgstr "année"
+
+#: apps/family/models.py:104
+msgid "family membership"
+msgstr "adhésion"
+
+#: apps/family/models.py:105
+msgid "family memberships"
+msgstr "adhésions"
+
+#: apps/family/models.py:108
+#, python-brace-format
+msgid "Family membership of {user} to {family}"
+msgstr "Adhésion de {user} à la famille {family}"
+
+#: apps/family/models.py:123 apps/family/templates/family/manage.html:137
+#: apps/family/templates/family/manage.html:148
+msgid "points"
+msgstr ""
+
+#: apps/family/models.py:146
+msgid "challenge"
+msgstr "défi"
+
+#: apps/family/models.py:147
+msgid "challenges"
+msgstr "défis"
+
+#: apps/family/models.py:163
+msgid "obtained at"
+msgstr "réalisé le"
+
+#: apps/family/models.py:174
+msgid "achievement"
+msgstr "succès"
+
+#: apps/family/models.py:175
+msgid "achievements"
+msgstr "succès"
+
+#: apps/family/models.py:178
+#, python-brace-format
+msgid "Challenge {challenge} carried out by Family {family}"
+msgstr "Défi {challenge} réalisé par la famille {family}"
+
+#: apps/family/tables.py:20 apps/family/tables.py:43 apps/treasury/models.py:56
+msgid "Description"
+msgstr "Description"
+
+#: apps/family/tables.py:42 apps/family/templates/family/manage.html:56
+#: apps/food/forms.py:171 apps/food/templates/food/qrcode.html:29
+#: apps/food/templates/food/transformedfood_update.html:23
+#: apps/note/templates/note/transaction_form.html:132
+#: apps/treasury/models.py:61
+msgid "Name"
+msgstr "Nom"
+
+#: apps/family/tables.py:44
+msgid "Points"
+msgstr ""
+
+#: apps/family/tables.py:89 apps/family/tables.py:140
+msgid "Challenge"
+msgstr "Défi"
+
+#: apps/family/tables.py:94 apps/family/tables.py:95
+#: apps/family/templates/family/achievement_confirm_validate.html:23
+#: apps/treasury/templates/treasury/sogecredit_detail.html:63
+#: apps/wei/tables.py:60 apps/wei/tables.py:131
+msgid "Validate"
+msgstr "Valider"
+
+#: apps/family/templates/family/achievement_confirm_delete.html:10
+msgid "Delete achievement"
+msgstr "Supprimer le succès"
+
+#: apps/family/templates/family/achievement_confirm_delete.html:14
+msgid ""
+"Are you sure you want to delete this achievement? This action can't be "
+"undone."
+msgstr ""
+"Êtes-vous sûr⋅e de vouloir supprimer ce succès ? Cette action ne pourra pas "
+"être annulée."
+
+#: apps/family/templates/family/achievement_confirm_delete.html:20
+#: apps/family/templates/family/achievement_confirm_validate.html:20
+msgid "Return to achievements list"
+msgstr "Retour à la liste des succès"
+
+#: apps/family/templates/family/achievement_confirm_validate.html:10
+msgid "Validate achievement"
+msgstr "Valider le succès"
+
+#: apps/family/templates/family/achievement_confirm_validate.html:14
+msgid ""
+"Are you sure you want to validate this achievement? This action can't be "
+"undone."
+msgstr ""
+"Êtes-vous sûr⋅e de vouloir valider ce succès ? Cette action ne pourra pas "
+"être annulée."
+
+#: apps/family/templates/family/achievement_list.html:13
+msgid "Invalid achievements history"
+msgstr "Historique des succès non validés"
+
+#: apps/family/templates/family/achievement_list.html:16
+#: apps/family/templates/family/achievement_list.html:28
+msgid "Return to management page"
+msgstr "Retour à la page de gestion"
+
+#: apps/family/templates/family/achievement_list.html:25
+msgid "Valid achievements history"
+msgstr "Historique des succès validés"
+
+#: apps/family/templates/family/base.html:29
+#: apps/member/templates/member/base.html:57
+msgid "Add member"
+msgstr "Ajouter un·e membre"
+
+#: apps/family/templates/family/base.html:34
+#: apps/member/templates/member/base.html:48
+#: apps/member/templates/member/base.html:62 apps/member/views.py:62
+#: apps/registration/templates/registration/future_profile_detail.html:48
+#: apps/wei/templates/wei/weimembership_form.html:117
+msgid "Update Profile"
+msgstr "Modifier le profil"
+
+#: apps/family/templates/family/base.html:39
+#: apps/member/templates/member/base.html:52
+#: apps/member/templates/member/base.html:67
+msgid "View Profile"
+msgstr "Voir le profil"
+
+#: apps/family/templates/family/base.html:42
+msgid "Return to the family list"
+msgstr "Retour à la liste des familles"
+
+#: apps/family/templates/family/challenge_detail.html:18
+msgid "Obtained by "
+msgstr "Obtenu par"
+
+#: apps/family/templates/family/challenge_detail.html:20
+msgid "families"
+msgstr "familles"
+
+#: apps/family/templates/family/challenge_detail.html:27
+msgid "Return to the challenge list"
+msgstr "Retour à la liste des défis"
+
+#: apps/family/templates/family/challenge_detail.html:31
+#: apps/food/templates/food/food_detail.html:38
+msgid "Update"
+msgstr "Modifier"
+
+#: apps/family/templates/family/challenge_list.html:17
+#: apps/family/templates/family/family_list.html:17
+#: apps/family/templates/family/manage.html:18
+#: apps/family/templates/family/manage.html:71
+msgid "Challenges"
+msgstr "Defis"
+
+#: apps/family/templates/family/challenge_list.html:21
+#: apps/family/templates/family/family_list.html:21
+#: apps/family/templates/family/manage.html:21
+msgid "Manage"
+msgstr "Gestion"
+
+#: apps/family/templates/family/family_detail.html:13
+msgid "Family members"
+msgstr "Membres de la famille"
+
+#: apps/family/templates/family/family_detail.html:24
+msgid "Completed challenges"
+msgstr "Défis réalisés"
+
+#: apps/family/templates/family/manage.html:38
+msgid "Please select a family"
+msgstr "Sélectionnez une famille"
+
+#: apps/family/templates/family/manage.html:59
+msgid "Select my family"
+msgstr "Sélectionner ma famille"
+
+#: apps/family/templates/family/manage.html:79
+msgid "Validate!"
+msgstr "Valider"
+
+#: apps/family/templates/family/manage.html:91
+msgid "Create a family or challenge"
+msgstr "Créer une famille ou un défi"
+
+#: apps/family/templates/family/manage.html:96
+msgid "Add a family"
+msgstr "Ajouter une famille"
+
+#: apps/family/templates/family/manage.html:101
+msgid "Add a challenge"
+msgstr "Ajouter un défi"
+
+#: apps/family/templates/family/manage.html:118
+msgid "List"
+msgstr "Liste"
+
+#: apps/family/templates/family/manage.html:123
+#: apps/note/templates/note/conso_form.html:108
+msgid "Search"
+msgstr "Recherche"
+
+#: apps/family/templates/family/manage.html:143
+msgid "Search challenge..."
+msgstr "Chercher un défi..."
+
+#: apps/family/templates/family/manage.html:167
+msgid "Recent achievements history"
+msgstr "Historique des derniers succès"
+
+#: apps/family/templates/family/manage.html:180
+msgid "Confirmation"
+msgstr "Confirmation"
+
+#: apps/family/templates/family/manage.html:186
+msgid "Are you sure you want to validate this challenge?"
+msgstr "Êtes-vous sûr⋅e de vouloir valider ce défi ?"
+
+#: apps/family/templates/family/manage.html:187
+msgid ""
+"To have your challenge officially validated, please send a message with:"
+msgstr ""
+"Pour que le défi soit officiellement validé, envoyez un message contenant :"
+
+#: apps/family/templates/family/manage.html:189
+msgid "The name of the family"
+msgstr "Le nom de la famille"
+
+#: apps/family/templates/family/manage.html:190
+msgid "The name of the challenge"
+msgstr "Le nom du défi"
+
+#: apps/family/templates/family/manage.html:191
+msgid "A photo or video as proof"
+msgstr "Une preuve photo ou vidéo"
+
+#: apps/family/templates/family/manage.html:194
+msgid "Send it via WhatsApp to:"
+msgstr "Envoyez le via WhasApp au :"
+
+#: apps/family/templates/family/manage.html:202
+msgid "OK"
+msgstr "OK"
+
+#: apps/family/templates/family/picture_update.html:40
+#: apps/member/templates/member/picture_update.html:40
+msgid "Nevermind"
+msgstr "Annuler"
+
+#: apps/family/templates/family/picture_update.html:41
+#: apps/member/templates/member/picture_update.html:41
+msgid "Crop and upload"
+msgstr "Recadrer et envoyer"
+
+#: apps/family/views.py:32
+msgid "Create family"
+msgstr "Créer une famille"
+
+#: apps/family/views.py:54
+msgid "Families list"
+msgstr "Liste des familles"
+
+#: apps/family/views.py:81
+msgid "Family detail"
+msgstr "Détails de la famille"
+
+#: apps/family/views.py:129
+msgid "Update family"
+msgstr "Modifier la famille"
+
+#: apps/family/views.py:140
+msgid "Update family picture"
+msgstr "Modifier la photo de la famille"
+
+#: apps/family/views.py:188
+msgid "Add a new member to the family"
+msgstr "Ajouter un·e nouvelleau membre à la famille"
+
+#: apps/family/views.py:232
+msgid "Create challenge"
+msgstr "Créer un défi"
+
+#: apps/family/views.py:252
+msgid "Challenges list"
+msgstr "Liste des défis"
+
+#: apps/family/views.py:279 apps/food/views.py:439
+msgid "Details of:"
+msgstr "Détails de :"
+
+#: apps/family/views.py:302
+msgid "Update challenge"
+msgstr "Modifier un défi"
+
+#: apps/family/views.py:317
+msgid "Manage families and challenges"
+msgstr "Gérer les familles et défis"
+
+#: apps/family/views.py:328
+msgid "You are not able to manage families and challenges."
+msgstr "Vous n'êtes pas autorisé·e à gérer les familles et défis"
+
+#: apps/family/views.py:387
+msgid "Achievement list"
+msgstr "Liste des succès"
+
+#: apps/family/views.py:394
+msgid "You are not able to see the achievement validation interface."
+msgstr "Vous n'êtes pas autorisé·e à voir l'interface de validation de défi."
+
#: apps/food/apps.py:11
msgid "food"
msgstr "bouffe"
@@ -550,13 +918,6 @@ msgstr "Durée de vie (en heure)"
msgid "Fully used"
msgstr "Entièrement utilisé"
-#: apps/food/forms.py:171 apps/food/templates/food/qrcode.html:29
-#: apps/food/templates/food/transformedfood_update.html:23
-#: apps/note/templates/note/transaction_form.html:132
-#: apps/treasury/models.py:61
-msgid "Name"
-msgstr "Nom"
-
#: apps/food/forms.py:181
#, fuzzy
#| msgid "QR-code number"
@@ -665,10 +1026,6 @@ msgstr "Contenu dans"
msgid "Contain"
msgstr "Contient"
-#: apps/food/templates/food/food_detail.html:38
-msgid "Update"
-msgstr "Modifier"
-
#: apps/food/templates/food/food_detail.html:43
msgid "Add to a meal"
msgstr "Ajouter à un plat"
@@ -831,10 +1188,6 @@ msgstr "Aliment entièrement utilisé dans : {meal.name}"
msgid "Update an aliment"
msgstr "Modifier un aliment"
-#: apps/food/views.py:439
-msgid "Details of:"
-msgstr "Détails de :"
-
#: apps/food/views.py:449 apps/treasury/tables.py:149
msgid "Yes"
msgstr "Oui"
@@ -961,7 +1314,7 @@ msgstr "Taille maximale : 2 Mo"
msgid "This image cannot be loaded."
msgstr "Cette image ne peut pas être chargée."
-#: apps/member/forms.py:154 apps/member/views.py:117
+#: apps/member/forms.py:154 apps/member/views.py:118
#: apps/registration/forms.py:33 apps/registration/views.py:282
msgid "An alias with a similar name already exists."
msgstr "Un alias avec un nom similaire existe déjà."
@@ -1024,14 +1377,14 @@ msgid "hash"
msgstr "haché"
#: apps/member/models.py:37
-#: apps/member/templates/member/includes/profile_info.html:43
+#: apps/member/templates/member/includes/profile_info.html:54
#: apps/registration/templates/registration/future_profile_detail.html:40
#: apps/wei/templates/wei/weimembership_form.html:44
msgid "phone number"
msgstr "numéro de téléphone"
#: apps/member/models.py:44
-#: apps/member/templates/member/includes/profile_info.html:37
+#: apps/member/templates/member/includes/profile_info.html:48
#: apps/registration/templates/registration/future_profile_detail.html:34
#: apps/wei/templates/wei/weimembership_form.html:38
msgid "section"
@@ -1119,14 +1472,14 @@ msgid "Year of entry to the school (None if not ENS student)"
msgstr "Année d'entrée dans l'école (None si non-étudiant·e de l'ENS)"
#: apps/member/models.py:82
-#: apps/member/templates/member/includes/profile_info.html:47
+#: apps/member/templates/member/includes/profile_info.html:58
#: apps/registration/templates/registration/future_profile_detail.html:37
#: apps/wei/templates/wei/weimembership_form.html:41
msgid "address"
msgstr "adresse"
#: apps/member/models.py:89
-#: apps/member/templates/member/includes/profile_info.html:50
+#: apps/member/templates/member/includes/profile_info.html:61
#: apps/registration/templates/registration/future_profile_detail.html:43
#: apps/wei/templates/wei/weimembership_form.html:47
msgid "paid"
@@ -1198,7 +1551,7 @@ msgstr "Activez votre compte Note Kfet"
#: apps/member/models.py:209
#: apps/member/templates/member/includes/club_info.html:55
-#: apps/member/templates/member/includes/profile_info.html:40
+#: apps/member/templates/member/includes/profile_info.html:51
#: apps/registration/templates/registration/future_profile_detail.html:22
#: apps/wei/templates/wei/base.html:68
#: apps/wei/templates/wei/weimembership_form.html:20
@@ -1272,10 +1625,6 @@ msgstr "l'adhésion finit le"
msgid "membership"
msgstr "adhésion"
-#: apps/member/models.py:356
-msgid "memberships"
-msgstr "adhésions"
-
#: apps/member/models.py:360
#, python-brace-format
msgid "Membership of {user} for the club {club}"
@@ -1286,11 +1635,11 @@ msgstr "Adhésion de {user} pour le club {club}"
msgid "The role {role} does not apply to the club {club}."
msgstr "Le rôle {role} ne s'applique pas au club {club}."
-#: apps/member/models.py:388 apps/member/views.py:759
+#: apps/member/models.py:388 apps/member/views.py:763
msgid "User is already a member of the club"
msgstr "L'utilisateur·rice est déjà membre du club"
-#: apps/member/models.py:400 apps/member/views.py:768
+#: apps/member/models.py:400 apps/member/views.py:772
msgid "User is not a member of the parent club"
msgstr "L'utilisateur·rice n'est pas membre du club parent"
@@ -1342,22 +1691,6 @@ msgstr ""
msgid "Account #"
msgstr "Compte n°"
-#: apps/member/templates/member/base.html:48
-#: apps/member/templates/member/base.html:62 apps/member/views.py:61
-#: apps/registration/templates/registration/future_profile_detail.html:48
-#: apps/wei/templates/wei/weimembership_form.html:117
-msgid "Update Profile"
-msgstr "Modifier le profil"
-
-#: apps/member/templates/member/base.html:52
-#: apps/member/templates/member/base.html:67
-msgid "View Profile"
-msgstr "Voir le profil"
-
-#: apps/member/templates/member/base.html:57
-msgid "Add member"
-msgstr "Ajouter un·e membre"
-
#: apps/member/templates/member/base.html:72
#: apps/member/templates/member/base.html:93
#: apps/member/templates/member/base.html:114
@@ -1404,8 +1737,8 @@ msgstr ""
"seront à nouveau possible."
#: apps/member/templates/member/club_alias.html:10
-#: apps/member/templates/member/profile_alias.html:10 apps/member/views.py:318
-#: apps/member/views.py:559
+#: apps/member/templates/member/profile_alias.html:10 apps/member/views.py:322
+#: apps/member/views.py:563
msgid "Note aliases"
msgstr "Alias de la note"
@@ -1451,20 +1784,20 @@ msgid "membership fee"
msgstr "cotisation pour adhérer"
#: apps/member/templates/member/includes/club_info.html:43
-#: apps/member/templates/member/includes/profile_info.html:55
+#: apps/member/templates/member/includes/profile_info.html:66
#: apps/treasury/templates/treasury/sogecredit_detail.html:24
#: apps/wei/templates/wei/base.html:58
msgid "balance"
msgstr "solde du compte"
#: apps/member/templates/member/includes/club_info.html:47
-#: apps/member/templates/member/includes/profile_info.html:20
+#: apps/member/templates/member/includes/profile_info.html:31
#: apps/note/models/notes.py:287 apps/wei/templates/wei/base.html:64
msgid "aliases"
msgstr "alias"
#: apps/member/templates/member/includes/club_info.html:51
-#: apps/member/templates/member/includes/profile_info.html:24
+#: apps/member/templates/member/includes/profile_info.html:35
msgid "Manage aliases"
msgstr "Gérer les alias"
@@ -1475,24 +1808,24 @@ msgstr "Gérer les alias"
msgid "username"
msgstr "pseudo"
-#: apps/member/templates/member/includes/profile_info.html:11
+#: apps/member/templates/member/includes/profile_info.html:22
msgid "password"
msgstr "mot de passe"
-#: apps/member/templates/member/includes/profile_info.html:15
+#: apps/member/templates/member/includes/profile_info.html:26
msgid "Change password"
msgstr "Changer le mot de passe"
-#: apps/member/templates/member/includes/profile_info.html:28
+#: apps/member/templates/member/includes/profile_info.html:39
#: apps/note/models/notes.py:244
msgid "friendships"
msgstr "amitiés"
-#: apps/member/templates/member/includes/profile_info.html:32
+#: apps/member/templates/member/includes/profile_info.html:43
msgid "Manage friendships"
msgstr "Gérer les amitiés"
-#: apps/member/templates/member/includes/profile_info.html:63
+#: apps/member/templates/member/includes/profile_info.html:74
msgid "API token"
msgstr "Accès API"
@@ -1540,14 +1873,6 @@ msgstr "Introspection :"
msgid "Show my applications"
msgstr "Voir mes applications"
-#: apps/member/templates/member/picture_update.html:40
-msgid "Nevermind"
-msgstr "Annuler"
-
-#: apps/member/templates/member/picture_update.html:41
-msgid "Crop and upload"
-msgstr "Recadrer et envoyer"
-
#: apps/member/templates/member/profile_detail.html:11
#: apps/registration/templates/registration/future_profile_detail.html:28
#: apps/wei/templates/wei/weimembership_form.html:26
@@ -1593,51 +1918,51 @@ msgstr "Sauvegarder les changements"
msgid "Registrations"
msgstr "Inscriptions"
-#: apps/member/views.py:74 apps/registration/forms.py:23
+#: apps/member/views.py:75 apps/registration/forms.py:23
msgid "This address must be valid."
msgstr "Cette adresse doit être valide."
-#: apps/member/views.py:154
+#: apps/member/views.py:155
msgid "Profile detail"
msgstr "Détails de l'utilisateur⋅rice"
-#: apps/member/views.py:220
+#: apps/member/views.py:224
msgid "Search user"
msgstr "Chercher un·e utilisateur·rice"
-#: apps/member/views.py:272
+#: apps/member/views.py:276
msgid "Note friendships"
msgstr "Amitiés note"
-#: apps/member/views.py:342
+#: apps/member/views.py:346
msgid "Update note picture"
msgstr "Modifier la photo de la note"
-#: apps/member/views.py:391
+#: apps/member/views.py:395
msgid "Manage auth token"
msgstr "Gérer les jetons d'authentification"
-#: apps/member/views.py:418
+#: apps/member/views.py:422
msgid "Create new club"
msgstr "Créer un nouveau club"
-#: apps/member/views.py:437
+#: apps/member/views.py:441
msgid "Search club"
msgstr "Chercher un club"
-#: apps/member/views.py:475
+#: apps/member/views.py:479
msgid "Club detail"
msgstr "Détails du club"
-#: apps/member/views.py:587
+#: apps/member/views.py:591
msgid "Update club"
msgstr "Modifier le club"
-#: apps/member/views.py:621
+#: apps/member/views.py:625
msgid "Add new member to the club"
msgstr "Ajouter un·e nouvelleau membre au club"
-#: apps/member/views.py:750
+#: apps/member/views.py:754
msgid ""
"This user don't have enough money to join this club, and can't have a "
"negative balance."
@@ -1645,19 +1970,19 @@ msgstr ""
"Cet⋅te utilisateur⋅rice n'a pas assez d'argent pour rejoindre ce club et ne "
"peut pas avoir un solde négatif."
-#: apps/member/views.py:772
+#: apps/member/views.py:776
msgid "The membership must start after {:%m-%d-%Y}."
msgstr "L'adhésion doit commencer après le {:%d/%m/%Y}."
-#: apps/member/views.py:777
+#: apps/member/views.py:781
msgid "The membership must begin before {:%m-%d-%Y}."
msgstr "L'adhésion doit commencer avant le {:%d/%m/%Y}."
-#: apps/member/views.py:927
+#: apps/member/views.py:931
msgid "Manage roles of an user in the club"
msgstr "Gérer les rôles d'un⋅e utilisateur⋅rice dans le club"
-#: apps/member/views.py:952
+#: apps/member/views.py:956
msgid "Members of the club"
msgstr "Membres du club"
@@ -1736,10 +2061,6 @@ msgstr "dernière date de négatif"
msgid "last time the balance was negative"
msgstr "dernier instant où la note était en négatif"
-#: apps/note/models/notes.py:44
-msgid "display image"
-msgstr "image affichée"
-
#: apps/note/models/notes.py:53 apps/note/models/transactions.py:132
msgid "created at"
msgstr "créée le"
@@ -2048,10 +2369,6 @@ msgstr "Consommer !"
msgid "Highlighted buttons"
msgstr "Boutons mis en avant"
-#: apps/note/templates/note/conso_form.html:108
-msgid "Search"
-msgstr "Recherche"
-
#: apps/note/templates/note/conso_form.html:133
msgid "Search button..."
msgstr "Chercher un bouton..."
@@ -2210,10 +2527,6 @@ msgstr "Peut {type} {model}.{field} si {query}"
msgid "Can {type} {model} in {query}"
msgstr "Peut {type} {model} si {query}"
-#: apps/permission/models.py:103
-msgid "rank"
-msgstr "rang"
-
#: apps/permission/models.py:113
msgid "permission mask"
msgstr "masque de permissions"
@@ -2387,7 +2700,7 @@ msgstr ""
"Vous n'avez pas la permission d'ajouter une instance du modèle « {model} » "
"avec ces paramètres. Merci de les corriger et de réessayer."
-#: apps/permission/views.py:111 note_kfet/templates/base.html:121
+#: apps/permission/views.py:111 note_kfet/templates/base.html:128
msgid "Rights"
msgstr "Droits"
@@ -2592,7 +2905,7 @@ msgstr ""
msgid "Invalidate pre-registration"
msgstr "Invalider l'inscription"
-#: apps/treasury/apps.py:12 note_kfet/templates/base.html:103
+#: apps/treasury/apps.py:12 note_kfet/templates/base.html:110
msgid "Treasury"
msgstr "Trésorerie"
@@ -2638,10 +2951,6 @@ msgstr "Devis"
msgid "Object"
msgstr "Objet"
-#: apps/treasury/models.py:56
-msgid "Description"
-msgstr "Description"
-
#: apps/treasury/models.py:65
msgid "Address"
msgstr "Adresse"
@@ -2938,11 +3247,6 @@ msgstr ""
"Merci de demander à l'utilisateur·rice de recharger sa note avant de "
"supprimer la demande de crédit."
-#: apps/treasury/templates/treasury/sogecredit_detail.html:63
-#: apps/wei/tables.py:60 apps/wei/tables.py:131
-msgid "Validate"
-msgstr "Valider"
-
#: apps/treasury/templates/treasury/sogecredit_detail.html:71
msgid "Return to credit list"
msgstr "Retour à la liste des crédits"
@@ -3008,7 +3312,7 @@ 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:109
+#: note_kfet/templates/base.html:116
msgid "WEI"
msgstr "WEI"
@@ -3075,10 +3379,6 @@ msgstr ""
msgid "Rate between 0 and 5."
msgstr "Note entre 0 et 5."
-#: apps/wei/models.py:25 apps/wei/templates/wei/base.html:36
-msgid "year"
-msgstr "année"
-
#: apps/wei/models.py:29 apps/wei/templates/wei/base.html:30
#: apps/wrapped/models.py:20
msgid "date start"
@@ -3784,7 +4084,7 @@ msgstr "données json"
msgid "data in the wrapped and generated by the script generate_wrapped"
msgstr "donnée dans le wrapped et générée par le script generate_wrapped"
-#: apps/wrapped/models.py:70 note_kfet/templates/base.html:115
+#: apps/wrapped/models.py:70 note_kfet/templates/base.html:122
msgid "Wrapped"
msgstr "Wrapped"
@@ -3939,19 +4239,19 @@ msgstr "Le wrapped est public"
msgid "List of wrapped"
msgstr "Liste des wrapped"
-#: note_kfet/settings/base.py:180
+#: note_kfet/settings/base.py:181
msgid "German"
msgstr "Allemand"
-#: note_kfet/settings/base.py:181
+#: note_kfet/settings/base.py:182
msgid "English"
msgstr "Anglais"
-#: note_kfet/settings/base.py:182
+#: note_kfet/settings/base.py:183
msgid "Spanish"
msgstr "Espagnol"
-#: note_kfet/settings/base.py:183
+#: note_kfet/settings/base.py:184
msgid "French"
msgstr "Français"
@@ -4012,34 +4312,34 @@ msgstr ""
msgid "Reset"
msgstr "Réinitialiser"
-#: note_kfet/templates/base.html:85
+#: note_kfet/templates/base.html:92
msgid "Users"
msgstr "Utilisateur·rices"
-#: note_kfet/templates/base.html:91
+#: note_kfet/templates/base.html:98
msgid "Clubs"
msgstr "Clubs"
-#: note_kfet/templates/base.html:126
+#: note_kfet/templates/base.html:133
msgid "Admin"
msgstr "Admin"
-#: note_kfet/templates/base.html:140
+#: note_kfet/templates/base.html:147
msgid "My account"
msgstr "Mon compte"
-#: note_kfet/templates/base.html:145
+#: note_kfet/templates/base.html:152
msgid "Log out"
msgstr "Se déconnecter"
-#: note_kfet/templates/base.html:154
+#: note_kfet/templates/base.html:161
#: note_kfet/templates/registration/signup.html:6
#: note_kfet/templates/registration/signup.html:11
#: note_kfet/templates/registration/signup.html:28
msgid "Sign up"
msgstr "Inscription"
-#: note_kfet/templates/base.html:161
+#: note_kfet/templates/base.html:168
#: note_kfet/templates/registration/login.html:6
#: note_kfet/templates/registration/login.html:15
#: note_kfet/templates/registration/login.html:38
@@ -4047,7 +4347,7 @@ msgstr "Inscription"
msgid "Log in"
msgstr "Se connecter"
-#: note_kfet/templates/base.html:175
+#: note_kfet/templates/base.html:182
msgid ""
"You are not a BDE member anymore. Please renew your membership if you want "
"to use the note."
@@ -4055,7 +4355,7 @@ msgstr ""
"Vous n'êtes plus adhérent·e BDE. Merci de réadhérer si vous voulez profiter "
"de la note."
-#: note_kfet/templates/base.html:181
+#: note_kfet/templates/base.html:188
msgid ""
"Your e-mail address is not validated. Please check your mail inbox and click "
"on the validation link."
@@ -4063,7 +4363,7 @@ msgstr ""
"Votre adresse e-mail n'est pas validée. Merci de vérifier votre boîte mail "
"et de cliquer sur le lien de validation."
-#: note_kfet/templates/base.html:187
+#: note_kfet/templates/base.html:194
msgid ""
"You declared that you opened a bank account in the Société générale. The "
"bank did not validate the creation of the account to the BDE, so the "
@@ -4077,35 +4377,35 @@ msgstr ""
"vérification peut durer quelques jours. Merci de vous assurer de bien aller "
"au bout de vos démarches."
-#: note_kfet/templates/base.html:214
+#: note_kfet/templates/base.html:221
msgid "Contact us"
msgstr "Nous contacter"
-#: note_kfet/templates/base.html:216
+#: note_kfet/templates/base.html:223
msgid "Technical Support"
msgstr "Support technique"
-#: note_kfet/templates/base.html:218
+#: note_kfet/templates/base.html:225
msgid "Charte Info (FR)"
msgstr "Charte Info (FR)"
-#: note_kfet/templates/base.html:220
+#: note_kfet/templates/base.html:227
msgid "FAQ (FR)"
msgstr "FAQ (FR)"
-#: note_kfet/templates/base.html:222
+#: note_kfet/templates/base.html:229
msgid "Managed by BDE"
msgstr "Géré par le BDE"
-#: note_kfet/templates/base.html:224
+#: note_kfet/templates/base.html:231
msgid "Hosted by Cr@ns"
msgstr "Hébergé par le Cr@ans"
-#: note_kfet/templates/base.html:266
+#: note_kfet/templates/base.html:273
msgid "The note is not available for now"
msgstr "La note est indisponible pour le moment"
-#: note_kfet/templates/base.html:268
+#: note_kfet/templates/base.html:275
msgid "Thank you for your understanding -- The Respos Info of BDE"
msgstr "Merci de votre compréhension -- Les Respos Info du BDE"
@@ -4365,8 +4665,8 @@ msgstr ""
"d'adhésion. Vous devez également valider votre adresse email en suivant le "
"lien que vous avez reçu."
-#~ msgid "Choose {NB_WORDS} words:"
-#~ msgstr "Choisissez {NB_WORDS} mots :"
+#~ msgid "Challenge validated"
+#~ msgstr "Défi validé"
#~ msgid "Deposit amount"
#~ msgstr "Caution"
@@ -4403,6 +4703,11 @@ msgstr ""
#~ msgid "Enter a valid value."
#~ msgstr "dévalider"
+#, fuzzy
+#~| msgid "invalidate"
+#~ msgid "Enter a valid domain name."
+#~ msgstr "dévalider"
+
#, fuzzy
#~| msgid "invalidate"
#~ msgid "Enter a valid URL."
@@ -4418,20 +4723,10 @@ msgstr ""
#~ msgid "Enter a valid email address."
#~ msgstr "dévalider"
-#, fuzzy
-#~| msgid "This activity is not validated yet."
-#~ msgid "Enter a valid IPv4 address."
-#~ msgstr "Cette activité n'est pas encore validée."
-
-#, fuzzy
-#~| msgid "This activity is not validated yet."
-#~ msgid "Enter a valid IPv6 address."
-#~ msgstr "Cette activité n'est pas encore validée."
-
-#, fuzzy
-#~| msgid "This activity is not validated yet."
-#~ msgid "Enter a valid IPv4 or IPv6 address."
-#~ msgstr "Cette activité n'est pas encore validée."
+#, fuzzy, python-format
+#~| msgid "invalidate"
+#~ msgid "Enter a valid %(protocol)s address."
+#~ msgstr "dévalider"
#, fuzzy
#~| msgid "phone number"
@@ -4463,6 +4758,11 @@ msgstr ""
#~ msgid "%(model_name)s with this %(field_label)s already exists."
#~ msgstr "Un modèle de transaction avec un nom similaire existe déjà"
+#, fuzzy, python-format
+#~| msgid "This activity is not validated yet."
+#~ msgid "“%(value)s” value must be a decimal number."
+#~ msgstr "Cette activité n'est pas encore validée."
+
#, fuzzy
#~| msgid "phone number"
#~ msgid "Decimal number"
@@ -4478,6 +4778,11 @@ msgstr ""
#~ msgid "Email address"
#~ msgstr "adresse"
+#, fuzzy, python-format
+#~| msgid "This activity is not validated yet."
+#~ msgid "“%(value)s” value must be a float."
+#~ msgstr "Cette activité n'est pas encore validée."
+
#, fuzzy
#~| msgid "phone number"
#~ msgid "Floating point number"
@@ -4515,7 +4820,7 @@ msgstr ""
#, fuzzy, python-format
#~| msgid "A template with this name already exist"
-#~ msgid "%(model)s instance with %(field)s %(value)r does not exist."
+#~ msgid "%(model)s instance with %(field)s %(value)r is not a valid choice."
#~ msgstr "Un modèle de transaction avec un nom similaire existe déjà"
#, fuzzy
@@ -4588,6 +4893,11 @@ msgstr ""
#~ msgid "Wed"
#~ msgstr "Wrapped"
+#, fuzzy
+#~| msgid "add"
+#~ msgid "Sun"
+#~ msgstr "ajouter"
+
#, fuzzy
#~| msgid "Search"
#~ msgid "March"
@@ -4623,6 +4933,11 @@ msgstr ""
#~ msgid "feb"
#~ msgstr "cotisation"
+#, fuzzy
+#~| msgid "day"
+#~ msgid "mar"
+#~ msgstr "jour"
+
#, fuzzy
#~| msgid "day"
#~ msgid "may"
@@ -4633,6 +4948,11 @@ msgstr ""
#~ msgid "jun"
#~ msgstr "ajouter"
+#, fuzzy
+#~| msgid "add"
+#~ msgid "jul"
+#~ msgstr "ajouter"
+
#, fuzzy
#~| msgid "product"
#~ msgid "oct"
@@ -4702,6 +5022,34 @@ msgstr ""
#~ msgstr[0] "année"
#~ msgstr[1] "année"
+#, fuzzy, python-format
+#~| msgid "minute"
+#~ msgid "%(num)d month"
+#~ msgid_plural "%(num)d months"
+#~ msgstr[0] "minute"
+#~ msgstr[1] "minute"
+
+#, fuzzy, python-format
+#~| msgid "year"
+#~ msgid "%(num)d week"
+#~ msgid_plural "%(num)d weeks"
+#~ msgstr[0] "année"
+#~ msgstr[1] "année"
+
+#, fuzzy, python-format
+#~| msgid "year"
+#~ msgid "%(num)d day"
+#~ msgid_plural "%(num)d days"
+#~ msgstr[0] "année"
+#~ msgstr[1] "année"
+
+#, fuzzy, python-format
+#~| msgid "year"
+#~ msgid "%(num)d hour"
+#~ msgid_plural "%(num)d hours"
+#~ msgstr[0] "année"
+#~ msgstr[1] "année"
+
#, fuzzy, python-format
#~| msgid "minute"
#~ msgid "%(num)d minute"
@@ -4729,6 +5077,11 @@ msgstr ""
#~ msgid "No week specified"
#~ msgstr "Pas de motif spécifié"
+#, fuzzy, python-format
+#~| msgid "This activity is not validated yet."
+#~ msgid "“%(path)s” does not exist"
+#~ msgstr "Cette activité n'est pas encore validée."
+
#, fuzzy
#~| msgid "Client secret"
#~ msgid "Confidential"
@@ -4769,11 +5122,41 @@ msgstr ""
#~ msgid "The access token is valid but does not have enough scope."
#~ msgstr "L'utilisateur·ice n'a pas assez d'argent."
+#, fuzzy
+#~| msgid "Client secret"
+#~ msgid "Hash client secret"
+#~ msgstr "Secret client"
+
+#, fuzzy
+#~| msgid "Redirect Uris"
+#~ msgid "Post Logout Redirect Uris"
+#~ msgstr "URIs de redirection"
+
#, fuzzy
#~| msgid "Application requires following permissions:"
#~ msgid "Application requires the following permissions"
#~ msgstr "L'application requiert les permissions suivantes :"
+#, fuzzy
+#~| msgid "obtained_at"
+#~ msgid "Obtained at"
+#~ msgstr "Réalisé le"
+
+#, fuzzy
+#~| msgid "This activity is not validated yet."
+#~ msgid "Enter a valid IPv4 address."
+#~ msgstr "Cette activité n'est pas encore validée."
+
+#, fuzzy
+#~| msgid "This activity is not validated yet."
+#~ msgid "Enter a valid IPv6 address."
+#~ msgstr "Cette activité n'est pas encore validée."
+
+#, fuzzy
+#~| msgid "This activity is not validated yet."
+#~ msgid "Enter a valid IPv4 or IPv6 address."
+#~ msgstr "Cette activité n'est pas encore validée."
+
#~ msgid "The BDE membership is included in the WEI registration."
#~ msgstr "L'adhésion au BDE est offerte avec l'inscription au WEI."
@@ -4855,9 +5238,6 @@ msgstr ""
#~ msgid "Add a new meal"
#~ msgstr "Ajouter un nouveau plat"
-#~ msgid "Update a meal"
-#~ msgstr "Modifier le plat"
-
#, fuzzy
#~| msgid "invalidate"
#~ msgid "Enter a valid color."
diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po
index 59989ae6..8305ad04 100644
--- a/locale/fr/LC_MESSAGES/djangojs.po
+++ b/locale/fr/LC_MESSAGES/djangojs.po
@@ -151,3 +151,33 @@ msgid "An error occured while (in)validating this transaction:"
msgstr ""
"Une erreur est survenue lors de la validation/dévalidation de cette "
"transaction :"
+
+msgid "Recent achievements history"
+msgstr "Historique des derniers succès"
+
+msgid "Family"
+msgstr "Famille"
+
+msgid "obtained at"
+msgstr "Réalisé le"
+
+msgid "Return to the family list"
+msgstr "Retour à la liste des familles"
+
+msgid "rank"
+msgstr "rang"
+
+msgid "Challenge"
+msgstr "Défis"
+
+msgid "Invalid achievements history"
+msgstr "Historique des défis invalides"
+
+msgid "Valid achievements history"
+msgstr "Historique des défis valides"
+
+msgid "Return to management page"
+msgstr "Retour à la page de gestion"
+
+
+
diff --git a/note_kfet/settings/base.py b/note_kfet/settings/base.py
index 1fa0a0ea..b1274a7f 100644
--- a/note_kfet/settings/base.py
+++ b/note_kfet/settings/base.py
@@ -72,6 +72,7 @@ INSTALLED_APPS = [
# Note apps
'api',
'activity',
+ 'family',
'food',
'logs',
'member',
diff --git a/note_kfet/templates/base.html b/note_kfet/templates/base.html
index b6762c9b..5bbed6c7 100644
--- a/note_kfet/templates/base.html
+++ b/note_kfet/templates/base.html
@@ -79,6 +79,13 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% trans 'Transfer' %}
{% endif %}
+ {% if user.is_authenticated %}
+
+ {% url 'family:family_list' as url %}
+ {% trans 'Families' %}
+
+ {% endif %}
+
{% if "auth.user"|model_list_length >= 2 %}
{% url 'member:user_list' as url %}
diff --git a/note_kfet/urls.py b/note_kfet/urls.py
index fb4b2323..733e3bb7 100644
--- a/note_kfet/urls.py
+++ b/note_kfet/urls.py
@@ -21,8 +21,9 @@ 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('food/', include('food.urls')),
+ path('wrapped/', include('wrapped.urls')),
+ path('family/', include('family.urls')),
# Include Django Contrib and Core routers
path('i18n/', include('django.conf.urls.i18n')),