1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-07-23 17:26:46 +02:00

Compare commits

...

6 Commits

19 changed files with 591 additions and 118 deletions

View File

@ -3,7 +3,7 @@
from api.viewsets import ReadProtectedModelViewSet from api.viewsets import ReadProtectedModelViewSet
from django_filters.rest_framework import DjangoFilterBackend 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.views import APIView
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
@ -21,9 +21,9 @@ class FamilyViewSet(ReadProtectedModelViewSet):
""" """
queryset = Family.objects.order_by('id') queryset = Family.objects.order_by('id')
serializer_class = FamilySerializer serializer_class = FamilySerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', ] filterset_fields = ['name', 'description', 'score', 'rank', ]
search_fields = ['$name', ] search_fields = ['$name', '$description', ]
class FamilyMembershipViewSet(ReadProtectedModelViewSet): class FamilyMembershipViewSet(ReadProtectedModelViewSet):
@ -34,9 +34,11 @@ class FamilyMembershipViewSet(ReadProtectedModelViewSet):
""" """
queryset = FamilyMembership.objects.order_by('id') queryset = FamilyMembership.objects.order_by('id')
serializer_class = FamilyMembershipSerializer serializer_class = FamilyMembershipSerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', ] filterset_fields = ['user__username', 'user__first_name', 'user__last_name', 'user__email', 'user__note__alias__name',
search_fields = ['$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): class ChallengeViewSet(ReadProtectedModelViewSet):
@ -47,9 +49,9 @@ class ChallengeViewSet(ReadProtectedModelViewSet):
""" """
queryset = Challenge.objects.order_by('id') queryset = Challenge.objects.order_by('id')
serializer_class = ChallengeSerializer serializer_class = ChallengeSerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', ] filterset_fields = ['name', 'description', 'points', ]
search_fields = ['$name', ] search_fields = ['$name', '$description', '$points', ]
class AchievementViewSet(ReadProtectedModelViewSet): class AchievementViewSet(ReadProtectedModelViewSet):
@ -60,22 +62,19 @@ class AchievementViewSet(ReadProtectedModelViewSet):
""" """
queryset = Achievement.objects.order_by('id') queryset = Achievement.objects.order_by('id')
serializer_class = AchievementSerializer serializer_class = AchievementSerializer
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', ] filterset_fields = ['family__name', 'family__description', 'challenge__name', 'challenge__description', 'obtained_at', 'valid', ]
search_fields = ['$name', ] search_fields = ['$family__name', '$family__description', '$challenge__name', '$challenge__description', ]
class BatchAchievementsAPIView(APIView): class BatchAchievementsAPIView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
def post(self, request, format=None): def post(self, request, format=None):
print("POST de la view spéciale") family_ids = request.data.get('families')
family_ids = request.data.get('families', []) challenge_ids = request.data.get('challenges')
challenge_ids = request.data.get('challenges', [])
families = Family.objects.filter(id__in=family_ids) families = Family.objects.filter(id__in=family_ids)
challenges = Challenge.objects.filter(id__in=challenge_ids) challenges = Challenge.objects.filter(id__in=challenge_ids)
for family in families: for family in families:
for challenge in challenges: for challenge in challenges:
a = Achievement(family=family, challenge=challenge) a = Achievement(family=family, challenge=challenge)

View File

@ -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'),
),
]

View File

@ -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',
),
]

View File

