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 00000000..41a31a1c Binary files /dev/null and b/apps/family/static/family/img/default_picture.png differ diff --git a/apps/family/static/family/js/achievements.js b/apps/family/static/family/js/achievements.js 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'))