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 '
  • ' + text + '
  • \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 = '' + + 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 %} +
    +
    +

    {% trans "Delete achievement" %}

    +
    +
    +
    + {% 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 %} +
    +
    +

    {% trans "Validate achievement" %}

    +
    +
    +
    + {% 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 %} + +
    +
    +

    + {% trans "Invalid achievements history" %} +

    + + {% trans "Return to management page" %} + +
    + {% render_table invalid %} +
    + +
    +
    +

    + {% trans "Valid achievements history" %} +

    + + {% trans "Return to management page" %} + +
    + {% 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 %} +
    +

    + {{ title }} +

    +
    + +
    + {% csrf_token %} + {{ form|crispy }} + +
    +
    +
    +{% 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 %} +
    +

    + {{ family.name }} +

    +
    + + + +
    +
    + {% 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 %} +
    +

    + {{ title }} {{ challenge.name }} +

    +
    +
      + {% for field, value in fields %} +
    • {{ field }} : {{ value }}
    • + {% endfor %} +
    • {% trans "Obtained by " %} {{obtained}} + {% if obtained > 1 %} + {% trans "families" %} + {% else %} + {% trans "family" %} + {% endif %} +
    • +
    + + {% trans "Return to the challenge list" %} + + {% if update %} + + {% trans "Update" %} + + {% endif %} +
    +
    +{% 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 %} +
    +

    + {{ title }} +

    +
    +
    + {% csrf_token %} + {{ form | crispy }} + +
    +
    +
    +{% 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 %} +
    + +
    + +
    +

    + {{ title }} +

    + {% 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 %} +
    +
    + {% trans "Family members" %} +
    + {% render_table member_list %} +
    + +
    +{% endif %} + +{% if achievement_list.data %} +
    +
    + {% trans "Completed challenges" %} +
    + {% 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 %} +
    +

    + {{ title }} +

    +
    +
    + {% csrf_token %} + {{ form | crispy }} + +
    +
    +
    +{% 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 %} +
    + +
    + +
    +

    + {{ title }} +

    + {% 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 #} +
    +
    +
    +

    + {% trans "Families" %} +

    +
    +
    +
      +
      + {# User search with autocompletion #} + +
      +
      + + {# Summary of challenges and validate button #} +
      +
      +
      +

      + {% trans "Challenges" %} +

      +
      +
      +
        +
        + +
        +
        +
        + {% endif %} + + {# Create family/challenge buttons #} + {% if can_add_family or can_add_challenge %} +
        +

        + {% trans "Create a family or challenge" %} +

        +
        + {% if can_add_family %} + + {% trans "Add a family" %} + + {% endif %} + {% if can_add_challenge %} + + {% trans "Add a challenge" %} + + {% endif %} +
        +
        + {% endif %} +
        + + {# Buttons column #} +
        + {% if can_add_achievement %} +
        + {# Tabs for list and search #} + + + {# Tabs content #} +
        +
        +
        +
        + {% for challenge in all_challenges %} + + {% endfor %} +
        +
        + +
        +
        + +
        + {% endif %} +
        +
        + +{# achievement history #} +{% if table.data %} +
        + +
        + {% render_table table %} +
        +
        + + + +{% 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 %} +
        +

        + {{ title }} +

        +
        +
        +
        + {% csrf_token %} + {{ form |crispy }} + {% if user.note.display_image != "pic/default.png" %} + + {% endif %} +
        +
        + + +
        +
        +{% 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 %} + + {% endif %} + {% if "auth.user"|model_list_length >= 2 %}