@ -4,6 +4,7 @@
from django.db import models, transaction from django.db import models, transaction
from django.utils import timezone from django.utils import timezone
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -44,10 +45,13 @@ class Family(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def get_absolute_url(self):
return reverse_lazy('family:family_detail', args=(self.pk,))
def update_score(self, *args, **kwargs): 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")) 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.save()
self.update_ranking() self.update_ranking()
@ -86,7 +90,7 @@ class FamilyMembership(models.Model):
family = models.ForeignKey( family = models.ForeignKey(
Family, Family,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name=_('members'), related_name=_('memberships'),
verbose_name=_('family'), verbose_name=_('family'),
) )
@ -119,10 +123,16 @@ class Challenge(models.Model):
verbose_name=_('points'), verbose_name=_('points'),
) )
obtained = models.PositiveIntegerField( @property
verbose_name=_('obtained'), def obtained(self):
default=0, 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 @transaction.atomic
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
@ -136,9 +146,6 @@ class Challenge(models.Model):
verbose_name = _('challenge') verbose_name = _('challenge')
verbose_name_plural = _('challenges') verbose_name_plural = _('challenges')
def __str__(self):
return self.name
class Achievement(models.Model): class Achievement(models.Model):
challenge = models.ForeignKey( challenge = models.ForeignKey(
@ -157,6 +164,11 @@ class Achievement(models.Model):
default=timezone.now, default=timezone.now,
) )
valid = models.BooleanField(
verbose_name=_('valid'),
default=False,
)
class Meta: class Meta:
verbose_name = _('achievement') verbose_name = _('achievement')
verbose_name_plural = _('achievements') verbose_name_plural = _('achievements')
@ -171,7 +183,6 @@ class Achievement(models.Model):
""" """
self.family = Family.objects.select_for_update().get(pk=self.family_id) self.family = Family.objects.select_for_update().get(pk=self.family_id)
self.challenge = Challenge.objects.select_for_update().get(pk=self.challenge_id) self.challenge = Challenge.objects.select_for_update().get(pk=self.challenge_id)
is_new = self.pk is None
super().save(*args, **kwargs) super().save(*args, **kwargs)
@ -179,13 +190,6 @@ class Achievement(models.Model):
self.family.refresh_from_db() self.family.refresh_from_db()
self.family.update_score() 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 @transaction.atomic
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
""" """
@ -200,8 +204,3 @@ class Achievement(models.Model):
# Remove points from the family # Remove points from the family
self.family.refresh_from_db() self.family.refresh_from_db()
self.family.update_score() self.family.update_score()
self.challenge.refresh_from_db()
self.challenge.obtained -= 1
self.challenge._force_save = True
self.challenge.save()

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -113,6 +113,7 @@ function reset () {
* Apply all transactions: all notes in `notes` buy each item in `buttons` * Apply all transactions: all notes in `notes` buy each item in `buttons`
*/ */
function consumeAll () { function consumeAll () {
console.log("test");
if (LOCK) { return } if (LOCK) { return }
LOCK = true LOCK = true
@ -130,11 +131,13 @@ function consumeAll () {
LOCK = false LOCK = false
return return
} }
console.log("couocu")
// Récupérer les IDs des familles et des challenges // Récupérer les IDs des familles et des challenges
const family_ids = notes_display.map(fam => fam.id) const family_ids = notes_display.map(fam => fam.id)
const challenge_ids = buttons.map(chal => chal.id) const challenge_ids = buttons.map(chal => chal.id)
console.log(family_ids)
console.log(challenge_ids)
$.ajax({ $.ajax({
url: '/family/api/family/achievements/batch/', url: '/family/api/family/achievements/batch/',
type: 'POST', 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 searchbar = document.getElementById("search-input")
var search_results = document.getElementById("search-results") 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) { 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) const field = $('#' + field_id)
console.log("autoCompleteFamily commence")
// Configuration du tooltip // Configuration du tooltip
field.tooltip({ field.tooltip({
html: true, html: true,

View File

@ -65,6 +65,23 @@ class AchievementTable(tables.Table):
""" """
List recent achievements. 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( delete = tables.LinkColumn(
'family:achievement_delete', 'family:achievement_delete',
args=[A('id')], args=[A('id')],
@ -73,11 +90,11 @@ class AchievementTable(tables.Table):
orderable=False, orderable=False,
attrs={ attrs={
'th': { 'th': {
'id': 'delete-membership-header' 'id': 'delete-achievement-header'
}, },
'a': { 'a': {
'class': 'btn btn-danger', 'class': 'btn btn-danger',
'data-type': 'delete-membership' 'data-type': 'delete-achievement'
} }
}, },
) )
@ -87,6 +104,20 @@ class AchievementTable(tables.Table):
'class': 'table table-condensed table-striped table-hover' 'class': 'table table-condensed table-striped table-hover'
} }
model = Achievement model = Achievement
fields = ('family', 'challenge', 'challenge__points', 'obtained_at', ) fields = ('family', 'challenge', 'challenge__points', 'obtained_at', 'valid')
template_name = 'django_tables2/bootstrap4.html' template_name = 'django_tables2/bootstrap4.html'
order_by = ('-obtained_at',) order_by = ('-obtained_at',)
class FamilyAchievementTable(tables.Table):
"""
Table des défis réalisés par une famille spécifique.
"""
class Meta:
model = Achievement
template_name = 'django_tables2/bootstrap4.html'
fields = ('challenge', 'challenge__points', 'obtained_at',)
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
order_by = ('-obtained_at',)

View File

@ -18,9 +18,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<a class="btn btn-primary" href="{% url 'family:achievement_list' %}">{% trans "Return to achievements list" %}</a> <a class="btn btn-primary" href="{% url 'family:achievement_list' %}">{% trans "Return to achievements list" %}</a>
{% if not object.locked %} <button class="btn btn-danger" type="submit">{% trans "Delete" %}</button>
<button class="btn btn-danger" type="submit">{% trans "Delete" %}</button>
{% endif %}
</form> </form>
</div> </div>
</div> </div>

View File

@ -0,0 +1,28 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-light">
<div class="card-header text-center">
<h4>{% trans "Validate achievement" %}</h4>
</div>
<div class="card-body">
<div class="alert alert-warning">
{% blocktrans %}Are you sure you want to validate this achievement? This action can't be undone.{% endblocktrans %}
</div>
</div>
<div class="card-footer text-center">
<form method="post">
{% csrf_token %}
<a class="btn btn-primary" href="{% url 'family:achievement_list' %}">{% trans "Return to achievements list" %}</a>
<form method="post" action="{% url 'family:achievement_validate' pk %}">
{% csrf_token %}
<button type="submit" class="btn btn-success">{% trans "Validate" %}</button>
</form>
</form>
</div>
</div>
{% endblock %}

View File

@ -10,13 +10,24 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div class="card mb-4" id="history"> <div class="card mb-4" id="history">
<div class="card-header"> <div class="card-header">
<p class="card-text font-weight-bold"> <p class="card-text font-weight-bold">
{% trans "Recent achievements history" %} {% trans "Invalid achievements history" %}
</p> </p>
<a class="btn btn-sm btn-primary mx-2" href="{% url "family:manage" %}"> <a class="btn btn-sm btn-primary mx-2" href="{% url "family:manage" %}">
{% trans "Return to management page" %} {% trans "Return to management page" %}
</a> </a>
</div> </div>
{% render_table table %} {% render_table invalid %}
</div> </div>
<div class="card mb-4" id="history">
<div class="card-header">
<p class="card-text font-weight-bold">
{% trans "Valid achievements history" %}
</p>
<a class="btn btn-sm btn-primary mx-2" href="{% url "family:manage" %}">
{% trans "Return to management page" %}
</a>
</div>
{% render_table valid %}
</div>
{% endblock %} {% endblock %}

View File

@ -13,4 +13,13 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div> </div>
{% render_table member_list %} {% render_table member_list %}
</div> </div>
<div class="my-4"></div>
<div class="card">
<div class="card-header position-relative">
<i class="fa fa-trophy"></i> {% trans "Completed challenges" %}
</div>
{% render_table achievement_list %}
</div>
{% endblock %} {% endblock %}

View File

@ -84,12 +84,12 @@ SPDX-License-Identifier: GPL-3.0-or-later
</h3> </h3>
<div class="card-body text-center"> <div class="card-body text-center">
{% if can_add_family %} {% if can_add_family %}
<a class="btn btn-sm btn-primary mx-2" href="{% url "family:add_family" %}"> <a class="btn btn-sm btn-primary mx-2" href="{% url "family:family_create" %}">
{% trans "Add a family" %} {% trans "Add a family" %}
</a> </a>
{% endif %} {% endif %}
{% if can_add_challenge %} {% if can_add_challenge %}
<a class="btn btn-sm btn-primary mx-2" href="{% url "family:add_challenge" %}"> <a class="btn btn-sm btn-primary mx-2" href="{% url "family:challenge_create" %}">
{% trans "Add a challenge" %} {% trans "Add a challenge" %}
</a> </a>
{% endif %} {% endif %}
@ -147,7 +147,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div> </div>
</div> </div>
{# transaction history #} {# achievement history #}
<div class="card"> <div class="card">
<div class="card-header position-relative" id="historyListHeading"> <div class="card-header position-relative" id="historyListHeading">
<a class="stretched-link font-weight-bold" <a class="stretched-link font-weight-bold"
@ -155,7 +155,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% trans "Recent achievements history" %} {% trans "Recent achievements history" %}
</a> </a>
</div> </div>
<div id="history_list"> <div id="history">
{% render_table table %} {% render_table table %}
</div> </div>
</div> </div>

View File

View File

@ -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/')

View File

@ -8,17 +8,18 @@ from . import views
app_name = 'family' app_name = 'family'
urlpatterns = [ urlpatterns = [
path('list/', views.FamilyListView.as_view(), name="family_list"), 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/<int:pk>/', views.FamilyDetailView.as_view(), name="family_detail"), path('<int:pk>/detail/', views.FamilyDetailView.as_view(), name="family_detail"),
path('update/<int:pk>/', views.FamilyUpdateView.as_view(), name="family_update"), path('<int:pk>/update/', views.FamilyUpdateView.as_view(), name="family_update"),
path('update_pic/<int:pk>/', views.FamilyPictureUpdateView.as_view(), name="update_pic"), path('<int:pk>/update_pic/', views.FamilyPictureUpdateView.as_view(), name="update_pic"),
path('add_member/<int:family_pk>/', views.FamilyAddMemberView.as_view(), name="family_add_member"), path('<int:family_pk>/add_member/', views.FamilyAddMemberView.as_view(), name="family_add_member"),
path('challenge/list/', views.ChallengeListView.as_view(), name="challenge_list"), 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/<int:pk>/', views.ChallengeDetailView.as_view(), name="challenge_detail"), path('challenge/<int:pk>/detail/', views.ChallengeDetailView.as_view(), name="challenge_detail"),
path('challenge/update/<int:pk>/', views.ChallengeUpdateView.as_view(), name="challenge_update"), path('challenge/<int:pk>/update/', views.ChallengeUpdateView.as_view(), name="challenge_update"),
path('manage/', views.FamilyManageView.as_view(), name="manage"), path('manage/', views.FamilyManageView.as_view(), name="manage"),
path('achievements/', views.AchievementsView.as_view(), name="achievement_list"), path('achievement/list/', views.AchievementListView.as_view(), name="achievement_list"),
path('achievement/delete/<int:pk>/', views.AchievementDeleteView.as_view(), name="achievement_delete"), path('achievement/<int:pk>/validate/', views.AchievementValidateView.as_view(), name="achievement_validate"),
path('api/family/', include('family.api.urls')), path('achievement/<int:pk>/delete/', views.AchievementDeleteView.as_view(), name="achievement_delete"),
path('api/family/', include(('family.api.urls', 'family_api'), namespace='api')),
] ]

View File

@ -4,19 +4,21 @@
from datetime import date from datetime import date
from django.conf import settings from django.conf import settings
from django.shortcuts import redirect
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import transaction 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.edit import DeleteView, FormMixin
from django.views.generic.base import TemplateView
from django.utils.translation import gettext_lazy as _ 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.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin, ProtectedCreateView from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from django.urls import reverse_lazy 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 .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 from .forms import ChallengeForm, FamilyMembershipForm, FamilyForm
@ -88,6 +90,12 @@ class FamilyDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context["can_add_members"] = PermissionBackend()\ context["can_add_members"] = PermissionBackend()\
.has_perm(self.request.user, "family.add_membership", empty_membership) .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 return context
@ -104,17 +112,28 @@ class FamilyUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
return reverse_lazy('family:family_detail', kwargs={'pk': self.object.pk}) 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 Update profile picture of the family
""" """
model = Family model = Family
extra_context = {"title": _("Update family picture")} extra_context = {"title": _("Update family picture")}
template_name = 'family/picture_update.html' 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): def get_success_url(self):
"""Redirect to family page after upload""" """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 @transaction.atomic
def form_valid(self, form): def form_valid(self, form):
@ -133,6 +152,11 @@ class FamilyPictureUpdateView(PictureUpdateView):
else: else:
image.name = "{}_pic.png".format(self.object.pk) 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): class FamilyAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
""" """
@ -274,30 +298,63 @@ class FamilyManageView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView
PermissionBackend.filter_queryset(self.request, Challenge, "view") PermissionBackend.filter_queryset(self.request, Challenge, "view")
).order_by('name') ).order_by('name')
context["can_add_family"] = PermissionBackend.check_perm(self.request, "family.add_family") context["can_add_family"] = PermissionBackend.check_perm(self.request, "family.family_create")
context["can_add_challenge"] = PermissionBackend.check_perm(self.request, "family.add_challenge") context["can_add_challenge"] = PermissionBackend.check_perm(self.request, "family.challenge_create")
return context return context
def get_table(self, **kwargs): def get_table(self, **kwargs):
table = super().get_table(**kwargs) table = super().get_table(**kwargs)
table.exclude = ('delete',) table.exclude = ('delete', 'validate',)
table.orderable = False table.orderable = False
return table return table
class AchievementsView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): class AchievementListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView):
""" """
List all achievements List all achievements
""" """
model = Achievement model = Achievement
table_class = AchievementTable tables = [AchievementTable, AchievementTable, ]
extra_context = {'title': _('Achievement list')} extra_context = {'title': _('Achievement list')}
def get_table(self, **kwargs): def get_tables(self, **kwargs):
table = super().get_table(**kwargs) tables = super().get_tables(**kwargs)
table.orderable = True
return table 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):
achievement = Achievement.objects.get(pk=pk)
achievement.valid = True
achievement.save()
return redirect(reverse_lazy('family:achievement_list'))
class AchievementDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView): class AchievementDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView):

View File

@ -7,6 +7,17 @@
<dt class="col-xl-6">{% trans 'username'|capfirst %}</dt> <dt class="col-xl-6">{% trans 'username'|capfirst %}</dt>
<dd class="col-xl-6">{{ user_object.username }}</dd> <dd class="col-xl-6">{{ user_object.username }}</dd>
<dt class="col-xl-6">{% trans 'family'|capfirst %}</dt>
<dd class="col-xl-6">
{% if families %}
{% for fam in families %}
<a href="{% url 'family:family_detail' fam.pk %}">{{ fam.name }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
{% else %}
<span class="text-muted">Aucune</span>
{% endif %}
</dd>
{% if user_object.pk == user.pk %} {% if user_object.pk == user.pk %}
<dt class="col-xl-6">{% trans 'password'|capfirst %}</dt> <dt class="col-xl-6">{% trans 'password'|capfirst %}</dt>
<dd class="col-xl-6"> <dd class="col-xl-6">

View File

@ -26,6 +26,7 @@ from note_kfet.middlewares import _set_current_request
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from permission.models import Role from permission.models import Role
from permission.views import ProtectQuerysetMixin, ProtectedCreateView from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from family.models import Family
from django import forms from django import forms
from .forms import UserForm, ProfileForm, ImageForm, ClubForm, MembershipForm, \ from .forms import UserForm, ProfileForm, ImageForm, ClubForm, MembershipForm, \
@ -207,6 +208,9 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context["can_unlock_note"] = not user.note.is_active and PermissionBackend\ context["can_unlock_note"] = not user.note.is_active and PermissionBackend\
.check_perm(self.request, "note.change_noteuser_is_active", modified_note) .check_perm(self.request, "note.change_noteuser_is_active", modified_note)
families = Family.objects.filter(memberships__user=user).distinct()
context["families"] = families
return context return context

View File

@ -4095,14 +4095,6 @@ msgstr "La note est indisponible pour le moment"
msgid "Thank you for your understanding -- The Respos Info of BDE" msgid "Thank you for your understanding -- The Respos Info of BDE"
msgstr "Merci de votre compréhension -- Les Respos Info du 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 #: note_kfet/templates/cas/logged.html:8
msgid "" msgid ""
"<h3>Log In Successful</h3>You have successfully logged into the Central " "<h3>Log In Successful</h3>You have successfully logged into the Central "