From 6c7d86185af363ec7f4694713579e1de9b6ae555 Mon Sep 17 00:00:00 2001 From: Ehouarn Date: Thu, 3 Jul 2025 14:34:04 +0200 Subject: [PATCH 01/32] Models --- apps/family/__init__.py | 0 apps/family/admin.py | 3 + apps/family/apps.py | 11 ++ apps/family/migrations/__init__.py | 0 apps/family/models.py | 165 +++++++++++++++++++++++++++++ apps/family/tests.py | 3 + apps/family/views.py | 3 + note_kfet/settings/base.py | 3 +- 8 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 apps/family/__init__.py create mode 100644 apps/family/admin.py create mode 100644 apps/family/apps.py create mode 100644 apps/family/migrations/__init__.py create mode 100644 apps/family/models.py create mode 100644 apps/family/tests.py create mode 100644 apps/family/views.py diff --git a/apps/family/__init__.py b/apps/family/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/family/admin.py b/apps/family/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/apps/family/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. 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/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..b3cb9abd --- /dev/null +++ b/apps/family/models.py @@ -0,0 +1,165 @@ +from django.db import models, transaction +from django.utils import timezone +from django.contrib.auth.models import User +from django.utils.translation import gettext_lazy as _ + + +class Family(models.Model): + name = models.CharField( + max_length=255, + verbose_name=_('name'), + unique=True + ) + + description = models.CharField( + max_length=255, + verbose_name=_('description') + ) + + score = models.PositiveIntegerField( + verbose_name=_('score') + ) + + rank = models.PositiveIntegerField( + verbose_name=_('rank'), + ) + + class Meta: + verbose_name = _('Family') + verbose_name_plural = _('Families') + + def __str__(self): + return self.name + + +class FamilyMembership(models.Model): + user = models.OneToOneField( + User, + on_delete=models.PROTECT, + related_name=_('family_memberships'), + verbose_name=_('user'), + ) + + family = models.ForeignKey( + Family, + on_delete=models.PROTECT, + related_name=_('members'), + verbose_name=_('family'), + ) + + year = models.PositiveIntegerField( + verbose_name=_('year'), + default=timezone.now().year, + ) + + class Meta: + 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 ChallengeCategory(models.Model): + name = models.CharField( + max_length=255, + verbose_name=_('name'), + unique=True, + ) + + class Meta: + verbose_name = _('challenge category') + verbose_name_plural = _('challenge categories') + + def __str__(self): + return self.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'), + ) + + category = models.ForeignKey( + ChallengeCategory, + verbose_name=_('category'), + on_delete=models.PROTECT + ) + + class Meta: + verbose_name = _('challenge') + verbose_name_plural = _('challenges') + + def __str__(self): + return self.name + + +class Achievement(models.Model): + challenge = models.ForeignKey( + Challenge, + on_delete=models.PROTECT, + + ) + family = models.ForeignKey( + Family, + on_delete=models.PROTECT, + verbose_name=_('family'), + ) + + obtained_at = models.DateTimeField( + verbose_name=_('obtained at'), + default=timezone.now, + ) + + class Meta: + verbose_name = _('achievement') + verbose_name_plural = _('achievements') + + def __str__(self): + return _('Challenge {challenge} carried out by Family {family}').format(challenge=self.challenge.name, family=self.family.name, ) + + @transaction.atomic + def save(self, *args, **kwargs): + """ + When saving, also grants points to the family + """ + self.family = Family.objects.select_for_update().get(pk=self.family_id) + challenge_points = self.challenge.points + is_new = self.pk is None + + super.save(*args, **kwargs) + + # Only grant points when getting a new achievement + if is_new: + self.family.refresh_from_db() + self.family.score += challenge_points + self.family._force_save = True + self.family.save() + + @transaction.atomic + def delete(self, *args, **kwargs): + """ + When deleting, also removes points from the family + """ + # Get the family and challenge before deletion + self.family = Family.objects.select_for_update().get(pk=self.family_id) + challenge_points = self.challenge.points + + # Delete the achievement + super().delete(*args, **kwargs) + + # Remove points from the family + self.family.refresh_from_db() + self.family.score -= challenge_points + self.family._force_save = True + self.family.save() diff --git a/apps/family/tests.py b/apps/family/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/apps/family/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/family/views.py b/apps/family/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/apps/family/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/note_kfet/settings/base.py b/note_kfet/settings/base.py index 0f3a757c..4b31e359 100644 --- a/note_kfet/settings/base.py +++ b/note_kfet/settings/base.py @@ -70,6 +70,7 @@ INSTALLED_APPS = [ # Note apps 'api', 'activity', + 'family', 'food', 'logs', 'member', @@ -270,7 +271,7 @@ OAUTH2_PROVIDER = { 'PKCE_REQUIRED': False, # PKCE (fix a breaking change of django-oauth-toolkit 2.0.0) 'OIDC_ENABLED': True, 'OIDC_RSA_PRIVATE_KEY': - os.getenv('OIDC_RSA_PRIVATE_KEY', '/var/secrets/oidc.key'), + os.getenv('OIDC_RSA_PRIVATE_KEY', 'CHANGE_ME_IN_ENV_SETTINGS').replace('\\n', '\n'), # for multilines 'SCOPES': { 'openid': "OpenID Connect scope" }, } From f6ad6197de62a231b0e95b8c0918fa0d467e2187 Mon Sep 17 00:00:00 2001 From: Ehouarn Date: Sat, 5 Jul 2025 19:47:35 +0200 Subject: [PATCH 02/32] ListViews et templates --- apps/family/admin.py | 3 - apps/family/migrations/0001_initial.py | 85 +++++++++++++++++++ apps/family/models.py | 37 ++++++++ apps/family/tables.py | 40 +++++++++ .../templates/family/challenge_list.html | 30 +++++++ apps/family/templates/family/family_list.html | 30 +++++++ apps/family/tests.py | 3 - apps/family/urls.py | 12 +++ apps/family/views.py | 64 +++++++++++++- note_kfet/templates/base.html | 7 ++ note_kfet/urls.py | 5 +- 11 files changed, 306 insertions(+), 10 deletions(-) delete mode 100644 apps/family/admin.py create mode 100644 apps/family/migrations/0001_initial.py create mode 100644 apps/family/tables.py create mode 100644 apps/family/templates/family/challenge_list.html create mode 100644 apps/family/templates/family/family_list.html delete mode 100644 apps/family/tests.py create mode 100644 apps/family/urls.py diff --git a/apps/family/admin.py b/apps/family/admin.py deleted file mode 100644 index 8c38f3f3..00000000 --- a/apps/family/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/apps/family/migrations/0001_initial.py b/apps/family/migrations/0001_initial.py new file mode 100644 index 00000000..86c7c135 --- /dev/null +++ b/apps/family/migrations/0001_initial.py @@ -0,0 +1,85 @@ +# Generated by Django 4.2.21 on 2025-07-04 19:05 + +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='ChallengeCategory', + 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')), + ], + options={ + 'verbose_name': 'challenge category', + 'verbose_name_plural': 'challenge categories', + }, + ), + 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(verbose_name='score')), + ('rank', models.PositiveIntegerField(verbose_name='rank')), + ], + options={ + 'verbose_name': 'Family', + 'verbose_name_plural': 'Families', + }, + ), + 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(verbose_name='obtained')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='family.challengecategory', verbose_name='category')), + ], + options={ + 'verbose_name': 'challenge', + 'verbose_name_plural': 'challenges', + }, + ), + 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/models.py b/apps/family/models.py index b3cb9abd..2b10999b 100644 --- a/apps/family/models.py +++ b/apps/family/models.py @@ -1,3 +1,6 @@ +# 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 @@ -53,6 +56,7 @@ class FamilyMembership(models.Model): ) class Meta: + unique_together = ('user', 'year',) verbose_name = _('family membership') verbose_name_plural = _('family memberships') @@ -96,6 +100,10 @@ class Challenge(models.Model): on_delete=models.PROTECT ) + obtained = models.PositiveIntegerField( + verbose_name=_('obtained') + ) + class Meta: verbose_name = _('challenge') verbose_name_plural = _('challenges') @@ -128,12 +136,27 @@ class Achievement(models.Model): def __str__(self): return _('Challenge {challenge} carried out by Family {family}').format(challenge=self.challenge.name, family=self.family.name, ) + @classmethod + def update_ranking(cls, *args, **kwargs): + """ + Update ranking when adding or removing points + """ + family_set = cls.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() + @transaction.atomic def save(self, *args, **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) challenge_points = self.challenge.points is_new = self.pk is None @@ -146,6 +169,13 @@ class Achievement(models.Model): self.family._force_save = True self.family.save() + self.challenge.refresh_from_db() + self.challenge.obtained += 1 + self.challenge._force_save = True + self.challenge.save() + + self.__class__.update_ranking() + @transaction.atomic def delete(self, *args, **kwargs): """ @@ -163,3 +193,10 @@ class Achievement(models.Model): self.family.score -= challenge_points self.family._force_save = True self.family.save() + + self.challenge.refresh_from_db() + self.challenge.obtained -= 1 + self.challenge._force_save = True + self.challenge.save() + + self.__class__.update_ranking() diff --git a/apps/family/tables.py b/apps/family/tables.py new file mode 100644 index 00000000..0e3ffc47 --- /dev/null +++ b/apps/family/tables.py @@ -0,0 +1,40 @@ +# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +import django_tables2 as tables +from django_tables2 import A + +from .models import Family, Challenge + + +class FamilyTable(tables.Table): + """ + List all families + """ + name = tables.LinkColumn( + "family:family_detail", + args=[A("pk")], + ) + + 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',) + + +class ChallengeTable(tables.Table): + """ + List all challenges + """ + class Meta: + attrs = { + 'class': 'table table-condensed table-striped table-hover' + } + order_by = ('id',) + model = Challenge + template_name = 'django_tables2/bootstrap4.html' + fields = ('name', 'points', 'category',) diff --git a/apps/family/templates/family/challenge_list.html b/apps/family/templates/family/challenge_list.html new file mode 100644 index 00000000..f16f37a7 --- /dev/null +++ b/apps/family/templates/family/challenge_list.html @@ -0,0 +1,30 @@ +{% 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 %} + diff --git a/apps/family/templates/family/family_list.html b/apps/family/templates/family/family_list.html new file mode 100644 index 00000000..38738fcf --- /dev/null +++ b/apps/family/templates/family/family_list.html @@ -0,0 +1,30 @@ +{% 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 %} + diff --git a/apps/family/tests.py b/apps/family/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/apps/family/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/apps/family/urls.py b/apps/family/urls.py new file mode 100644 index 00000000..99b87d92 --- /dev/null +++ b/apps/family/urls.py @@ -0,0 +1,12 @@ +# 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 FamilyListView, ChallengeListView + +app_name = 'family' +urlpatterns = [ + path('list/', FamilyListView.as_view(), name="family_list"), + path('challenge/list/', ChallengeListView.as_view(), name="challenge_list"), +] diff --git a/apps/family/views.py b/apps/family/views.py index 91ea44a2..8d41ccac 100644 --- a/apps/family/views.py +++ b/apps/family/views.py @@ -1,3 +1,63 @@ -from django.shortcuts import render +# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later -# Create your views here. +from django.contrib.auth.mixins import LoginRequiredMixin +from django.views.generic import DetailView, UpdateView +from django.utils.translation import gettext_lazy as _ +from django_tables2 import SingleTableView +from permission.views import ProtectQuerysetMixin, ProtectedCreateView + +from .models import Family, Challenge +from .tables import FamilyTable, ChallengeTable + + +class FamilyCreateView(ProtectQuerysetMixin, ProtectedCreateView): + """ + Create family + """ + model = Family + extra_context = {"title": _('Create family')} + + def get_sample_object(self): + return Family( + name="", + description="Sample family", + score=0, + rank=0, + ) + + +class FamilyListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): + """ + List existing Families + """ + model = Family + table_class = FamilyTable + extra_context = {"title": _('Families list')} + + +class FamilyDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): + """ + Display details of a family + """ + model = Family + context_object_name = "family" + extra_context = {"title": _('Family detail')} + + +class FamilyUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): + """ + Update the information of a family. + """ + model = Family + context_object_name = "family" + extra_context = {"title": _('Update family')} + + +class ChallengeListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): + """ + List all challenges + """ + model = Challenge + table_class = ChallengeTable + extra_context = {"title": _('Challenges list')} diff --git a/note_kfet/templates/base.html b/note_kfet/templates/base.html index 1c601c50..9301ee36 100644 --- a/note_kfet/templates/base.html +++ b/note_kfet/templates/base.html @@ -78,6 +78,13 @@ SPDX-License-Identifier: GPL-3.0-or-later {% trans 'Transfer' %} {% endif %} + {% if user.is_authenticated %} + + {% endif %} + {% if "auth.user"|model_list_length >= 2 %}
  • 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) + console.log("autoCompleteFamily commence") + // Configuration du tooltip + field.tooltip({ + html: true, + placement: 'bottom', + title: 'Chargement...', + trigger: 'manual', + container: field.parent(), + fallbackPlacement: 'clockwise' + }) + + // Masquer le tooltip lors d'un clic ailleurs + $(document).click(function (e) { + if (!e.target.id.startsWith(family_prefix)) { + field.tooltip('hide') + } + }) + + let old_pattern = null + + // Réinitialiser la recherche au clic + field.click(function () { + field.tooltip('hide') + field.removeClass('is-invalid') + field.val('') + old_pattern = '' + }) + + // Sur "Entrée", sélectionner la première famille + field.keypress(function (event) { + if (event.originalEvent.charCode === 13 && families.length > 0) { + const li_obj = field.parent().find('ul li').first() + displayFamily(families[0], families[0].name, user_family_field, profile_pic_field) + li_obj.trigger('click') + } + }) + + // Mise à jour des suggestions lors de la saisie + field.keyup(function (e) { + field.removeClass('is-invalid') + + if (e.originalEvent.charCode === 13) { return } + + const pattern = field.val() + + if (pattern === old_pattern) { return } + old_pattern = pattern + families.length = 0 + + if (pattern === '') { + field.tooltip('hide') + families.length = 0 + return + } + + // Appel à l'API pour récupérer les familles correspondantes + $.getJSON('/api/family/family/?format=json&search=' + pattern, + function (results) { + if (pattern !== $('#' + field_id).val()) { return } + + let matched_html = '
      ' + results.results.forEach(function (family) { + matched_html += li(family_prefix + '_' + family.id, + family.name, + '') + families.push(family) + }) + matched_html += '
    ' + + field.attr('data-original-title', matched_html).tooltip('show') + + results.results.forEach(function (family) { + const family_obj = $('#' + family_prefix + '_' + family.id) + family_obj.hover(function () { + displayFamily(family, family.name, user_family_field, profile_pic_field) + }) + family_obj.click(function () { + var disp = null + families_display.forEach(function (d) { + if (d.id === family.id) { + d.quantity += 1 + disp = d + } + }) + if (disp == null) { + disp = { + name: family.name, + id: family.id, + family: family, + quantity: 1 + } + families_display.push(disp) + } + + if (family_click && !family_click()) { return } + + const family_list = $('#' + family_list_id) + let html = '' + families_display.forEach(function (disp) { + html += li(family_prefix + '_' + disp.id, + disp.name + + '' + + disp.quantity + '', + '') + }) + + 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) { + if (disp.quantity > 1 || disp.id !== d.id) { + disp.quantity -= disp.id === d.id ? 1 : 0 + new_families_display.push(disp) + html += li(family_prefix + '_' + disp.id, disp.name + + '' + disp.quantity + '') + } + }) + + 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/templates/family/manage.html b/apps/family/templates/family/manage.html index 84506884..275337fa 100644 --- a/apps/family/templates/family/manage.html +++ b/apps/family/templates/family/manage.html @@ -9,180 +9,176 @@ SPDX-License-Identifier: GPL-3.0-or-later {% block content %}
    -
    -
    -
    - {# User details column #} -
    -
    - - - -
    - {% trans "Please select a family" %} -
    -
    -
    +
    +
    + {# Family details column #} +
    +
    + + + +
    + {% trans "Please select a family" %} +
    +
    +
    - {# Family selection column #} -
    + {# Family selection column #} +
    +
    +
    +

    + {% trans "Families" %} +

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

      + {% trans "Challenges" %} +

      +
      +
      +
        +
        + +
        +
        +
        + + {# Create family/challenge buttons #}
        -
        -

        - {% trans "Families" %} -

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

          - {% trans "Challenges" %} -

          -
          -
          -
            -
            - -
            -
            -
            - - {# Create family/challenge buttons #} -
            -

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

            -
            - {% if can_add_family %} - - {% trans "Add a family" %} - - {% endif %} - {% if can_add_challenge %} - - {% trans "Add a challenge" %} - - {% endif %} -
            -
            -
            - - {# Buttons column #} -
            -
            - {# Tabs for list and search #} - - - {# Tabs content #} -
            -
            -
            -
            - {% for challenge in all_challenges %} - - {% endfor %} -
            -
            - + {% endif %} +
            +
            +
            + + {# Buttons column #} +
            +
            + {# Tabs for list and search #} + + + {# Tabs content #} +
            +
            +
            +
            + {% for challenge in all_challenges %} + + {% endfor %} +
            +
            + +
            +
            + + {# Mode switch #} +
            -
            - - {# Mode switch #} -
            -
            {# transaction history #} -
            -
            -

            - {% trans "Recent achievements history" %} -

            -
            - {% render_table table %} -
            +
            +
            +

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

            +
            + {% render_table table %} +
            {% endblock %} -{% block extrajavascript %} - - + + {% endfor %} + + {% for challenge in all_challenges %} + document.getElementById("search_challenge{{ challenge.id }}").addEventListener("click", function() { + addChallenge({{ challenge.id}}, "{{ challenge.name|escapejs }}", {{ challenge.points }}); + }); + {% endfor %} + {% endblock %} \ No newline at end of file From 67b936ae9895476f55eafb3f7e150e70aa237708 Mon Sep 17 00:00:00 2001 From: Ehouarn Date: Fri, 18 Jul 2025 21:01:15 +0200 Subject: [PATCH 14/32] Rank calculation optimized --- apps/family/api/urls.py | 8 +++-- apps/family/api/views.py | 30 ++++++++++++++++ apps/family/models.py | 7 ++-- apps/family/static/family/js/achievements.js | 36 ++++++++++++++------ apps/family/urls.py | 3 +- 5 files changed, 67 insertions(+), 17 deletions(-) diff --git a/apps/family/api/urls.py b/apps/family/api/urls.py index b231ef99..e94776d7 100644 --- a/apps/family/api/urls.py +++ b/apps/family/api/urls.py @@ -1,7 +1,7 @@ # Copyright (C) 2018-2025 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later - -from .views import FamilyViewSet, FamilyMembershipViewSet, ChallengeViewSet, AchievementViewSet +from django.urls import path +from .views import FamilyViewSet, FamilyMembershipViewSet, ChallengeViewSet, AchievementViewSet, BatchAchievementsAPIView def register_family_urls(router, path): @@ -12,3 +12,7 @@ def register_family_urls(router, path): 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') +] \ No newline at end of file diff --git a/apps/family/api/views.py b/apps/family/api/views.py index f2fe0208..d568c1c6 100644 --- a/apps/family/api/views.py +++ b/apps/family/api/views.py @@ -4,6 +4,14 @@ from api.viewsets import ReadProtectedModelViewSet from django_filters.rest_framework import DjangoFilterBackend from rest_framework.filters import SearchFilter +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework import status +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST +from django.http import JsonResponse +import json from .serializers import FamilySerializer, FamilyMembershipSerializer, ChallengeSerializer, AchievementSerializer from ..models import Family, FamilyMembership, Challenge, Achievement @@ -59,3 +67,25 @@ class AchievementViewSet(ReadProtectedModelViewSet): filter_backends = [DjangoFilterBackend, SearchFilter] filterset_fields = ['name', ] search_fields = ['$name', ] + + +class BatchAchievementsAPIView(APIView): + permission_classes = [IsAuthenticated] + def post(self, request, format=None): + print("POST de la view spéciale") + family_ids = request.data.get('families', []) + challenge_ids = request.data.get('challenges', []) + + families = Family.objects.filter(id__in=family_ids) + challenges = Challenge.objects.filter(id__in=challenge_ids) + + for family in families: + for challenge in challenges: + a = Achievement(family=family, challenge=challenge) + a.save(update_score=False) + + for family in families: + family.update_score() + Family.update_ranking() + + return Response({'status': 'ok'}, status=status.HTTP_201_CREATED) \ No newline at end of file diff --git a/apps/family/models.py b/apps/family/models.py index 1d2d0d34..1acc9ba8 100644 --- a/apps/family/models.py +++ b/apps/family/models.py @@ -165,7 +165,7 @@ class Achievement(models.Model): return _('Challenge {challenge} carried out by Family {family}').format(challenge=self.challenge.name, family=self.family.name, ) @transaction.atomic - def save(self, *args, **kwargs): + def save(self, *args, update_score=True, **kwargs): """ When saving, also grants points to the family """ @@ -175,8 +175,9 @@ class Achievement(models.Model): super().save(*args, **kwargs) - self.family.refresh_from_db() - self.family.update_score() + if update_score: + self.family.refresh_from_db() + self.family.update_score() # Count only when getting a new achievement if is_new: diff --git a/apps/family/static/family/js/achievements.js b/apps/family/static/family/js/achievements.js index dba07f0d..db29923d 100644 --- a/apps/family/static/family/js/achievements.js +++ b/apps/family/static/family/js/achievements.js @@ -113,33 +113,47 @@ function reset () { * Apply all transactions: all notes in `notes` buy each item in `buttons` */ function consumeAll () { - console.log("consumeAll lancée") if (LOCK) { return } - LOCK = true let error = false if (notes_display.length === 0) { - document.getElementById('note').classList.add('is-invalid') - $('#note_list').html(li('', 'Ajoutez des familles.', 'text-danger')) + // ... gestion erreur ... error = true } - if (buttons.length === 0) { - $('#consos_list').html(li('', 'Ajoutez des défis.', 'text-danger')) + // ... gestion erreur ... error = true } - if (error) { LOCK = false return } - notes_display.forEach(function (family) { - buttons.forEach(function (challenge) { - grantAchievement(family, challenge) - }) + // Récupérer les IDs des familles et des challenges + const family_ids = notes_display.map(fam => fam.id) + const challenge_ids = buttons.map(chal => chal.id) + + $.ajax({ + url: '/family/api/family/achievements/batch/', + type: 'POST', + data: JSON.stringify({ + families: family_ids, + challenges: challenge_ids + }), + contentType: 'application/json', + headers: { + 'X-CSRFToken': CSRF_TOKEN + }, + success: function () { + reset() + addMsg("Défis validés pour les familles !", 'success', 5000) + }, + error: function (e) { + reset() + addMsg("Erreur lors de la création des achievements.",'danger',5000) + } }) } diff --git a/apps/family/urls.py b/apps/family/urls.py index 094ed505..ee491ecc 100644 --- a/apps/family/urls.py +++ b/apps/family/urls.py @@ -1,7 +1,7 @@ # Copyright (C) 2018-2025 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -from django.urls import path +from django.urls import path, include from . import views @@ -18,4 +18,5 @@ urlpatterns = [ 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('api/family/', include('family.api.urls')), ] From 9e700fd3de664799c11d4076ce4069d481ffc210 Mon Sep 17 00:00:00 2001 From: Ehouarn Date: Fri, 18 Jul 2025 22:11:43 +0200 Subject: [PATCH 15/32] Achievement delete --- apps/family/api/urls.py | 4 ++- apps/family/api/views.py | 7 ++-- apps/family/tables.py | 23 ++++++++++-- .../family/achievement_confirm_delete.html | 27 ++++++++++++++ .../templates/family/achievement_list.html | 22 ++++++++++++ apps/family/templates/family/manage.html | 19 +++++----- apps/family/urls.py | 2 ++ apps/family/views.py | 35 +++++++++++++++++-- apps/treasury/views.py | 2 +- 9 files changed, 119 insertions(+), 22 deletions(-) create mode 100644 apps/family/templates/family/achievement_confirm_delete.html create mode 100644 apps/family/templates/family/achievement_list.html diff --git a/apps/family/api/urls.py b/apps/family/api/urls.py index e94776d7..35cf1409 100644 --- a/apps/family/api/urls.py +++ b/apps/family/api/urls.py @@ -1,6 +1,7 @@ # 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 @@ -13,6 +14,7 @@ def register_family_urls(router, path): router.register(path + '/challenge', ChallengeViewSet) router.register(path + '/achievement', AchievementViewSet) + urlpatterns = [ path('achievements/batch/', BatchAchievementsAPIView.as_view(), name='batch_achievements') -] \ No newline at end of file +] diff --git a/apps/family/api/views.py b/apps/family/api/views.py index d568c1c6..50ac0496 100644 --- a/apps/family/api/views.py +++ b/apps/family/api/views.py @@ -8,10 +8,6 @@ from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework import status -from django.views.decorators.csrf import csrf_exempt -from django.views.decorators.http import require_POST -from django.http import JsonResponse -import json from .serializers import FamilySerializer, FamilyMembershipSerializer, ChallengeSerializer, AchievementSerializer from ..models import Family, FamilyMembership, Challenge, Achievement @@ -71,6 +67,7 @@ class AchievementViewSet(ReadProtectedModelViewSet): class BatchAchievementsAPIView(APIView): permission_classes = [IsAuthenticated] + def post(self, request, format=None): print("POST de la view spéciale") family_ids = request.data.get('families', []) @@ -88,4 +85,4 @@ class BatchAchievementsAPIView(APIView): family.update_score() Family.update_ranking() - return Response({'status': 'ok'}, status=status.HTTP_201_CREATED) \ No newline at end of file + return Response({'status': 'ok'}, status=status.HTTP_201_CREATED) diff --git a/apps/family/tables.py b/apps/family/tables.py index f7eb2a16..871dfd35 100644 --- a/apps/family/tables.py +++ b/apps/family/tables.py @@ -3,6 +3,8 @@ import django_tables2 as tables from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from django_tables2 import A from .models import Family, Challenge, FamilyMembership, Achievement @@ -63,11 +65,28 @@ class AchievementTable(tables.Table): """ List recent achievements. """ + delete = tables.LinkColumn( + 'family:achievement_delete', + args=[A('id')], + verbose_name=_("Delete"), + text=_("Delete"), + orderable=False, + attrs={ + 'th': { + 'id': 'delete-membership-header' + }, + 'a': { + 'class': 'btn btn-danger', + 'data-type': 'delete-membership' + } + }, + ) + class Meta: attrs = { 'class': 'table table-condensed table-striped table-hover' } model = Achievement - fields = ('family', 'challenge', 'obtained_at', ) + fields = ('family', 'challenge', 'challenge__points', 'obtained_at', ) template_name = 'django_tables2/bootstrap4.html' - orderable = False + 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..893e0afb --- /dev/null +++ b/apps/family/templates/family/achievement_confirm_delete.html @@ -0,0 +1,27 @@ +{% 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_list.html b/apps/family/templates/family/achievement_list.html new file mode 100644 index 00000000..fe1fd62f --- /dev/null +++ b/apps/family/templates/family/achievement_list.html @@ -0,0 +1,22 @@ +{% 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 "Recent achievements history" %} +

            + + {% trans "Return to management page" %} + +
            + {% render_table table %} +
            + +{% endblock %} \ No newline at end of file diff --git a/apps/family/templates/family/manage.html b/apps/family/templates/family/manage.html index 275337fa..0ecac60a 100644 --- a/apps/family/templates/family/manage.html +++ b/apps/family/templates/family/manage.html @@ -143,24 +143,21 @@ SPDX-License-Identifier: GPL-3.0-or-later
            - {# Mode switch #} - {# transaction history #} -
            -
            -

            +

            + +
            + {% render_table table %}
            - {% render_table table %}
            {% endblock %} diff --git a/apps/family/urls.py b/apps/family/urls.py index ee491ecc..072cbada 100644 --- a/apps/family/urls.py +++ b/apps/family/urls.py @@ -18,5 +18,7 @@ urlpatterns = [ 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('achievements/', views.AchievementsView.as_view(), name="achievement_list"), + path('achievement/delete//', views.AchievementDeleteView.as_view(), name="achievement_delete"), path('api/family/', include('family.api.urls')), ] diff --git a/apps/family/views.py b/apps/family/views.py index 35f073fb..6325c445 100644 --- a/apps/family/views.py +++ b/apps/family/views.py @@ -7,6 +7,7 @@ from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin from django.db import transaction from django.views.generic import DetailView, UpdateView +from django.views.generic.edit import DeleteView from django.utils.translation import gettext_lazy as _ from django_tables2 import SingleTableView from permission.backends import PermissionBackend @@ -195,7 +196,7 @@ class ChallengeCreateView(ProtectQuerysetMixin, ProtectedCreateView): ) def get_success_url(self): - return reverse_lazy('family:challenge_list') + return reverse_lazy('family:manage') class ChallengeListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): @@ -264,7 +265,7 @@ class FamilyManageView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView # 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()[:20] + ).order_by("-obtained_at").all() def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -277,3 +278,33 @@ class FamilyManageView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView context["can_add_challenge"] = PermissionBackend.check_perm(self.request, "family.add_challenge") return context + + def get_table(self, **kwargs): + table = super().get_table(**kwargs) + table.exclude = ('delete',) + table.orderable = False + return table + + +class AchievementsView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): + """ + List all achievements + """ + model = Achievement + table_class = AchievementTable + extra_context = {'title': _('Achievement list')} + + def get_table(self, **kwargs): + table = super().get_table(**kwargs) + table.orderable = True + return table + + +class AchievementDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView): + """ + Delete an Achievement + """ + model = Achievement + + def get_success_url(self): + return reverse_lazy('family:achievement_list') 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")} From ea8fcad8b594b2c9f8f53952e972c6c26624e489 Mon Sep 17 00:00:00 2001 From: ikea Date: Sat, 19 Jul 2025 00:52:10 +0200 Subject: [PATCH 16/32] =?UTF-8?q?Ajout=20des=20d=C3=A9fis=20r=C3=A9alis?= =?UTF-8?q?=C3=A9s=20par=20une=20famille?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/family/tables.py | 13 +++++++++++++ apps/family/templates/family/family_detail.html | 9 +++++++++ apps/family/views.py | 8 +++++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/family/tables.py b/apps/family/tables.py index 871dfd35..759de96d 100644 --- a/apps/family/tables.py +++ b/apps/family/tables.py @@ -90,3 +90,16 @@ class AchievementTable(tables.Table): fields = ('family', 'challenge', 'challenge__points', 'obtained_at', ) template_name = 'django_tables2/bootstrap4.html' order_by = ('-obtained_at',) + +class FamilyAchievementTable(tables.Table): + """ + Table des défis réalisés par une famille spécifique. + """ + class Meta: + model = Achievement + template_name = 'django_tables2/bootstrap4.html' + fields = ('challenge', 'challenge__points', 'obtained_at',) + attrs = { + 'class': 'table table-condensed table-striped table-hover' + } + order_by = ('-obtained_at',) \ No newline at end of file diff --git a/apps/family/templates/family/family_detail.html b/apps/family/templates/family/family_detail.html index a1db566f..dc38edda 100644 --- a/apps/family/templates/family/family_detail.html +++ b/apps/family/templates/family/family_detail.html @@ -13,4 +13,13 @@ SPDX-License-Identifier: GPL-3.0-or-later
            {% render_table member_list %}
            + +
            + +
            +
            + {% trans "Completed challenges" %} +
            + {% render_table achievement_list %} +
            {% endblock %} \ No newline at end of file diff --git a/apps/family/views.py b/apps/family/views.py index 6325c445..33108f32 100644 --- a/apps/family/views.py +++ b/apps/family/views.py @@ -16,7 +16,7 @@ from django.urls import reverse_lazy from member.views import PictureUpdateView from .models import Family, Challenge, FamilyMembership, User, Achievement -from .tables import FamilyTable, ChallengeTable, FamilyMembershipTable, AchievementTable +from .tables import FamilyTable, ChallengeTable, FamilyMembershipTable, AchievementTable, FamilyAchievementTable from .forms import ChallengeForm, FamilyMembershipForm, FamilyForm @@ -88,6 +88,12 @@ class FamilyDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): 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 From 4c3b714b56c57cf455e6445830a15980017af5ae Mon Sep 17 00:00:00 2001 From: ikea Date: Sun, 20 Jul 2025 21:31:59 +0200 Subject: [PATCH 17/32] Affiche les familles dans le profil utilisateur avec lien vers la page de la famille --- apps/member/templates/member/includes/profile_info.html | 9 +++++++++ apps/member/views.py | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/apps/member/templates/member/includes/profile_info.html b/apps/member/templates/member/includes/profile_info.html index 3a927c9f..dd184c02 100644 --- a/apps/member/templates/member/includes/profile_info.html +++ b/apps/member/templates/member/includes/profile_info.html @@ -7,6 +7,15 @@
            {% trans 'username'|capfirst %}
            {{ user_object.username }}
            +
            {% trans 'family'|capfirst %}
            +
            + {% for family in families %} + {{ family.name }}{% if not forloop.last %}, {% endif %} + {% empty %} + {% trans 'None' %} + {% endfor %} +
            + {% if user_object.pk == user.pk %}
            {% trans 'password'|capfirst %}
            diff --git a/apps/member/views.py b/apps/member/views.py index 19f9b46f..d2b27291 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, \ @@ -92,6 +93,9 @@ class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): if fields_modifiable: context['profile_form'] = profile_form + families = Family.objects.filter(members=user).distinct() + context["families"] = families + return context @transaction.atomic From 2af671d61a466064bcfe61caf87f07804f627d97 Mon Sep 17 00:00:00 2001 From: ikea Date: Sun, 20 Jul 2025 23:27:53 +0200 Subject: [PATCH 18/32] =?UTF-8?q?Fix=20traduction=20.po=20=E2=80=93=20supp?= =?UTF-8?q?ression=20doublons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- locale/fr/LC_MESSAGES/django.po | 8 -------- 1 file changed, 8 deletions(-) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index b62c48bc..76ebb02b 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -4095,14 +4095,6 @@ msgstr "La note est indisponible pour le moment" msgid "Thank you for your understanding -- The Respos Info of BDE" msgstr "Merci de votre compréhension -- Les Respos Info du BDE" -#: note_kfet/templates/base_search.html:15 -msgid "Search by attribute such as name..." -msgstr "Chercher par un attribut tel que le nom..." - -#: note_kfet/templates/base_search.html:23 -msgid "There is no results." -msgstr "Il n'y a pas de résultat." - #: note_kfet/templates/cas/logged.html:8 msgid "" "

            Log In Successful

            You have successfully logged into the Central " From db4d0dd83a981eeaac3dbcf66ef9d2b25e36a0e2 Mon Sep 17 00:00:00 2001 From: ikea Date: Mon, 21 Jul 2025 22:11:20 +0200 Subject: [PATCH 19/32] fix --- .../templates/member/includes/profile_info.html | 12 +++++++----- apps/member/views.py | 6 +++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/member/templates/member/includes/profile_info.html b/apps/member/templates/member/includes/profile_info.html index dd184c02..3ea525d5 100644 --- a/apps/member/templates/member/includes/profile_info.html +++ b/apps/member/templates/member/includes/profile_info.html @@ -9,11 +9,13 @@
            {% trans 'family'|capfirst %}
            - {% for family in families %} - {{ family.name }}{% if not forloop.last %}, {% endif %} - {% empty %} - {% trans 'None' %} - {% endfor %} + {% if families %} + {% for fam in families %} + {{ fam.name }}{% if not forloop.last %}, {% endif %} + {% endfor %} + {% else %} + Aucune + {% endif %}
            {% if user_object.pk == user.pk %} diff --git a/apps/member/views.py b/apps/member/views.py index d2b27291..3c1ebef7 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -93,9 +93,6 @@ class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): if fields_modifiable: context['profile_form'] = profile_form - families = Family.objects.filter(members=user).distinct() - context["families"] = families - return context @transaction.atomic @@ -210,6 +207,9 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): modified_note.is_active = True 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(members__user=user).distinct() + context["families"] = families return context From c66cc14576a9e02ee3b228732dad2dde91f17abf Mon Sep 17 00:00:00 2001 From: Ehouarn Date: Tue, 22 Jul 2025 01:30:47 +0200 Subject: [PATCH 20/32] Added valid field and logic for Achievement --- ...ent_valid_alter_familymembership_family.py | 24 +++++++++ apps/family/models.py | 11 ++-- apps/family/tables.py | 26 +++++++-- .../family/achievement_confirm_delete.html | 4 +- .../family/achievement_confirm_validate.html | 28 ++++++++++ .../templates/family/achievement_list.html | 15 +++++- apps/family/urls.py | 17 +++--- apps/family/views.py | 54 +++++++++++++++---- apps/member/views.py | 4 +- 9 files changed, 152 insertions(+), 31 deletions(-) create mode 100644 apps/family/migrations/0003_achievement_valid_alter_familymembership_family.py create mode 100644 apps/family/templates/family/achievement_confirm_validate.html 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/models.py b/apps/family/models.py index 1acc9ba8..708c3929 100644 --- a/apps/family/models.py +++ b/apps/family/models.py @@ -45,9 +45,9 @@ class Family(models.Model): return self.name def update_score(self, *args, **kwargs): - challenge_set = Challenge.objects.select_for_update().filter(achievement__family=self) + challenge_set = Challenge.objects.select_for_update().filter(achievement__family=self, achievement__valid=True) points_sum = challenge_set.aggregate(models.Sum("points")) - self.score = points_sum["points__sum"] + self.score = points_sum["points__sum"] if points_sum["points__sum"] else 0 self.save() self.update_ranking() @@ -86,7 +86,7 @@ class FamilyMembership(models.Model): family = models.ForeignKey( Family, on_delete=models.PROTECT, - related_name=_('members'), + related_name=_('memberships'), verbose_name=_('family'), ) @@ -157,6 +157,11 @@ class Achievement(models.Model): default=timezone.now, ) + valid = models.BooleanField( + verbose_name=_('valid'), + default=False, + ) + class Meta: verbose_name = _('achievement') verbose_name_plural = _('achievements') diff --git a/apps/family/tables.py b/apps/family/tables.py index 759de96d..0a0b773a 100644 --- a/apps/family/tables.py +++ b/apps/family/tables.py @@ -65,6 +65,23 @@ class AchievementTable(tables.Table): """ List recent achievements. """ + validate = tables.LinkColumn( + 'family:achievement_validate', + args=[A('id')], + verbose_name=_("Validate"), + text=_("Validate"), + orderable=False, + attrs={ + 'th': { + 'id': 'validate-achievement-header' + }, + 'a': { + 'class': 'btn btn-success', + 'data-type': 'validate-achievement' + } + }, + ) + delete = tables.LinkColumn( 'family:achievement_delete', args=[A('id')], @@ -73,11 +90,11 @@ class AchievementTable(tables.Table): orderable=False, attrs={ 'th': { - 'id': 'delete-membership-header' + 'id': 'delete-achievement-header' }, 'a': { 'class': 'btn btn-danger', - 'data-type': 'delete-membership' + 'data-type': 'delete-achievement' } }, ) @@ -87,10 +104,11 @@ class AchievementTable(tables.Table): 'class': 'table table-condensed table-striped table-hover' } model = Achievement - fields = ('family', 'challenge', 'challenge__points', 'obtained_at', ) + fields = ('family', 'challenge', 'challenge__points', 'obtained_at', 'valid') template_name = 'django_tables2/bootstrap4.html' order_by = ('-obtained_at',) + class FamilyAchievementTable(tables.Table): """ Table des défis réalisés par une famille spécifique. @@ -102,4 +120,4 @@ class FamilyAchievementTable(tables.Table): attrs = { 'class': 'table table-condensed table-striped table-hover' } - order_by = ('-obtained_at',) \ No newline at end of file + order_by = ('-obtained_at',) diff --git a/apps/family/templates/family/achievement_confirm_delete.html b/apps/family/templates/family/achievement_confirm_delete.html index 893e0afb..3b378fa5 100644 --- a/apps/family/templates/family/achievement_confirm_delete.html +++ b/apps/family/templates/family/achievement_confirm_delete.html @@ -18,9 +18,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
            {% csrf_token %} {% trans "Return to achievements list" %} - {% if not object.locked %} - - {% endif %} +
            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 index fe1fd62f..63cd6255 100644 --- a/apps/family/templates/family/achievement_list.html +++ b/apps/family/templates/family/achievement_list.html @@ -10,13 +10,24 @@ SPDX-License-Identifier: GPL-3.0-or-later

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

            {% trans "Return to management page" %}
            - {% render_table table %} + {% 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/urls.py b/apps/family/urls.py index 072cbada..7d9e9c5b 100644 --- a/apps/family/urls.py +++ b/apps/family/urls.py @@ -9,16 +9,17 @@ app_name = 'family' urlpatterns = [ path('list/', views.FamilyListView.as_view(), name="family_list"), path('add-family/', views.FamilyCreateView.as_view(), name="add_family"), - path('detail//', 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('/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('add-challenge/', views.ChallengeCreateView.as_view(), name="add_challenge"), - path('challenge/detail//', views.ChallengeDetailView.as_view(), name="challenge_detail"), - path('challenge/update//', views.ChallengeUpdateView.as_view(), name="challenge_update"), + 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('achievements/', views.AchievementsView.as_view(), name="achievement_list"), - path('achievement/delete//', views.AchievementDeleteView.as_view(), name="achievement_delete"), + path('achievement/list/', views.AchievementsView.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')), ] diff --git a/apps/family/views.py b/apps/family/views.py index 33108f32..a6e886a8 100644 --- a/apps/family/views.py +++ b/apps/family/views.py @@ -4,12 +4,14 @@ from datetime import date from django.conf import settings +from django.shortcuts import redirect from django.contrib.auth.mixins import LoginRequiredMixin from django.db import transaction -from django.views.generic import DetailView, UpdateView +from django.views.generic import DetailView, UpdateView, ListView from django.views.generic.edit import DeleteView +from django.views.generic.base import TemplateView from django.utils.translation import gettext_lazy as _ -from django_tables2 import SingleTableView +from django_tables2 import SingleTableView, MultiTableMixin from permission.backends import PermissionBackend from permission.views import ProtectQuerysetMixin, ProtectedCreateView from django.urls import reverse_lazy @@ -287,23 +289,57 @@ class FamilyManageView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView def get_table(self, **kwargs): table = super().get_table(**kwargs) - table.exclude = ('delete',) + table.exclude = ('delete', 'validate',) table.orderable = False return table -class AchievementsView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): +class AchievementsView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView): """ List all achievements """ model = Achievement - table_class = AchievementTable + tables = [AchievementTable, AchievementTable, ] extra_context = {'title': _('Achievement list')} - def get_table(self, **kwargs): - table = super().get_table(**kwargs) - table.orderable = True - return table + def 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 post(self, request, pk): + # On récupère l'objet à valider + achievement = Achievement.objects.get(pk=pk) + # On modifie le champ valid + achievement.valid = True + achievement.save() + # On redirige vers la page de détail ou la liste + return redirect(reverse_lazy('family:achievement_list')) class AchievementDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView): diff --git a/apps/member/views.py b/apps/member/views.py index 3c1ebef7..3cf3cd32 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -207,8 +207,8 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): modified_note.is_active = True 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(members__user=user).distinct() + + families = Family.objects.filter(memberships__user=user).distinct() context["families"] = families return context From adc925e4b14bcf911eba217b3f6f492e1f432d73 Mon Sep 17 00:00:00 2001 From: Ehouarn Date: Tue, 22 Jul 2025 18:31:55 +0200 Subject: [PATCH 21/32] Tests --- apps/family/api/views.py | 35 +- .../0004_remove_challenge_obtained.py | 17 + apps/family/models.py | 34 +- .../static/family/img/default_picture.png | Bin 0 -> 4054 bytes apps/family/static/family/js/achievements.js | 34 +- apps/family/templates/family/manage.html | 8 +- apps/family/tests/__init__.py | 0 apps/family/tests/test_family.py | 318 ++++++++++++++++++ apps/family/urls.py | 8 +- apps/family/views.py | 35 +- 10 files changed, 403 insertions(+), 86 deletions(-) create mode 100644 apps/family/migrations/0004_remove_challenge_obtained.py create mode 100644 apps/family/static/family/img/default_picture.png create mode 100644 apps/family/tests/__init__.py create mode 100644 apps/family/tests/test_family.py diff --git a/apps/family/api/views.py b/apps/family/api/views.py index 50ac0496..79a719d1 100644 --- a/apps/family/api/views.py +++ b/apps/family/api/views.py @@ -3,7 +3,7 @@ from api.viewsets import ReadProtectedModelViewSet from django_filters.rest_framework import DjangoFilterBackend -from rest_framework.filters import SearchFilter +from api.filters import RegexSafeSearchFilter from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -21,9 +21,9 @@ class FamilyViewSet(ReadProtectedModelViewSet): """ queryset = Family.objects.order_by('id') serializer_class = FamilySerializer - filter_backends = [DjangoFilterBackend, SearchFilter] - filterset_fields = ['name', ] - search_fields = ['$name', ] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] + filterset_fields = ['name', 'description', 'score', 'rank', ] + search_fields = ['$name', '$description', ] class FamilyMembershipViewSet(ReadProtectedModelViewSet): @@ -34,9 +34,11 @@ class FamilyMembershipViewSet(ReadProtectedModelViewSet): """ queryset = FamilyMembership.objects.order_by('id') serializer_class = FamilyMembershipSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] - filterset_fields = ['name', ] - search_fields = ['$name', ] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] + filterset_fields = ['user__username', 'user__first_name', 'user__last_name', 'user__email', 'user__note__alias__name', + 'user__note__alias__normalized_name', 'family__name', 'family__description', 'year', ] + search_fields = ['$user__username', '$user__first_name', '$user__last_name', '$user__email', '$user__note__alias__name', + '$user__note__alias__normalized_name', '$family__name', '$family__description', '$year', ] class ChallengeViewSet(ReadProtectedModelViewSet): @@ -47,9 +49,9 @@ class ChallengeViewSet(ReadProtectedModelViewSet): """ queryset = Challenge.objects.order_by('id') serializer_class = ChallengeSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] - filterset_fields = ['name', ] - search_fields = ['$name', ] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] + filterset_fields = ['name', 'description', 'points', ] + search_fields = ['$name', '$description', '$points', ] class AchievementViewSet(ReadProtectedModelViewSet): @@ -60,22 +62,19 @@ class AchievementViewSet(ReadProtectedModelViewSet): """ queryset = Achievement.objects.order_by('id') serializer_class = AchievementSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] - filterset_fields = ['name', ] - search_fields = ['$name', ] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] + filterset_fields = ['family__name', 'family__description', 'challenge__name', 'challenge__description', 'obtained_at', 'valid', ] + search_fields = ['$family__name', '$family__description', '$challenge__name', '$challenge__description', ] class BatchAchievementsAPIView(APIView): permission_classes = [IsAuthenticated] def post(self, request, format=None): - print("POST de la view spéciale") - family_ids = request.data.get('families', []) - challenge_ids = request.data.get('challenges', []) - + family_ids = request.data.get('families') + challenge_ids = request.data.get('challenges') families = Family.objects.filter(id__in=family_ids) challenges = Challenge.objects.filter(id__in=challenge_ids) - for family in families: for challenge in challenges: a = Achievement(family=family, challenge=challenge) 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/models.py b/apps/family/models.py index 708c3929..71ccfa08 100644 --- a/apps/family/models.py +++ b/apps/family/models.py @@ -4,6 +4,7 @@ from django.db import models, transaction from django.utils import timezone from django.contrib.auth.models import User +from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ @@ -44,6 +45,9 @@ class Family(models.Model): def __str__(self): return self.name + def get_absolute_url(self): + return reverse_lazy('family:family_detail', args=(self.pk,)) + def update_score(self, *args, **kwargs): challenge_set = Challenge.objects.select_for_update().filter(achievement__family=self, achievement__valid=True) points_sum = challenge_set.aggregate(models.Sum("points")) @@ -119,10 +123,16 @@ class Challenge(models.Model): verbose_name=_('points'), ) - obtained = models.PositiveIntegerField( - verbose_name=_('obtained'), - default=0, - ) + @property + def obtained(self): + achievements = Achievement.objects.filter(challenge=self, valid=True) + return achievements.count() + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse_lazy('family:challenge_detail', args=(self.pk,)) @transaction.atomic def save(self, *args, **kwargs): @@ -136,9 +146,6 @@ class Challenge(models.Model): verbose_name = _('challenge') verbose_name_plural = _('challenges') - def __str__(self): - return self.name - class Achievement(models.Model): challenge = models.ForeignKey( @@ -176,7 +183,6 @@ class Achievement(models.Model): """ self.family = Family.objects.select_for_update().get(pk=self.family_id) self.challenge = Challenge.objects.select_for_update().get(pk=self.challenge_id) - is_new = self.pk is None super().save(*args, **kwargs) @@ -184,13 +190,6 @@ class Achievement(models.Model): self.family.refresh_from_db() self.family.update_score() - # Count only when getting a new achievement - if is_new: - self.challenge.refresh_from_db() - self.challenge.obtained += 1 - self.challenge._force_save = True - self.challenge.save() - @transaction.atomic def delete(self, *args, **kwargs): """ @@ -205,8 +204,3 @@ class Achievement(models.Model): # Remove points from the family self.family.refresh_from_db() self.family.update_score() - - self.challenge.refresh_from_db() - self.challenge.obtained -= 1 - self.challenge._force_save = True - self.challenge.save() 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 0000000000000000000000000000000000000000..41a31a1cf5eddf31e98a7e6cd06b4c45f2faee92 GIT binary patch literal 4054 zcmV;{4=M18P);r6Qr3ZkW>p0HXHpgkJ`+&_#u-7oWvMZmynAZVM0ha?OAwkr;#xV{6 zn%X%|TTKH7yJ&>5DK=aeqc<=F7>JA(cV_&%MZoL8i@;03+em<24)o7>jOD=3ft!KG z>?Rv|umGvea9~ehShI#A!7#Y_zrEBf+sG2&79==dQ?|uOoV|dHfro*`bpEIlnq~m! z0t3o+j8R<%16KmG(Z9gUQWJ0kwIA0SfbqaAV5^)yGb+HIz#U|JDUMaMfk7R#|H4+k zyD6vdjD*=6xCfbG(!PLw=-PICuotkZ)o<2P-;}hG5c>o7Q=PzQJ&LrZ53-oq_IEet z_NWm(U4Svbqv&6#ni|vWkAA>o9sFh$GP)WSU>o2(;8pZ5&JfgyOam|IGQQIzh(eu16$>skY`|+f=;MfNUhx?tD`=%?EaDuOkgN+<|_l zoAPNEGWBja=KJV(wy8cnNMc{9HfS5rhGD=YWUa%9j}geeuV=IkNtJuizYCg*X*H6) zB?&?r4i7{&$%SN-Q6JAE{kdM-kfMb2h;U6=|(fO$w-qo@hxZaoJ0o3;@~sk{yB z0(_!vND)F9K?Wi(=6$sdF-oN`@~HK?wjo96s(k*@NM=D>WJ;5>v<)bV<|EdA$X*`% zYAs#XAWsM%X&X{6S|dnLd|SVseoD4pBz z&k7{VOq8=(zDJM-;5sZgsze_m{!G_*fSiscC#xc(wGF5j-vM&2=kpn1aHO^Y_2TnU zXAdOPp;0|n16v~r{i4F>2y(K=a~WaL6F6Agh4`tO3AI$d2Y=$Xz!jq6`pU?;tw>Z+SYGPZj`o15X0a zA;C}vU6Be{2LfMiR_tj%R{~p-z&9c;I0XGK-OAJxz%lrHMC}Ls5GhZf-S#5dt!&4n z!pZ1&P8oU@IILvfYx%;AT~LBM~}_fb~e?Zyr9b1V9tlUHkj@49eC$@D^slWO}b zi~i-v2hlST{m#jwkAdTyc)|@`kxvM;{nnWlyK+G6Jc)kiWYt*oz-NV=ks+&=#vL!upD?y+lW>~^}&?y0GWtv9S?zJ_}jXE5O5RnF7_sp z-DL`L!#D!>Uv_G4(Qivgqgjyr6#eJm4M;^+*KCPw@$PH}ZvwI_*A=5WIloWc@35Uo zFa%i}(e9`pduV5#Z#AxRXv@wy>~O6=I&5d@Xk>SP7kEJ7ylt85@Yp?(l5!(*{LR_8 zd(>Ly=(zRpp|$~e&?!I6QV9)&VbT8xae=(Vfj&h6L$%koSeMR(NOTu;Dde z+mg=P5xtPz0@|}z8<2{|QQ7#j`BAuCW647ZvzJlvGxYCjMYIYDnt4cI{X65|%?E}S_Fgt7 zWVFeL@=SXhNXBpNPV=n=z^-LDF9SOg#-i*7V7lYBCP`1ic|P``g-F(RU)<}c9rfrm z$8AlPspxl}4=uuPZrag~+EI@d6V7{3E;OfkTbQbCfCrW#>27>Yac)Ogd!8l`VSU0- zI1v3ioJ%$0w`4+3q?nI(yIbpcr!7vL24p5kyAxe#CGh$3T-7PvkZdN}?QX5r$eNc? zfRLsl%Tg0kg~}HVgqe19n#)GJCWLnofJe0rD48|DSMit0r2)7T*#{OHPiPyE0=uDq zrz=Zq@mp$a3t*Cic9f;V-8H>h%tOC3%LtBBO7=@F1|gYZwA5BpL61gPZLA^!Pc7W z#tqfxSoAwKk3PVCRz!ebDz&eOe&ECt6=!SYyJhXp&8kUm+^`8hQEXok?c>532Fyag zbF=DdM?PrbaAa*oyPbLU4t_>f=Y+D=q%|QWTJDT_4v#$$$v)rU@Eop~j08&bSb!A6 zWlGOWfx!-s?~?As8Q4*32_8@2T5!Y1FkNB zvBbZJ=@?)Ju)DS)MF=rKKo$&Rpr35OIqisM&Tx5mxxg*#YjYK-D6=GBmK`P=7SGFT7Gm&g1?;&YPTV}Ra zAZr+_$eu~QCz3DN$KEcV%msGQHX?R<0p}9OHf~csJx`=)Ca-SInZPy3;+T;hE=xN& zC4>MO3j9Ia2uG|$5;&|w>Xr9Ez99$M3SL&ph0w78DZp}M7!L7$s1{8~m8?gRYPO4- z)r7Pu3!4MmAT>fp03(5~BbgCXBBUasQ4;#M)$M7F6Hmw}^F{#eD^u5&>LHJWGR-?v z+kiT7Rl;ozlH)2Rq8&jjK*A)Jh0?5OS2V7hPy!^i3!*5mY8y~89hIvKo=^hBMvzVU zK-++lu@R&o3y>l!hR}$+I_?_SJ4gx4);1t8Qo3PSfRw-++6DwhlePgR@}af?^%7Em z%+WR=FkDR|`(_;}ij z_k{&WNh~KWXU3jX!51<0>8A)OKp?JB#4?v>W0AH2MX&&=9~(ikVF6MiF$IW?Ala}0 zDUp~0#72;8Sb&sBOxsQRYa0*=_L5#AbBJepGo|EiE_qwqfFgtzAS;O{X`HJ9FtKl^hE-pCCf=) zU;xtYeMx6@T8bpli}J zS_m~jq92+KZqzoQ3_=Z%7qks93Nap-q-{jXY>R$Ia#P2e4@_la>53LL0+$fx0(4!3 zT$AG^WZQ)i7UO^mk(cl-kTMX%fH#3DWUq~46yyuQV@L{4?K`GPbVbb`OOTL9kQTZD zJ0VqXcR@Dw^>5aGNU?guJ%0s`)i%P&$C>EgRjZ=Ziuzoq*f-t2#nYRWTCbJ zM%u(4AU1*+PKiB079!8bhT(+R17r;e?DsLkCH4RTcwgH9BVAGmkmx_kF~TK<0GY3C zfRQd1Acj!`3lPJofdzziLHqk zC6ZczJP+KbZG@2ysRal~X1`CgjWE(6#Q>QD+@NiQkp?LS2*Bk?As)jhM5+PuF>t=N z5k~x^8Xy1<0*`1LVZ=?!0RobRe7&|2M!cjRAg=)b&^E$|m(&9U;5KaojCiR+fK0*G zMT|PBLV&CQUeq?ih?6P=2v~ACqfV+4AU(AWFyf>t0WwV603%MS5FlNEq1r|maZ-f< z*&XPmZA1d});2(c25kd8a2jy5wh;-i2hape*EYl`m&xc?h)qcs<#WFgG6R9t=wDQ+ z>AXChV1&z6=wD2!>Vh2oU9XJ*v6WxK|q2FmH|C#}u1N6mcmjS>u^gGI=t5v|g$f(i{ z1Aqg7`RI3!DJ(5OX1_*KVa~x`#!a2;S>W414=O?r;J4^I-V~Sq2b@IW?1bDAw4H5= zSJ#v1cPS?TOVD?)DSjP6352q#(RNxw5kg_dCTI^iL zL9|AYuE5E_e}Fr%;4sRkl#KqqN06?_EW?%9rZmGXr3A?T0kEA=M);+bHvj+t07*qo IM6N<$f;wYbJOBUy literal 0 HcmV?d00001 diff --git a/apps/family/static/family/js/achievements.js b/apps/family/static/family/js/achievements.js index db29923d..ebec8689 100644 --- a/apps/family/static/family/js/achievements.js +++ b/apps/family/static/family/js/achievements.js @@ -113,6 +113,7 @@ function reset () { * Apply all transactions: all notes in `notes` buy each item in `buttons` */ function consumeAll () { + console.log("test"); if (LOCK) { return } LOCK = true @@ -130,11 +131,13 @@ function consumeAll () { LOCK = false return } - + console.log("couocu") // 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) + console.log(family_ids) + console.log(challenge_ids) $.ajax({ url: '/family/api/family/achievements/batch/', type: 'POST', @@ -157,34 +160,6 @@ function consumeAll () { }) } -/** - * Create a new achievement through the API. - * @param family The selected family - * @param challenge The selected challenge - */ -function grantAchievement (family, challenge) { - console.log("grant lancée",family,challenge) - $.post('/api/family/achievement/', - { - csrfmiddlewaretoken: CSRF_TOKEN, - family: family.id, - challenge: challenge.id, - }) - .done(function () { - reset() - addMsg("Défi validé pour la famille !", 'success', 5000) - }) - .fail(function (e) { - reset() - if (e.responseJSON) { - errMsg(e.responseJSON) - } else if (e.responseText) { - errMsg(e.responseText) - } else { - errMsg("Erreur inconnue lors de la création de l'achievement.") - } - }) -} var searchbar = document.getElementById("search-input") var search_results = document.getElementById("search-results") @@ -264,7 +239,6 @@ function li (id, text, extra_css) { */ function autoCompleteFamily(field_id, family_list_id, families, families_display, family_prefix = 'family', user_family_field = null, profile_pic_field = null, family_click = null) { const field = $('#' + field_id) - console.log("autoCompleteFamily commence") // Configuration du tooltip field.tooltip({ html: true, diff --git a/apps/family/templates/family/manage.html b/apps/family/templates/family/manage.html index 0ecac60a..22a4ed90 100644 --- a/apps/family/templates/family/manage.html +++ b/apps/family/templates/family/manage.html @@ -84,12 +84,12 @@ SPDX-License-Identifier: GPL-3.0-or-later
            {% if can_add_family %} - + {% trans "Add a family" %} {% endif %} {% if can_add_challenge %} - + {% trans "Add a challenge" %} {% endif %} @@ -147,7 +147,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
            -{# transaction history #} +{# achievement history #}
            -
            +
            {% render_table table %}
            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..f2b31784 --- /dev/null +++ b/apps/family/tests/test_family.py @@ -0,0 +1,318 @@ +# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +import os + +from api.tests import TestAPI +from django.contrib.auth.models import User +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from rest_framework.test import APITestCase +from django.urls import reverse +from django.utils import timezone + +from ..api.views import FamilyViewSet, FamilyMembershipViewSet, ChallengeViewSet, AchievementViewSet +from ..models import Family, FamilyMembership, Challenge, Achievement + + +class TestFamily(TestCase): + """ + Test family + """ + + def setUp(self): + self.user = User.objects.create_superuser( + username='admintoto', + password='toto1234', + email='toto@example.com', + ) + self.client.force_login(self.user) + + sess = self.client.session + sess['permission_mask'] = 42 + sess.save() + + self.family = Family.objects.create( + name='Test family', + description='', + ) + + self.challenge = Challenge.objects.create( + name='Test challenge', + description='', + points=100, + ) + + self.achievement = Achievement.objects.create( + family=self.family, + challenge=self.challenge, + valid=False, + ) + + def test_family_list(self): + """ + Test display family list + """ + response = self.client.get(reverse("family:family_list")) + self.assertEqual(response.status_code, 200) + + def test_family_create(self): + """ + Test create a family + """ + response = self.client.get(reverse("family:family_create")) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("family:family_create"), data={ + "name": "Family toto", + "description": "A test family", + }) + self.assertTrue(Family.objects.filter(name="Family toto").exists()) + self.assertRedirects(response, reverse("family:manage"), 302, 200) + + def test_family_detail(self): + """ + Test display the detail of a family + """ + response = self.client.get(reverse("family:family_detail", args=(self.family.pk,))) + self.assertEqual(response.status_code, 200) + + def test_family_update(self): + """ + Test update a family + """ + response = self.client.get(reverse("family:family_update", args=(self.family.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("family:family_update", args=(self.family.pk,)), data=dict( + name="Toto family updated", + description="A larger description for the test family" + )) + self.assertRedirects(response, self.family.get_absolute_url(), 302, 200) + self.assertTrue(Family.objects.filter(name="Toto family updated").exists()) + + def test_family_update_picture(self): + """ + Test update the picture of a family + """ + response = self.client.get(reverse("family:update_pic", args=(self.family.pk,))) + self.assertEqual(response.status_code, 200) + + old_pic = self.family.display_image + + with open("apps/family/static/family/img/default_picture.png", "rb") as f: + image = SimpleUploadedFile("image.png", f.read(), "image/png") + response = self.client.post(reverse("family:update_pic", args=(self.family.pk,)), dict( + image=image, + x=0, + y=0, + width=200, + height=200, + )) + self.assertRedirects(response, self.family.get_absolute_url(), 302, 200) + + self.family.refresh_from_db() + self.assertTrue(os.path.exists(self.family.display_image.path)) + os.remove(self.family.display_image.path) + + self.family.display_image = old_pic + self.family.save() + + def test_family_add_member(self): + """ + Test add memberships to a family + """ + response = self.client.get(reverse("family:family_add_member", args=(self.family.pk,))) + self.assertEqual(response.status_code, 200) + + user = User.objects.create(username="totototo") + user.profile.registration_valid = True + user.profile.email_confirmed = True + user.profile.save() + user.save() + + response = self.client.post(reverse("family:family_add_member", args=(self.family.pk,)), data=dict( + user=user.pk, + )) + self.assertRedirects(response, self.family.get_absolute_url(), 302, 200) + + self.assertTrue(FamilyMembership.objects.filter(user=user, family=self.family, year=timezone.now().year).exists()) + + def test_challenge_list(self): + """ + Test display challenge list + """ + response = self.client.get(reverse('family:challenge_list')) + self.assertEqual(response.status_code, 200) + + def test_challenge_create(self): + """ + Test create a challenge + """ + response = self.client.get(reverse("family:challenge_create")) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("family:challenge_create"), data={ + "name": "Challenge for toto", + "description": "A test challenge", + "points": 50, + }) + self.assertTrue(Challenge.objects.filter(name="Challenge for toto").exists()) + self.assertRedirects(response, reverse("family:manage"), 302, 200) + + def test_challenge_detail(self): + """ + Test display the detail of a challenge + """ + response = self.client.get(reverse("family:challenge_detail", args=(self.challenge.pk,))) + self.assertEqual(response.status_code, 200) + + def test_challenge_update(self): + """ + Test update a challenge + """ + response = self.client.get(reverse("family:challenge_update", args=(self.challenge.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("family:challenge_update", args=(self.challenge.pk,)), data=dict( + name="Challenge updated", + description="Another description", + points=10, + )) + self.assertRedirects(response, self.challenge.get_absolute_url(), 302, 200) + self.assertTrue(Challenge.objects.filter(name="Challenge updated").exists()) + + def test_render_manage_page(self): + """ + Test render manage page + """ + response = self.client.get(reverse("family:manage")) + self.assertEqual(response.status_code, 200) + + def test_validate_achievement(self): + """ + Test validate an achievement + """ + old_family_score = self.family.score + + response = self.client.get(reverse("family:achievement_validate", args=(self.achievement.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("family:achievement_validate", args=(self.achievement.pk,))) + self.assertRedirects(response, reverse("family:achievement_list"), 302, 200) + + self.achievement.refresh_from_db() + self.assertIs(self.achievement.valid, True) + + self.family.refresh_from_db() + self.assertEqual(self.family.score, old_family_score + self.achievement.challenge.points) + + def test_delete_achievement(self): + """ + Test delete an achievement + """ + response = self.client.get(reverse("family:achievement_delete", args=(self.achievement.pk,))) + self.assertEqual(response.status_code, 200) + + response = self.client.delete(reverse("family:achievement_delete", args=(self.achievement.pk,))) + self.assertRedirects(response, reverse("family:achievement_list"), 302, 200) + self.assertFalse(Achievement.objects.filter(pk=self.achievement.pk).exists()) + + +class TestBatchAchievements(APITestCase): + def setUp(self): + self.user = User.objects.create_superuser( + username='admintoto', + password='toto1234', + email='toto@example.com', + ) + self.client.force_login(self.user) + + sess = self.client.session + sess['permission_mask'] = 42 + sess.save() + + self.families = [ + Family.objects.create(name=f'Famille {i}', description='') for i in range(2) + ] + self.challenges = [ + Challenge.objects.create(name=f'Challenge {i}', description='', points=50) for i in range(3) + ] + + self.url = reverse("family:api:batch_achievements") + + def test_batch_achievement_creation(self): + family_ids = [f.id for f in self.families] + challenge_ids = [c.id for c in self.challenges] + response = self.client.post( + self.url, + data={ + 'families': family_ids, + 'challenges': challenge_ids + }, + format='json' + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data['status'], 'ok') + + expected_count = len(family_ids) * len(challenge_ids) + self.assertEqual(Achievement.objects.count(), expected_count) + + # Check that correct couples family/challenge exist + for f in self.families: + for c in self.challenges: + self.assertTrue( + Achievement.objects.filter(family=f, challenge=c).exists() + ) + + +class TestFamilyAPI(TestAPI): + def setUp(self): + super().setUp() + + self.family = Family.objects.create( + name='Test family', + description='', + ) + + self.familymembership = FamilyMembership.objects.create( + user=self.user, + family=self.family, + ) + + self.challenge = Challenge.objects.create( + name='Test challenge', + description='', + points=100, + ) + + self.achievement = Achievement.objects.create( + family=self.family, + challenge=self.challenge, + valid=False, + ) + + def test_family_api(self): + """ + Load Family API page and test all filters and permissions + """ + self.check_viewset(FamilyViewSet, '/api/family/family/') + + def test_familymembership_api(self): + """ + Load FamilyMembership API page and test all filters and permissions + """ + self.check_viewset(FamilyMembershipViewSet, '/api/family/familymembership/') + + def test_challenge_api(self): + """ + Load Challenge API page and test all filters and permissions + """ + self.check_viewset(ChallengeViewSet, '/api/family/challenge/') + + def test_achievement_api(self): + """ + Load Achievement API page and test all filters and permissions + """ + self.check_viewset(AchievementViewSet, '/api/family/achievement/') diff --git a/apps/family/urls.py b/apps/family/urls.py index 7d9e9c5b..edb0d18a 100644 --- a/apps/family/urls.py +++ b/apps/family/urls.py @@ -8,18 +8,18 @@ from . import views app_name = 'family' urlpatterns = [ path('list/', views.FamilyListView.as_view(), name="family_list"), - path('add-family/', views.FamilyCreateView.as_view(), name="add_family"), + path('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('add-challenge/', views.ChallengeCreateView.as_view(), name="add_challenge"), + 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.AchievementsView.as_view(), name="achievement_list"), + 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')), + path('api/family/', include(('family.api.urls', 'family_api'), namespace='api')), ] diff --git a/apps/family/views.py b/apps/family/views.py index a6e886a8..aee6f276 100644 --- a/apps/family/views.py +++ b/apps/family/views.py @@ -8,14 +8,14 @@ from django.shortcuts import redirect from django.contrib.auth.mixins import LoginRequiredMixin from django.db import transaction from django.views.generic import DetailView, UpdateView, ListView -from django.views.generic.edit import DeleteView +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.views import PictureUpdateView +from member.forms import ImageForm from .models import Family, Challenge, FamilyMembership, User, Achievement from .tables import FamilyTable, ChallengeTable, FamilyMembershipTable, AchievementTable, FamilyAchievementTable @@ -112,17 +112,28 @@ class FamilyUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): return reverse_lazy('family:family_detail', kwargs={'pk': self.object.pk}) -class FamilyPictureUpdateView(PictureUpdateView): +class FamilyPictureUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin, DetailView): """ Update profile picture of the family """ model = Family extra_context = {"title": _("Update family picture")} template_name = 'family/picture_update.html' + form_class = ImageForm + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['form'] = self.form_class(self.request.POST, self.request.FILES) + return context def get_success_url(self): """Redirect to family page after upload""" - return reverse_lazy('family:family_detail', kwargs={'pk': self.object.id}) + return reverse_lazy('family:family_detail', kwargs={'pk': self.object.pk}) + + def post(self, request, *args, **kwargs): + form = self.get_form() + self.object = self.get_object() + return self.form_valid(form) if form.is_valid() else self.form_invalid(form) @transaction.atomic def form_valid(self, form): @@ -141,6 +152,11 @@ class FamilyPictureUpdateView(PictureUpdateView): else: image.name = "{}_pic.png".format(self.object.pk) + # Save + self.object.display_image = image + self.object.save() + return super().form_valid(form) + class FamilyAddMemberView(ProtectQuerysetMixin, ProtectedCreateView): """ @@ -282,8 +298,8 @@ class FamilyManageView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView PermissionBackend.filter_queryset(self.request, Challenge, "view") ).order_by('name') - context["can_add_family"] = PermissionBackend.check_perm(self.request, "family.add_family") - context["can_add_challenge"] = PermissionBackend.check_perm(self.request, "family.add_challenge") + context["can_add_family"] = PermissionBackend.check_perm(self.request, "family.family_create") + context["can_add_challenge"] = PermissionBackend.check_perm(self.request, "family.challenge_create") return context @@ -294,7 +310,7 @@ class FamilyManageView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView return table -class AchievementsView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView): +class AchievementListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView): """ List all achievements """ @@ -333,12 +349,11 @@ class AchievementValidateView(ProtectQuerysetMixin, LoginRequiredMixin, Template template_name = 'family/achievement_confirm_validate.html' def post(self, request, pk): - # On récupère l'objet à valider achievement = Achievement.objects.get(pk=pk) - # On modifie le champ valid + achievement.valid = True achievement.save() - # On redirige vers la page de détail ou la liste + return redirect(reverse_lazy('family:achievement_list')) From 4fa8ef4b56fc9c85a67574f9f583ef64a6488068 Mon Sep 17 00:00:00 2001 From: ikea Date: Wed, 6 Aug 2025 08:20:43 +0200 Subject: [PATCH 22/32] =?UTF-8?q?Ajout=20de=20la=20pop-up=20de=20validatio?= =?UTF-8?q?n=20de=20d=C3=A9fi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/family/tables.py | 2 +- apps/family/templates/family/manage.html | 34 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/family/tables.py b/apps/family/tables.py index 0a0b773a..460a2e5c 100644 --- a/apps/family/tables.py +++ b/apps/family/tables.py @@ -116,7 +116,7 @@ class FamilyAchievementTable(tables.Table): class Meta: model = Achievement template_name = 'django_tables2/bootstrap4.html' - fields = ('challenge', 'challenge__points', 'obtained_at',) + fields = ('challenge', 'challenge__points', 'obtained_at', 'valid') attrs = { 'class': 'table table-condensed table-striped table-hover' } diff --git a/apps/family/templates/family/manage.html b/apps/family/templates/family/manage.html index 22a4ed90..b284ffff 100644 --- a/apps/family/templates/family/manage.html +++ b/apps/family/templates/family/manage.html @@ -159,6 +159,34 @@ SPDX-License-Identifier: GPL-3.0-or-later {% render_table table %}
            + + + + {% endblock %} @@ -178,4 +206,10 @@ SPDX-License-Identifier: GPL-3.0-or-later }); {% endfor %} + + {% endblock %} \ No newline at end of file From b10b2fb3b650606f305d68c57e2266bfde546642 Mon Sep 17 00:00:00 2001 From: ikea Date: Fri, 8 Aug 2025 16:44:37 +0200 Subject: [PATCH 23/32] Rajout du lien vers la page user dans table --- apps/family/tables.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/family/tables.py b/apps/family/tables.py index 460a2e5c..709ca90c 100644 --- a/apps/family/tables.py +++ b/apps/family/tables.py @@ -2,12 +2,16 @@ # SPDX-License-Identifier: GPL-3.0-or-later import django_tables2 as tables -from django.urls import reverse +from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ from django_tables2 import A -from .models import Family, Challenge, FamilyMembership, Achievement +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): """ @@ -51,6 +55,15 @@ class FamilyMembershipTable(tables.Table): """ List all family memberships. """ + + def render_user(self, value): + # Display user's name, clickable if permission is granted + s = value.username + if PermissionBackend.check_perm(get_current_request(), "auth.view_user", value): + s = format_html("{name}", + url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s) + return s + class Meta: attrs = { 'class': 'table table-condensed table-striped', From 74f9c53c1822b8028a98275e79328610ea2dd471 Mon Sep 17 00:00:00 2001 From: Ehouarn Date: Sat, 9 Aug 2025 15:38:02 +0200 Subject: [PATCH 24/32] Visual improvement on manage page --- apps/family/static/family/js/achievements.js | 23 +------- apps/family/tables.py | 3 +- apps/family/templates/family/manage.html | 58 +++++++++++++++++++- apps/family/views.py | 7 +++ 4 files changed, 66 insertions(+), 25 deletions(-) diff --git a/apps/family/static/family/js/achievements.js b/apps/family/static/family/js/achievements.js index ebec8689..b9774579 100644 --- a/apps/family/static/family/js/achievements.js +++ b/apps/family/static/family/js/achievements.js @@ -57,7 +57,6 @@ function addChallenge (id, name, amount) { /** Ajout de 1 à chaque clic d'un bouton déjà choisi */ buttons.forEach(function (b) { if (b.id === id) { - b.quantity += 1 challenge = b } }) @@ -65,8 +64,6 @@ function addChallenge (id, name, amount) { challenge = { id: id, name: name, - quantity: 1, - amount: amount, } buttons.push(challenge) } @@ -76,8 +73,7 @@ function addChallenge (id, name, amount) { const list = 'consos_list' let html = '' buttons.forEach(function (challenge) { - html += li('conso_button_' + challenge.id, challenge.name + - '' + challenge.quantity + '') + html += li('conso_button_' + challenge.id, challenge.name) }) document.getElementById(list).innerHTML = html @@ -94,7 +90,6 @@ function addChallenge (id, name, amount) { * Reset the page as its initial state. */ function reset () { - console.log("reset lancée") notes_display.length = 0 notes.length = 0 buttons.length = 0 @@ -113,7 +108,6 @@ function reset () { * Apply all transactions: all notes in `notes` buy each item in `buttons` */ function consumeAll () { - console.log("test"); if (LOCK) { return } LOCK = true @@ -131,13 +125,10 @@ function consumeAll () { LOCK = false return } - console.log("couocu") // 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) - console.log(family_ids) - console.log(challenge_ids) $.ajax({ url: '/family/api/family/achievements/batch/', type: 'POST', @@ -318,7 +309,6 @@ function autoCompleteFamily(field_id, family_list_id, families, families_display var disp = null families_display.forEach(function (d) { if (d.id === family.id) { - d.quantity += 1 disp = d } }) @@ -327,7 +317,6 @@ function autoCompleteFamily(field_id, family_list_id, families, families_display name: family.name, id: family.id, family: family, - quantity: 1 } families_display.push(disp) } @@ -338,9 +327,7 @@ function autoCompleteFamily(field_id, family_list_id, families, families_display let html = '' families_display.forEach(function (disp) { html += li(family_prefix + '_' + disp.id, - disp.name + - '' + - disp.quantity + '', + disp.name, '') }) @@ -398,12 +385,6 @@ function removeFamily(d, family_prefix, families_display, family_list_id, user_f const new_families_display = [] let html = '' families_display.forEach(function (disp) { - if (disp.quantity > 1 || disp.id !== d.id) { - disp.quantity -= disp.id === d.id ? 1 : 0 - new_families_display.push(disp) - html += li(family_prefix + '_' + disp.id, disp.name + - '' + disp.quantity + '') - } }) families_display.length = 0 diff --git a/apps/family/tables.py b/apps/family/tables.py index 709ca90c..728058ae 100644 --- a/apps/family/tables.py +++ b/apps/family/tables.py @@ -5,14 +5,13 @@ 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 diff --git a/apps/family/templates/family/manage.html b/apps/family/templates/family/manage.html index b284ffff..70419342 100644 --- a/apps/family/templates/family/manage.html +++ b/apps/family/templates/family/manage.html @@ -52,7 +52,12 @@ SPDX-License-Identifier: GPL-3.0-or-later {# User search with autocompletion #} @@ -210,6 +215,55 @@ SPDX-License-Identifier: GPL-3.0-or-later document.getElementById("consume_all").addEventListener("click", function () { $('#validationModal').modal('show'); }); + + {% if user_family %} + document.getElementById("select_my_family").addEventListener("click", function () { + // Simulate selecting the user's family + var userFamily = { + id: {{ user_family.id }}, + name: "{{ user_family.name|escapejs }}", + display_image: "{{ user_family.display_image.url|default:'/static/member/img/default_picture.png'|escapejs }}" + }; + + // Check if family is already selected + var alreadySelected = false; + notes_display.forEach(function (d) { + if (d.id === userFamily.id) { + alreadySelected = true; + } + }); + + if (!alreadySelected) { + // Add the family to the selected families + var disp = { + name: userFamily.name, + id: userFamily.id, + family: userFamily, + }; + notes_display.push(disp); + + // Update the display + const family_list = $('#note_list'); + let html = ''; + notes_display.forEach(function (disp) { + html += li('note_' + disp.id, disp.name, ''); + }); + + family_list.html(html); + + // Add click handlers for removal + notes_display.forEach(function (disp) { + const line_obj = $('#note_' + disp.id); + line_obj.hover(function () { + displayFamily(disp.family, disp.name, 'user_note', 'profile_pic'); + }); + line_obj.click(removeFamily(disp, 'note', notes_display, 'note_list', 'user_note', 'profile_pic')); + }); + + // Display the family info + displayFamily(userFamily, userFamily.name, 'user_note', 'profile_pic'); + } + }); + {% endif %} - {% endblock %} \ No newline at end of file diff --git a/apps/family/views.py b/apps/family/views.py index aee6f276..fee0c5ad 100644 --- a/apps/family/views.py +++ b/apps/family/views.py @@ -301,6 +301,13 @@ class FamilyManageView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView context["can_add_family"] = PermissionBackend.check_perm(self.request, "family.family_create") context["can_add_challenge"] = PermissionBackend.check_perm(self.request, "family.challenge_create") + # 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 + return context def get_table(self, **kwargs): From 4a9b7c131229c908b3c89d5b29b26197720fd61d Mon Sep 17 00:00:00 2001 From: Ehouarn Date: Mon, 11 Aug 2025 19:09:30 +0200 Subject: [PATCH 25/32] Phone number link --- apps/family/templates/family/manage.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/family/templates/family/manage.html b/apps/family/templates/family/manage.html index 70419342..cd835c3d 100644 --- a/apps/family/templates/family/manage.html +++ b/apps/family/templates/family/manage.html @@ -183,7 +183,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
          • The name of the challenge
          • A photo or video as proof
          • -

            Send it via WhatsApp to: +33 6 30 21 12 44

            +

            Send it via WhatsApp to: +33 6 30 21 12 44

            diff --git a/apps/family/templates/family/family_detail.html b/apps/family/templates/family/family_detail.html index dc38edda..b81fa955 100644 --- a/apps/family/templates/family/family_detail.html +++ b/apps/family/templates/family/family_detail.html @@ -7,6 +7,7 @@ SPDX-License-Identifier: GPL-3.0-or-later {% load i18n perms %} {% block profile_content %} +{% if member_list.data %}
            {% trans "Family members" %} @@ -15,11 +16,14 @@ SPDX-License-Identifier: GPL-3.0-or-later
            +{% 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_list.html b/apps/family/templates/family/family_list.html index 55feed5e..f06eba6e 100644 --- a/apps/family/templates/family/family_list.html +++ b/apps/family/templates/family/family_list.html @@ -16,9 +16,11 @@ SPDX-License-Identifier: GPL-3.0-or-later {% trans "Challenges" %} + {% if can_manage %} {% trans "Manage" %} + {% endif %}
            diff --git a/apps/family/templates/family/manage.html b/apps/family/templates/family/manage.html index cd835c3d..06df632b 100644 --- a/apps/family/templates/family/manage.html +++ b/apps/family/templates/family/manage.html @@ -26,6 +26,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
            + {% if can_add_achievement %}
            {# Family details column #}
            @@ -81,8 +82,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
            + {% endif %} {# Create family/challenge buttons #} + {% if can_add_family or can_add_challenge %}

            {% trans "Create a family or challenge" %} @@ -100,10 +103,12 @@ SPDX-License-Identifier: GPL-3.0-or-later {% endif %}

            + {% endif %} {# Buttons column #}
            + {% if can_add_achievement %}
            {# Tabs for list and search #}
            @@ -149,10 +154,12 @@ SPDX-License-Identifier: GPL-3.0-or-later
            + {% endif %}
            {# achievement history #} +{% if table.data %} - +{% endif %} {% endblock %} diff --git a/apps/family/views.py b/apps/family/views.py index fee0c5ad..07ecc23c 100644 --- a/apps/family/views.py +++ b/apps/family/views.py @@ -6,6 +6,7 @@ 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 @@ -51,6 +52,24 @@ class FamilyListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): table_class = FamilyTable extra_context = {"title": _('Families list')} + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + fake_family = Family(name="", description="") + fake_challenge = Challenge(name="", description="", points=0) + can_add_family = PermissionBackend.check_perm(self.request, "family.add_family", fake_family) + can_add_challenge = PermissionBackend.check_perm(self.request, "family.add_challenge", fake_challenge) + + if Family.objects.exists() and Challenge.objects.exists(): + fake_achievement = Achievement(family=Family.objects.first(), challenge=Challenge.objects.first(), valid=False) + can_add_achievement = PermissionBackend.check_perm(self.request, "family.add_achievement", fake_achievement) + else: + can_add_achievement = False + + context["can_manage"] = can_add_family or can_add_challenge or can_add_achievement + + return context + class FamilyDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): """ @@ -231,6 +250,24 @@ class ChallengeListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableVie table_class = ChallengeTable extra_context = {"title": _('Challenges list')} + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + fake_family = Family(name="", description="") + fake_challenge = Challenge(name="", description="", points=0) + can_add_family = PermissionBackend.check_perm(self.request, "family.add_family", fake_family) + can_add_challenge = PermissionBackend.check_perm(self.request, "family.add_challenge", fake_challenge) + + if Family.objects.exists() and Challenge.objects.exists(): + fake_achievement = Achievement(family=Family.objects.first(), challenge=Challenge.objects.first(), valid=False) + can_add_achievement = PermissionBackend.check_perm(self.request, "family.add_achievement", fake_achievement) + else: + can_add_achievement = False + + context["can_manage"] = can_add_family or can_add_challenge or can_add_achievement + + return context + class ChallengeDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): """ @@ -283,6 +320,11 @@ class FamilyManageView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView if not request.user.is_authenticated: return self.handle_no_permission() + perm = PermissionBackend.has_model_perm(self.request, Achievement(), "add") + perm = perm or PermissionBackend.has_model_perm(self.request, Challenge(), "add") + perm = perm or PermissionBackend.has_model_perm(self.request, Family(), "add") + if not perm: + raise PermissionDenied(_("You are not able to manage families and challenges.")) return super().dispatch(request, *args, **kwargs) def get_queryset(self, **kwargs): @@ -298,8 +340,9 @@ class FamilyManageView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView PermissionBackend.filter_queryset(self.request, Challenge, "view") ).order_by('name') - context["can_add_family"] = PermissionBackend.check_perm(self.request, "family.family_create") - context["can_add_challenge"] = PermissionBackend.check_perm(self.request, "family.challenge_create") + 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: @@ -316,6 +359,13 @@ class FamilyManageView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView 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): """ @@ -325,6 +375,14 @@ class AchievementListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMi 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) @@ -355,6 +413,19 @@ class AchievementValidateView(ProtectQuerysetMixin, LoginRequiredMixin, Template """ 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) @@ -370,5 +441,18 @@ class AchievementDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView """ model = Achievement + def dispatch(self, request, *args, **kwargs): + if not request.user.is_authenticated: + return self.handle_no_permission() + + fake_achievement = Achievement( + family=Family.objects.first(), + challenge=Challenge.objects.first(), + valid=False, + ) + if not PermissionBackend.check_perm(self.request, "family.change_achievement_valid", fake_achievement): + raise PermissionDenied() + return super().dispatch(request, *args, **kwargs) + def get_success_url(self): return reverse_lazy('family:achievement_list') diff --git a/apps/member/signals.py b/apps/member/signals.py index b1b8cd82..f07c8896 100644 --- a/apps/member/signals.py +++ b/apps/member/signals.py @@ -17,7 +17,6 @@ def save_user_profile(instance, created, raw, **_kwargs): def update_wei_registration_fee_on_membership_creation(sender, instance, created, **kwargs): if not hasattr(instance, "_no_signal") and created: - print('update_wei_registration_fee_on_membership_creation') from wei.models import WEIRegistration if instance.club.id == 1 or instance.club.id == 2: registrations = WEIRegistration.objects.filter( 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, From 80f28aa7711cb2df2df5a2b97cded9aeb9475db9 Mon Sep 17 00:00:00 2001 From: Ehouarn Date: Wed, 13 Aug 2025 16:02:59 +0200 Subject: [PATCH 28/32] No hard coded phone number in template --- apps/family/templates/family/manage.html | 7 ++++++- apps/family/views.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/family/templates/family/manage.html b/apps/family/templates/family/manage.html index 06df632b..b344bb5b 100644 --- a/apps/family/templates/family/manage.html +++ b/apps/family/templates/family/manage.html @@ -190,7 +190,12 @@ SPDX-License-Identifier: GPL-3.0-or-later
          • The name of the challenge
          • A photo or video as proof
          • -

            Send it via WhatsApp to: +33 6 30 21 12 44

            +

            + Send it via WhatsApp to: + {% for num in phone_numbers %} + {{ num }}{% if not forloop.last %}, {% endif %} + {% endfor %} +