1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-10-31 23:54:30 +01:00

Compare commits

..

3 Commits

Author SHA1 Message Date
aeltheos
6b3fcb2408 Merge branch 'nix-shell' into 'main'
Nix shell

See merge request bde/nk20!201
2025-05-09 19:26:25 +02:00
Yoann Beaugnon
dde1baa25c typo 2022-08-21 19:50:53 +02:00
Yoann Beaugnon
7a7ee47e0b Add two shell.nix to enable easier development on nixos. 2022-08-21 19:46:11 +02:00
149 changed files with 2227 additions and 11546 deletions

View File

@@ -21,6 +21,3 @@ EMAIL_PASSWORD=CHANGE_ME
# Wiki configuration
WIKI_USER=NoteKfet2020
WIKI_PASSWORD=
# OIDC
OIDC_RSA_PRIVATE_KEY=CHANGE_ME

1
.gitignore vendored
View File

@@ -48,7 +48,6 @@ backups/
env/
venv/
db.sqlite3
shell.nix
# ansibles customs host
ansible/host_vars/*.yaml

View File

@@ -8,7 +8,7 @@ variables:
GIT_SUBMODULE_STRATEGY: recursive
# Ubuntu 22.04
py310-django52:
py310-django42:
stage: test
image: ubuntu:22.04
before_script:
@@ -22,10 +22,10 @@ py310-django52:
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
python3-bs4 python3-setuptools tox texlive-xetex
script: tox -e py310-django52
script: tox -e py310-django42
# Debian Bookworm
py311-django52:
py311-django42:
stage: test
image: debian:bookworm
before_script:
@@ -37,7 +37,7 @@ py311-django52:
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
python3-bs4 python3-setuptools tox texlive-xetex
script: tox -e py311-django52
script: tox -e py311-django42
linters:
stage: quality-assurance

View File

@@ -61,8 +61,8 @@ Bien que cela permette de créer une instance sur toutes les distributions,
6. (Optionnel) **Création d'une clé privée OpenID Connect**
Pour activer le support d'OpenID Connect, il faut générer une clé privée, par
exemple avec openssl (`openssl genrsa -out oidc.key 4096`), et copier la clé dans .env dans le champ
`OIDC_RSA_PRIVATE_KEY`.
exemple avec openssl (`openssl genrsa -out oidc.key 4096`), et renseigner son
emplacement dans `OIDC_RSA_PRIVATE_KEY` (par défaut `/var/secrets/oidc.key`).
7. Enjoy :
@@ -237,8 +237,8 @@ Sinon vous pouvez suivre les étapes décrites ci-dessous.
7. **Création d'une clé privée OpenID Connect**
Pour activer le support d'OpenID Connect, il faut générer une clé privée, par
exemple avec openssl (`openssl genrsa -out oidc.key 4096`), et renseigner le champ
`OIDC_RSA_PRIVATE_KEY` dans le .env (par défaut `/var/secrets/oidc.key`).
exemple avec openssl (`openssl genrsa -out oidc.key 4096`), et renseigner son
emplacement dans `OIDC_RSA_PRIVATE_KEY` (par défaut `/var/secrets/oidc.key`).
8. *Enjoy \o/*

View File

@@ -48,15 +48,5 @@
"can_invite": true,
"guest_entry_fee": 0
}
},
{
"model": "activity.activitytype",
"pk": 8,
"fields": {
"name": "Perm bouffe",
"manage_entries": false,
"can_invite": false,
"guest_entry_fee": 0
}
}
]
]

View File

@@ -32,7 +32,7 @@ class ActivityForm(forms.ModelForm):
def clean_organizer(self):
organizer = self.cleaned_data['organizer']
if not organizer.note.is_active:
self.add_error('organizer', _('The note of this club is inactive.'))
self.add_error('organiser', _('The note of this club is inactive.'))
return organizer
def clean_date_end(self):

View File

@@ -1,19 +0,0 @@
# Generated by Django 4.2.20 on 2025-05-08 19:07
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('activity', '0006_guest_school'),
]
operations = [
migrations.AlterField(
model_name='guest',
name='activity',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='activity.activity'),
),
]

View File

@@ -37,11 +37,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div id="guests_table">
{% render_table guests %}
</div>
<div class="card-footer text-center">
<button class="btn btn-block btn-primary mb-3" onclick="window.location.href='?_export=1&table=guests'">
{% trans "Export to CSV" %}
</button>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,4 +1,4 @@
{% extends "base_search.html" %}
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
@@ -44,8 +44,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
<h3 class="card-header text-center">
{% trans "All activities" %}
</h3>
{% render_table all %}
{% render_table table %}
</div>
{{ block.super }}
{% endblock %}

View File

@@ -1,7 +1,7 @@
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n perms pretty_money dict_get %}
{% load i18n perms pretty_money %}
{% url 'activity:activity_detail' activity.pk as activity_detail_url %}
<div id="activity_info" class="card bg-light shadow mb-3">
@@ -53,12 +53,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
<dt class="col-xl-6">{% trans 'opened'|capfirst %}</dt>
<dd class="col-xl-6">{{ activity.open|yesno }}</dd>
</dl>
{% if show_entries|dict_get:activity %}
<h2 class="text-center">
{{ entries_count|dict_get:activity }}
{% if entries_count|dict_get:activity >= 2 %}{% trans "entries" %}{% else %}{% trans "entry" %}{% endif %}
</h2>
{% endif %}
</div>
<div class="card-footer text-center">
@@ -66,10 +60,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
<a class="btn btn-warning btn-sm my-1" href="{% url 'activity:activity_entry' pk=activity.pk %}"> {% trans "Entry page" %}</a>
{% endif %}
{% if activity.activity_type.name == "Perm bouffe" %}
<a class="btn btn-warning btn-sm my-1" href="{% url 'food:dish_list' activity_pk=activity.pk %}"> {% trans "Dish page" %}</a>
{% endif %}
{% if request.path_info == activity_detail_url %}
{% if activity.valid and ".change__open"|has_perm:activity %}
<a class="btn btn-warning btn-sm my-1" id="open_activity"> {% if activity.open %}{% trans "close"|capfirst %}{% else %}{% trans "open"|capfirst %}{% endif %}</a>

View File

@@ -1,12 +0,0 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django import template
def dict_get(d, key):
return d.get(key)
register = template.Library()
register.filter('dict_get', dict_get)

View File

@@ -67,65 +67,32 @@ class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin
tables = [
lambda data: ActivityTable(data, prefix="all-"),
lambda data: ActivityTable(data, prefix="upcoming-"),
lambda data: ActivityTable(data, prefix="search-"),
]
extra_context = {"title": _("Activities")}
def get_queryset(self, **kwargs):
"""
Filter the user list with the given pattern.
"""
return super().get_queryset().distinct()
return super().get_queryset(**kwargs).distinct()
def get_tables_data(self):
# first table = all activities, second table = upcoming, third table = search
# table search
qs = self.get_queryset().order_by('-date_start')
if "search" in self.request.GET and self.request.GET['search']:
pattern = self.request.GET['search']
# check regex
valid_regex = is_regex(pattern)
suffix = '__iregex' if valid_regex else '__istartswith'
prefix = '^' if valid_regex else ''
qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern})
| Q(**{f'organizer__name{suffix}': prefix + pattern})
| Q(**{f'organizer__note__alias__name{suffix}': prefix + pattern}))
else:
qs = qs.none()
search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Activity, 'view'))
# first table = all activities, second table = upcoming
return [
self.get_queryset().order_by("-date_start"),
Activity.objects.filter(date_end__gt=timezone.now())
.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))
.distinct()
.order_by("date_start"),
search_table,
.order_by("date_start")
]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
tables = context["tables"]
for name, table in zip(["all", "upcoming", "table"], tables):
for name, table in zip(["table", "upcoming"], tables):
context[name] = table
started_activities = self.get_queryset().filter(open=True, valid=True).distinct().all()
context["started_activities"] = started_activities
entries_count = {}
show_entries = {}
for activity in started_activities:
if activity.activity_type.manage_entries:
entries = Entry.objects.filter(activity=activity)
entries_count[activity] = entries.count()
show_entries[activity] = True
context["entries_count"] = entries_count
context["show_entries"] = show_entries
return context
@@ -136,19 +103,12 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix
model = Activity
context_object_name = "activity"
extra_context = {"title": _("Activity detail")}
export_formats = ["csv"]
tables = [
GuestTable,
OpenerTable,
lambda data: GuestTable(data, prefix="guests-"),
lambda data: OpenerTable(data, prefix="opener-"),
]
def get_tables(self):
tables = super().get_tables()
tables[0].prefix = "guests"
tables[1].prefix = "opener"
return tables
def get_tables_data(self):
return [
Guest.objects.filter(activity=self.object)
@@ -157,51 +117,6 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix
.filter(PermissionBackend.filter_queryset(self.request, Opener, "view")),
]
def render_to_response(self, context, **response_kwargs):
"""
Gère l'export CSV manuel pour MultiTableMixin.
"""
if "_export" in self.request.GET:
import tablib
table_name = self.request.GET.get("table")
if table_name:
tables = self.get_tables()
data_list = self.get_tables_data()
for t, d in zip(tables, data_list):
if t.prefix == table_name:
# Préparer le CSV
dataset = tablib.Dataset()
columns = list(t.base_columns) # noms des colonnes
dataset.headers = columns
for row in d:
values = []
for col in columns:
try:
val = getattr(row, col, "")
# Gestion spéciale pour la colonne 'entry'
if col == "entry":
if getattr(row, "has_entry", False):
val = timezone.localtime(row.entry.time).strftime("%Y-%m-%d %H:%M:%S")
else:
val = ""
values.append(str(val) if val is not None else "")
except Exception: # RelatedObjectDoesNotExist ou autre
values.append("")
dataset.append(values)
csv_bytes = dataset.export("csv")
if isinstance(csv_bytes, str):
csv_bytes = csv_bytes.encode("utf-8")
response = HttpResponse(csv_bytes, content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="{table_name}.csv"'
return response
# Sinon rendu normal
return super().render_to_response(context, **response_kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data()
@@ -222,14 +137,6 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix
"placeholder": ""
}
}
if self.object.activity_type.manage_entries:
entries = Entry.objects.filter(activity=self.object)
context["entries_count"] = {self.object: entries.count()}
context["show_entries"] = {self.object: timezone.now() > timezone.localtime(self.object.date_start)}
else:
context["entries_count"] = {self.object: 0}
context["show_entries"] = {self.object: False}
return context

View File

@@ -19,10 +19,6 @@ if "activity" in settings.INSTALLED_APPS:
from activity.api.urls import register_activity_urls
register_activity_urls(router, 'activity')
if "family" in settings.INSTALLED_APPS:
from family.api.urls import register_family_urls
register_family_urls(router, 'family')
if "food" in settings.INSTALLED_APPS:
from food.api.urls import register_food_urls
register_food_urls(router, 'food')

View File

@@ -1,46 +0,0 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from rest_framework import serializers
from ..models import Family, FamilyMembership, Challenge, Achievement
class FamilySerializer(serializers.ModelSerializer):
"""
REST API Serializer for Family.
The djangorestframework plugin will analyse the model `Family` and parse all fields in the API.
"""
class Meta:
model = Family
fields = '__all__'
class FamilyMembershipSerializer(serializers.ModelSerializer):
"""
REST API Serializer for FamilyMembership.
The djangorestframework plugin will analyse the model `FamilyMembership` and parse all fields in the API.
"""
class Meta:
model = FamilyMembership
fields = '__all__'
class ChallengeSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Challenge.
The djangorestframework plugin will analyse the model `Challenge` and parse all fields in the API.
"""
class Meta:
model = Challenge
fields = '__all__'
class AchievementSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Achievement.
The djangorestframework plugin will analyse the model `Achievement` and parse all fields in the API.
"""
class Meta:
model = Achievement
fields = '__all__'

View File

@@ -1,20 +0,0 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path
from .views import FamilyViewSet, FamilyMembershipViewSet, ChallengeViewSet, AchievementViewSet, BatchAchievementsAPIView
def register_family_urls(router, path):
"""
Configure router for Family REST API
"""
router.register(path + '/family', FamilyViewSet)
router.register(path + '/familymembership', FamilyMembershipViewSet)
router.register(path + '/challenge', ChallengeViewSet)
router.register(path + '/achievement', AchievementViewSet)
urlpatterns = [
path('achievements/batch/', BatchAchievementsAPIView.as_view(), name='batch_achievements')
]

View File

@@ -1,98 +0,0 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from api.viewsets import ReadProtectedModelViewSet
from django_filters.rest_framework import DjangoFilterBackend
from api.filters import RegexSafeSearchFilter
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from .serializers import FamilySerializer, FamilyMembershipSerializer, ChallengeSerializer, AchievementSerializer
from ..models import Family, FamilyMembership, Challenge, Achievement
class FamilyViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Family` objects, serialize it to JSON with the given serializer,
then render it on /api/family/family/
"""
queryset = Family.objects.order_by('id')
serializer_class = FamilySerializer
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', 'description', 'score', 'rank', ]
search_fields = ['$name', '$description', ]
class FamilyMembershipViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `FamilyMembership` objects, serialize it to JSON with the given serializer,
then render it on /api/family/familymembership/
"""
queryset = FamilyMembership.objects.order_by('id')
serializer_class = FamilyMembershipSerializer
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['user__username', 'user__first_name', 'user__last_name', 'user__email', 'user__note__alias__name',
'user__note__alias__normalized_name', 'family__name', 'family__description', 'year', ]
search_fields = ['$user__username', '$user__first_name', '$user__last_name', '$user__email', '$user__note__alias__name',
'$user__note__alias__normalized_name', '$family__name', '$family__description', '$year', ]
class ChallengeViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Challenge` objects, serialize it to JSON with the given serializer,
then render it on /api/family/challenge/
"""
queryset = Challenge.objects.order_by('id')
serializer_class = ChallengeSerializer
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['name', 'description', 'points', ]
search_fields = ['$name', '$description', '$points', ]
class AchievementViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Achievement` objects, serialize it to JSON with the given serializer,
then render it on /api/family/achievement/
"""
queryset = Achievement.objects.order_by('id')
serializer_class = AchievementSerializer
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['family__name', 'family__description', 'challenge__name', 'challenge__description', 'obtained_at', 'valid', ]
search_fields = ['$family__name', '$family__description', '$challenge__name', '$challenge__description', ]
class BatchAchievementsAPIView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request, format=None):
family_ids = request.data.get('families')
challenge_ids = request.data.get('challenges')
families = Family.objects.filter(id__in=family_ids)
challenges = Challenge.objects.filter(id__in=challenge_ids)
results = []
for family in families:
for challenge in challenges:
a, created = Achievement.objects.get_or_create(family=family, challenge=challenge)
if created:
results.append({
'family': family.name,
'challenge': challenge.name,
'status': 'created'
})
else:
results.append({
'family': family.name,
'challenge': challenge.name,
'status': 'existed',
})
for family in families:
family.update_score()
Family.update_ranking()
return Response({'results': results}, status=status.HTTP_201_CREATED)

View File

@@ -1,11 +0,0 @@
# 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')

View File

@@ -1,44 +0,0 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django import forms
from django.forms.widgets import NumberInput
from note_kfet.inputs import Autocomplete
from .models import Challenge, FamilyMembership, User, Family
class ChallengeForm(forms.ModelForm):
"""
To update a challenge
"""
class Meta:
model = Challenge
fields = ('name', 'description', 'points',)
widgets = {
"points": NumberInput()
}
class FamilyForm(forms.ModelForm):
class Meta:
model = Family
fields = ('name', 'description', )
class FamilyMembershipForm(forms.ModelForm):
class Meta:
model = FamilyMembership
fields = ('user', )
widgets = {
"user":
Autocomplete(
User,
attrs={
'api_url': '/api/user/',
'name_field': 'username',
'placeholder': 'Nom ...',
},
)
}

View File

@@ -1,73 +0,0 @@
# Generated by Django 4.2.21 on 2025-07-06 16:07
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Challenge',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='name')),
('description', models.CharField(max_length=255, verbose_name='description')),
('points', models.PositiveIntegerField(verbose_name='points')),
('obtained', models.PositiveIntegerField(default=0, verbose_name='obtained')),
],
options={
'verbose_name': 'challenge',
'verbose_name_plural': 'challenges',
},
),
migrations.CreateModel(
name='Family',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True, verbose_name='name')),
('description', models.CharField(max_length=255, verbose_name='description')),
('score', models.PositiveIntegerField(default=0, verbose_name='score')),
('rank', models.PositiveIntegerField(verbose_name='rank')),
],
options={
'verbose_name': 'Family',
'verbose_name_plural': 'Families',
},
),
migrations.CreateModel(
name='Achievement',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('obtained_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='obtained at')),
('challenge', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='family.challenge')),
('family', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='family.family', verbose_name='family')),
],
options={
'verbose_name': 'achievement',
'verbose_name_plural': 'achievements',
},
),
migrations.CreateModel(
name='FamilyMembership',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('year', models.PositiveIntegerField(default=2025, verbose_name='year')),
('family', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='members', to='family.family', verbose_name='family')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='family_memberships', to=settings.AUTH_USER_MODEL, verbose_name='user')),
],
options={
'verbose_name': 'family membership',
'verbose_name_plural': 'family memberships',
'unique_together': {('user', 'year')},
},
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 4.2.23 on 2025-07-17 15:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('family', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='family',
name='display_image',
field=models.ImageField(default='pic/default.png', max_length=255, upload_to='pic/', verbose_name='display image'),
),
]

View File

@@ -1,24 +0,0 @@
# 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

@@ -1,17 +0,0 @@
# 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

@@ -1,17 +0,0 @@
# Generated by Django 5.2.4 on 2025-08-13 20:26
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('family', '0004_remove_challenge_obtained'),
]
operations = [
migrations.AlterUniqueTogether(
name='achievement',
unique_together={('challenge', 'family')},
),
]

View File

@@ -1,207 +0,0 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.db import models, transaction
from django.utils import timezone
from django.contrib.auth.models import User
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
class Family(models.Model):
name = models.CharField(
max_length=255,
verbose_name=_('name'),
unique=True,
)
description = models.CharField(
max_length=255,
verbose_name=_('description'),
)
score = models.PositiveIntegerField(
verbose_name=_('score'),
default=0,
)
rank = models.PositiveIntegerField(
verbose_name=_('rank'),
)
display_image = models.ImageField(
verbose_name=_('display image'),
max_length=255,
blank=False,
null=False,
upload_to='pic/',
default='pic/default.png'
)
class Meta:
verbose_name = _('Family')
verbose_name_plural = _('Families')
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse_lazy('family:family_detail', args=(self.pk,))
def update_score(self, *args, **kwargs):
challenge_set = Challenge.objects.select_for_update().filter(achievement__family=self, achievement__valid=True)
points_sum = challenge_set.aggregate(models.Sum("points"))
self.score = points_sum["points__sum"] if points_sum["points__sum"] else 0
self.save()
self.update_ranking()
@staticmethod
def update_ranking(*args, **kwargs):
"""
Update ranking when adding or removing points
"""
family_set = Family.objects.select_for_update().all().order_by("-score")
for i in range(family_set.count()):
if i == 0 or family_set[i].score != family_set[i - 1].score:
new_rank = i + 1
family = family_set[i]
family.rank = new_rank
family._force_save = True
family.save()
def save(self, *args, **kwargs):
if self.rank is None:
last_family = Family.objects.order_by("rank").last()
if last_family is None or last_family.score > self.score:
self.rank = Family.objects.count() + 1
else:
self.rank = last_family.rank
super().save(*args, **kwargs)
class FamilyMembership(models.Model):
user = models.OneToOneField(
User,
on_delete=models.PROTECT,
related_name=_('family_memberships'),
verbose_name=_('user'),
)
family = models.ForeignKey(
Family,
on_delete=models.PROTECT,
related_name=_('memberships'),
verbose_name=_('family'),
)
year = models.PositiveIntegerField(
verbose_name=_('year'),
default=timezone.now().year,
)
class Meta:
unique_together = ('user', 'year',)
verbose_name = _('family membership')
verbose_name_plural = _('family memberships')
def __str__(self):
return _('Family membership of {user} to {family}').format(user=self.user.username, family=self.family.name, )
class Challenge(models.Model):
name = models.CharField(
max_length=255,
verbose_name=_('name'),
)
description = models.CharField(
max_length=255,
verbose_name=_('description'),
)
points = models.PositiveIntegerField(
verbose_name=_('points'),
)
@property
def obtained(self):
achievements = Achievement.objects.filter(challenge=self, valid=True)
return achievements.count()
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse_lazy('family:challenge_detail', args=(self.pk,))
@transaction.atomic
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# Update families who already obtained this challenge
achievements = Achievement.objects.filter(challenge=self)
for achievement in achievements:
achievement.save()
class Meta:
verbose_name = _('challenge')
verbose_name_plural = _('challenges')
class Achievement(models.Model):
challenge = models.ForeignKey(
Challenge,
on_delete=models.PROTECT,
)
family = models.ForeignKey(
Family,
on_delete=models.PROTECT,
verbose_name=_('family'),
)
obtained_at = models.DateTimeField(
verbose_name=_('obtained at'),
default=timezone.now,
)
valid = models.BooleanField(
verbose_name=_('valid'),
default=False,
)
class Meta:
unique_together = ('challenge', 'family',)
verbose_name = _('achievement')
verbose_name_plural = _('achievements')
def __str__(self):
return _('Challenge {challenge} carried out by Family {family}').format(challenge=self.challenge.name, family=self.family.name, )
@transaction.atomic
def save(self, *args, update_score=True, **kwargs):
"""
When saving, also grants points to the family
"""
self.family = Family.objects.select_for_update().get(pk=self.family_id)
self.challenge = Challenge.objects.select_for_update().get(pk=self.challenge_id)
super().save(*args, **kwargs)
if update_score:
self.family.refresh_from_db()
self.family.update_score()
@transaction.atomic
def delete(self, *args, **kwargs):
"""
When deleting, also removes points from the family
"""
# Get the family and challenge before deletion
self.family = Family.objects.select_for_update().get(pk=self.family_id)
# Delete the achievement
super().delete(*args, **kwargs)
# Remove points from the family
self.family.refresh_from_db()
self.family.update_score()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,411 +0,0 @@
// Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
// SPDX-License-Identifier: GPL-3.0-or-later
// When a transaction is performed, lock the interface to prevent spam clicks.
var LOCK = false
/**
* Refresh the history table on the consumptions page.
*/
function refreshHistory () {
$('#history').load('/family/manage/ #history')
}
$(document).ready(function () {
// If hash of a category in the URL, then select this category
// else select the first one
if (location.hash) {
$("a[href='" + location.hash + "']").tab('show')
} else {
$("a[data-toggle='tab']").first().tab('show')
}
// When selecting a category, change URL
$(document.body).on('click', "a[data-toggle='tab']", function () {
location.hash = this.getAttribute('href')
})
})
notes = []
notes_display = []
buttons = []
// When the user searches an alias, we update the auto-completion
autoCompleteFamily('note', 'note_list', notes, notes_display,
'note', 'user_note', 'profile_pic', function () {
return true
})
/**
* Add a transaction from a button.
* @param fam Where the money goes
* @param amount The price of the item
* @param type The type of the transaction (content type id for RecurrentTransaction)
* @param category_id The category identifier
* @param category_name The category name
* @param template_id The identifier of the button
* @param template_name The name of the button
*/
function addChallenge (id, name, amount) {
var challenge = null
/** Ajout de 1 à chaque clic d'un bouton déjà choisi */
buttons.forEach(function (b) {
if (b.id === id) {
challenge = b
}
})
if (challenge == null) {
challenge = {
id: id,
name: name,
}
buttons.push(challenge)
}
const dc_obj = true
const list = 'consos_list'
let html = ''
buttons.forEach(function (challenge) {
html += li('conso_button_' + challenge.id, challenge.name)
})
document.getElementById(list).innerHTML = html
buttons.forEach((button) => {
document.getElementById(`conso_button_${button.id}`).addEventListener('click', () => {
if (LOCK) { return }
removeNote(button, 'conso_button', buttons, list)()
})
})
}
/**
* Reset the page as its initial state.
*/
function reset () {
notes_display.length = 0
notes.length = 0
buttons.length = 0
document.getElementById('note_list').innerHTML = ''
document.getElementById('consos_list').innerHTML = ''
document.getElementById('note').value = ''
document.getElementById('note').dataset.originTitle = ''
$('#note').tooltip('hide')
document.getElementById('profile_pic').src = '/static/member/img/default_picture.png'
document.getElementById('profile_pic_link').href = '#'
refreshHistory()
LOCK = false
}
/**
* Apply all transactions: all notes in `notes` buy each item in `buttons`
*/
function consumeAll () {
if (LOCK) { return }
LOCK = true
let error = false
if (notes_display.length === 0) {
// ... gestion erreur ...
error = true
}
if (buttons.length === 0) {
// ... gestion erreur ...
error = true
}
if (error) {
LOCK = false
return
}
// Récupérer les IDs des familles et des challenges
const family_ids = notes_display.map(fam => fam.id)
const challenge_ids = buttons.map(chal => chal.id)
$.ajax({
url: '/family/api/family/achievements/batch/',
type: 'POST',
data: JSON.stringify({
families: family_ids,
challenges: challenge_ids
}),
contentType: 'application/json',
headers: {
'X-CSRFToken': CSRF_TOKEN
},
success: function (data) {
reset()
data.results.forEach(function (result) {
if (result.status === 'created') {
addMsg(
interpolate(gettext('Invalid achievement for challenge %s ' +
'and family %s created.'), [result.challenge, result.family]),
'success',
5000
)
} else {
addMsg(
interpolate(gettext('An achievement for challenge %s ' +
'and family %s already exists.'), [result.challenge, result.family]),
'danger',
8000
)
}
})
}
})
}
var searchbar = document.getElementById("search-input")
var search_results = document.getElementById("search-results")
var old_pattern = null;
var firstMatch = null;
/**
* Updates the button search tab
* @param force Forces the update even if the pattern didn't change
*/
function updateSearch(force = false) {
let pattern = searchbar.value
if (pattern === "")
firstMatch = null;
if ((pattern === old_pattern || pattern === "") && !force)
return;
firstMatch = null;
const re = new RegExp(pattern, "i");
Array.from(search_results.children).forEach(function(b) {
if (re.test(b.innerText)) {
b.hidden = false;
if (firstMatch === null) {
firstMatch = b;
}
} else
b.hidden = true;
});
}
searchbar.addEventListener("input", function (e) {
debounce(updateSearch)()
});
searchbar.addEventListener("keyup", function (e) {
if (firstMatch && e.key === "Enter")
firstMatch.click()
});
function createshiny() {
const list_btn = document.querySelectorAll('.btn-outline-dark')
const shiny_class = list_btn[Math.floor(Math.random() * list_btn.length)].classList
shiny_class.replace('btn-outline-dark', 'btn-outline-dark-shiny')
}
createshiny()
/**
* Query the 20 first matched notes with a given pattern
* @param pattern The pattern that is queried
* @param fun For each found note with the matched alias `alias`, fun(note, alias) is called.
*/
function getMatchedFamilies (pattern, fun) {
$.getJSON('/api/family/family/?format=json&alias=' + pattern + '&search=family', fun)
}
/**
* Generate a <li> entry with a given id and text
*/
function li (id, text, extra_css) {
return '<li class="list-group-item py-1 px-2 d-flex justify-content-between align-items-center text-truncate ' +
(extra_css || '') + '"' + ' id="' + id + '">' + text + '</li>\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 <li> pour les familles sélectionnées
* @param user_family_field L'identifiant du champ qui affiche la famille survolée (optionnel)
* @param profile_pic_field L'identifiant du champ qui affiche la photo de la famille survolée (optionnel)
* @param family_click Fonction appelée lors du clic sur un nom. Si elle existe et ne retourne pas true, la famille n'est pas affichée.
*/
function autoCompleteFamily(field_id, family_list_id, families, families_display, family_prefix = 'family', user_family_field = null, profile_pic_field = null, family_click = null) {
const field = $('#' + field_id)
// Configuration du tooltip
field.tooltip({
html: true,
placement: 'bottom',
title: 'Chargement...',
trigger: 'manual',
container: field.parent(),
fallbackPlacement: 'clockwise'
})
// Masquer le tooltip lors d'un clic ailleurs
$(document).click(function (e) {
if (!e.target.id.startsWith(family_prefix)) {
field.tooltip('hide')
}
})
let old_pattern = null
// Réinitialiser la recherche au clic
field.click(function () {
field.tooltip('hide')
field.removeClass('is-invalid')
field.val('')
old_pattern = ''
})
// Sur "Entrée", sélectionner la première famille
field.keypress(function (event) {
if (event.originalEvent.charCode === 13 && families.length > 0) {
const li_obj = field.parent().find('ul li').first()
displayFamily(families[0], families[0].name, user_family_field, profile_pic_field)
li_obj.trigger('click')
}
})
// Mise à jour des suggestions lors de la saisie
field.keyup(function (e) {
field.removeClass('is-invalid')
if (e.originalEvent.charCode === 13) { return }
const pattern = field.val()
if (pattern === old_pattern) { return }
old_pattern = pattern
families.length = 0
if (pattern === '') {
field.tooltip('hide')
families.length = 0
return
}
// Appel à l'API pour récupérer les familles correspondantes
$.getJSON('/api/family/family/?format=json&search=' + pattern,
function (results) {
if (pattern !== $('#' + field_id).val()) { return }
let matched_html = '<ul class="list-group list-group-flush">'
results.results.forEach(function (family) {
matched_html += li(family_prefix + '_' + family.id,
family.name,
'')
families.push(family)
})
matched_html += '</ul>'
field.attr('data-original-title', matched_html).tooltip('show')
results.results.forEach(function (family) {
const family_obj = $('#' + family_prefix + '_' + family.id)
family_obj.hover(function () {
displayFamily(family, family.name, user_family_field, profile_pic_field)
})
family_obj.click(function () {
var disp = null
families_display.forEach(function (d) {
if (d.id === family.id) {
disp = d
}
})
if (disp == null) {
disp = {
name: family.name,
id: family.id,
family: family,
}
families_display.push(disp)
}
if (family_click && !family_click()) { return }
const family_list = $('#' + family_list_id)
let html = ''
families_display.forEach(function (disp) {
html += li(family_prefix + '_' + disp.id,
disp.name,
'')
})
family_list.html(html)
field.tooltip('update')
families_display.forEach(function (disp) {
const line_obj = $('#' + family_prefix + '_' + disp.id)
line_obj.hover(function () {
displayFamily(disp.family, disp.name, user_family_field, profile_pic_field)
})
line_obj.click(removeFamily(disp, family_prefix, families_display, family_list_id, user_family_field,
profile_pic_field))
})
})
})
})
})
}
/**
* Affiche le nom et la photo d'une famille
* @param family L'objet famille à afficher
* @param user_family_field L'identifiant du champ où afficher le nom (optionnel)
* @param profile_pic_field L'identifiant du champ où afficher la photo (optionnel)
*/
function displayFamily(family, user_family_field = null, profile_pic_field = null) {
if (!family.display_image) {
family.display_image = '/static/member/img/default_picture.png'
}
if (user_family_field !== null) {
$('#' + user_family_field).removeAttr('class')
$('#' + user_family_field).text(family.name)
if (profile_pic_field != null) {
$('#' + profile_pic_field).attr('src', family.display_image)
// Si tu veux un lien vers la page famille :
$('#' + profile_pic_field + '_link').attr('href', '/family/detail/' + family.id + '/')
}
}
}
/**
* Retire une famille de la liste sélectionnée.
* @param d La famille à retirer
* @param family_prefix Le préfixe des <li>
* @param families_display Le tableau des familles sélectionnées
* @param family_list_id L'id du bloc où sont affichées les familles
* @param user_family_field Champ d'affichage (optionnel)
* @param profile_pic_field Champ photo (optionnel)
* @returns une fonction compatible avec les événements jQuery
*/
function removeFamily(d, family_prefix, families_display, family_list_id, user_family_field = null, profile_pic_field = null) {
return function () {
const new_families_display = []
let html = ''
families_display.forEach(function (disp) {
})
families_display.length = 0
new_families_display.forEach(function (disp) {
families_display.push(disp)
})
$('#' + family_list_id).html(html)
families_display.forEach(function (disp) {
const obj = $('#' + family_prefix + '_' + disp.id)
obj.click(removeFamily(disp, family_prefix, families_display, family_list_id, user_family_field, profile_pic_field))
obj.hover(function () {
displayFamily(disp.family, user_family_field, profile_pic_field)
})
})
}
}

View File

@@ -1,149 +0,0 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import django_tables2 as tables
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django_tables2 import A
from django.urls import reverse, reverse_lazy
from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend
from .models import Achievement, Challenge, Family, FamilyMembership
class FamilyTable(tables.Table):
"""
List all families
"""
description = tables.Column(verbose_name=_("Description"))
class Meta:
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
model = Family
template_name = 'django_tables2/bootstrap4.html'
fields = ('name', 'score', 'rank',)
order_by = ('rank',)
row_attrs = {
'class': 'table-row',
'data-href': lambda record: reverse('family:family_detail', args=[record.pk]),
'style': 'cursor:pointer',
}
class ChallengeTable(tables.Table):
"""
List all challenges
"""
name = tables.Column(verbose_name=_("Name"))
description = tables.Column(verbose_name=_("Description"))
points = tables.Column(verbose_name=_("Points"))
class Meta:
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
order_by = ('id',)
model = Challenge
template_name = 'django_tables2/bootstrap4.html'
fields = ('name', 'description', 'points',)
row_attrs = {
'class': 'table-row',
'data-href': lambda record: reverse('family:challenge_detail', args=[record.pk]),
'style': 'cursor:pointer',
}
class FamilyMembershipTable(tables.Table):
"""
List all family memberships.
"""
def render_user(self, value):
# Display user's name, clickable if permission is granted
s = value.username
if PermissionBackend.check_perm(get_current_request(), "auth.view_user", value):
s = format_html("<a href='{url}'>{name}</a>",
url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s)
return s
class Meta:
attrs = {
'class': 'table table-condensed table-striped',
'style': 'table-layout: fixed;'
}
template_name = 'django_tables2/bootstrap4.html'
fields = ('user',)
model = FamilyMembership
class AchievementTable(tables.Table):
"""
List recent achievements.
"""
challenge = tables.Column(verbose_name=_("Challenge"))
validate = tables.LinkColumn(
'family:achievement_validate',
args=[A('id')],
verbose_name=_("Validate"),
text=_("Validate"),
orderable=False,
attrs={
'th': {
'id': 'validate-achievement-header'
},
'a': {
'class': 'btn btn-success',
'data-type': 'validate-achievement'
}
},
)
delete = tables.LinkColumn(
'family:achievement_delete',
args=[A('id')],
verbose_name=_("Delete"),
text=_("Delete"),
orderable=False,
attrs={
'th': {
'id': 'delete-achievement-header'
},
'a': {
'class': 'btn btn-danger',
'data-type': 'delete-achievement'
}
},
)
class Meta:
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
model = Achievement
fields = ('family', 'challenge', 'challenge__points', 'obtained_at', 'valid')
template_name = 'django_tables2/bootstrap4.html'
order_by = ('-obtained_at',)
class FamilyAchievementTable(tables.Table):
"""
Table des défis réalisés par une famille spécifique.
"""
challenge = tables.Column(verbose_name=_("Challenge"))
class Meta:
model = Achievement
template_name = 'django_tables2/bootstrap4.html'
fields = ('challenge', 'challenge__points', 'obtained_at', 'valid')
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
order_by = ('-obtained_at',)

View File

@@ -1,25 +0,0 @@
{% 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 "Delete achievement" %}</h4>
</div>
<div class="card-body">
<div class="alert alert-warning">
{% blocktrans %}Are you sure you want to delete 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>
<button class="btn btn-danger" type="submit">{% trans "Delete" %}</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -1,28 +0,0 @@
{% 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

@@ -1,33 +0,0 @@
{% 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 %}
<div class="card mb-4" id="history">
<div class="card-header">
<p class="card-text font-weight-bold">
{% trans "Invalid 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 invalid %}
</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 %}

View File

@@ -1,60 +0,0 @@
{% extends "family/base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load crispy_forms_tags i18n pretty_money %}
{% block profile_content %}
<div class="card bg-light">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body">
<form method="post" action="">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
</form>
</div>
</div>
{% endblock %}
{% block extrajavascript %}
<script>
function autocompleted(user) {
$("#id_last_name").val(user.last_name);
$("#id_first_name").val(user.first_name);
$.getJSON("/api/members/profile/" + user.id + "/", function (profile) {
let fee = profile.paid ? "{{ club.membership_fee_paid }}" : "{{ club.membership_fee_unpaid }}";
$("#id_credit_amount").val((Number(fee) / 100).toFixed(2));
});
}
soge_field = $("#id_soge");
function fillFields() {
let checked = soge_field.is(':checked');
if (!checked) {
$("input").attr('disabled', false);
$("#id_user").attr('disabled', true);
$("select").attr('disabled', false);
return;
}
let credit_type = $("#id_credit_type");
credit_type.attr('disabled', true);
credit_type.val(4);
let credit_amount = $("#id_credit_amount");
credit_amount.attr('disabled', true);
credit_amount.val('{{ total_fee }}');
let bank = $("#id_bank");
bank.attr('disabled', true);
bank.val('Société générale');
}
soge_field.change(fillFields);
</script>
{% endblock %}

View File

@@ -1,52 +0,0 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n perms %}
{# Use a fluid-width container #}
{% block containertype %}container-fluid{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-xl-4">
{% block profile_info %}
<div class="card bg-light" id="card-infos">
<h4 class="card-header text-center">
{{ family.name }}
</h4>
<div class="text-center">
<a href="{% url 'family:update_pic' family.pk %}">
<img src="{{ family.display_image.url }}" class="img-thumbnail mt-2">
</a>
</div>
<div class="card-body" id="profile_infos">
{% include "family/family_info.html" %}
</div>
<div class="card-footer">
{% if can_add_members %}
<a class="btn btn-sm btn-success" href="{% url 'family:family_add_member' family_pk=family.pk %}"
data-turbolinks="false"> {% trans "Add member" %}</a>
{% endif %}
{% if ".change_"|has_perm:family %}
<a class="btn btn-sm btn-secondary" href="{% url 'family:family_update' pk=family.pk %}"
data-turbolinks="false">
<i class="fa fa-edit"></i> {% trans 'Update Profile' %}
</a>
{% endif %}
{% url 'family:family_detail' family.pk as family_detail_url %}
{% if request.path_info != family_detail_url %}
<a class="btn btn-sm btn-primary" href="{{ family_detail_url }}">{% trans 'View Profile' %}</a>
{% endif %}
<a class="btn btn-sm btn-primary" href="{% url "family:family_list" %}">
{% trans "Return to the family list" %}
</a>
</div>
</div>
{% endblock %}
</div>
<div class="col-xl-8">
{% block profile_content %}{% endblock %}
</div>
</div>
{% endblock %}

View File

@@ -1,36 +0,0 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }} {{ challenge.name }}
</h3>
<div class="card-body">
<ul>
{% for field, value in fields %}
<li> {{ field }} : {{ value }}</li>
{% endfor %}
<li> {% trans "Obtained by " %} {{obtained}}
{% if obtained > 1 %}
{% trans "families" %}
{% else %}
{% trans "family" %}
{% endif %}
</li>
</ul>
<a class="btn btn-sm btn-primary" href="{% url "family:challenge_list" %}">
{% trans "Return to the challenge list" %}
</a>
{% if update %}
<a class="btn btn-sm btn-secondary" href="{% url "family:challenge_update" pk=challenge.pk %}">
{% trans "Update" %}
</a>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -1,21 +0,0 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body" id="form">
<form method="post">
{% csrf_token %}
{{ form | crispy }}
<button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -1,42 +0,0 @@
{% 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 %}
<div class="row">
<div class="col-xl-12">
<div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0">
<a href="{% url "family:family_list" %}" class="btn btn-sm btn-outline-primary">
{% trans "Families" %}
</a>
<a href="#" class="btn btn-sm btn-outline-primary active">
{% trans "Challenges" %}
</a>
{% if can_manage %}
<a href="{% url "family:manage" %}" class="btn btn-sm btn-outline-primary">
{% trans "Manage" %}
</a>
{% endif %}
</div>
</div>
</div>
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
{% render_table table %}
</div>
{% endblock %}
{% block extrajavascript %}
<script type="text/javascript">
$(".table-row").click(function () {
window.document.location = $(this).data("href");
});
</script>
{% endblock %}

View File

@@ -1,29 +0,0 @@
{% extends "family/base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load render_table from django_tables2 %}
{% load i18n perms %}
{% block profile_content %}
{% if member_list.data %}
<div class="card">
<div class="card-header position-relative" id="clubListHeading">
<i class="fa fa-users"></i> {% trans "Family members" %}
</div>
{% render_table member_list %}
</div>
<div class="my-4"></div>
{% endif %}
{% if achievement_list.data %}
<div class="card">
<div class="card-header position-relative">
<i class="fa fa-trophy"></i> {% trans "Completed challenges" %}
</div>
{% render_table achievement_list %}
</div>
{% endif %}
{% endblock %}

View File

@@ -1,21 +0,0 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body" id="form">
<form method="post">
{% csrf_token %}
{{ form | crispy }}
<button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -1,15 +0,0 @@
{% load i18n pretty_money perms %}
<dl class="row">
<dt class="col-xl-6">{% trans 'name'|capfirst %}</dt>
<dd class="col-xl-6">{{ family.name }}</dd>
<dt class="col-xl-6">{% trans 'description'|capfirst %}</dt>
<dd class="col-xl-6">{{ family.description }}</dd>
<dt class="col-xl-6">{% trans 'score'|capfirst %}</dt>
<dd class="col-xl-6">{{ family.score }}</dd>
<dt class="col-xl-6">{% trans 'rank'|capfirst %}</dt>
<dd class="col-xl-6">{{ family.rank }}</dd>
</dl>

View File

@@ -1,43 +0,0 @@
{% 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 %}
<div class="row">
<div class="col-xl-12">
<div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0">
<a href="#" class="btn btn-sm btn-outline-primary active">
{% trans "Families" %}
</a>
<a href="{% url "family:challenge_list" %}" class="btn btn-sm btn-outline-primary">
{% trans "Challenges" %}
</a>
{% if can_manage %}
<a href="{% url "family:manage" %}" class="btn btn-sm btn-outline-primary">
{% trans "Manage" %}
</a>
{% endif %}
</div>
</div>
</div>
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
{% render_table table %}
</div>
{% endblock %}
{% block extrajavascript %}
<script type="text/javascript">
$(".table-row").click(function () {
window.document.location = $(this).data("href");
});
</script>
{% endblock %}

View File

@@ -1,287 +0,0 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n static django_tables2 %}
{% block containertype %}container-fluid{% endblock %}
{% block content %}
<div class="row">
<div class="col-xl-12">
<div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0">
<a href="{% url "family:family_list" %}" class="btn btn-sm btn-outline-primary">
{% trans "Families" %}
</a>
<a href="{% url "family:challenge_list" %}" class="btn btn-sm btn-outline-primary">
{% trans "Challenges" %}
</a>
<a href="#" class="btn btn-sm btn-outline-primary active">
{% trans "Manage" %}
</a>
</div>
</div>
</div>
<div class="row mb-3">
<div class='col-sm-5 col-xl-6' id="infos_div">
{% if can_add_achievement %}
<div class="row justify-content-center justify-content-md-end">
{# Family details column #}
<div class="col picture-col">
<div class="card bg-light mb-4 text-center">
<a id="profile_pic_link" href="#">
<img src="{% static "member/img/default_picture.png" %}" id="profile_pic" alt="" class="card-img-top d-none d-sm-block">
</a>
<div class="card-body text-center text-break p-2">
<span id="user_note"><i class="small">{% trans "Please select a family" %}</i></span>
</div>
</div>
</div>
{# Family selection column #}
<div class="col-xl" id="user_select_div">
<div class="card bg-light border-success mb-4">
<div class="card-header">
<p class="card-text font-weight-bold">
{% trans "Families" %}
</p>
</div>
<div class="card-body p-0" style="min-height:125px;">
<ul class="list-group list-group-flush" id="note_list"></ul>
</div>
{# User search with autocompletion #}
<div class="card-footer">
<input class="form-control mx-auto d-block mb-2" placeholder="{% trans "Name" %}" type="text" id="note" autofocus />
{% if user_family %}
<button class="btn btn-sm btn-secondary btn-block" id="select_my_family">
{% trans "Select my family" %} ({{ user_family.name }})
</button>
{% endif %}
</div>
</div>
</div>
{# Summary of challenges and validate button #}
<div class="col-xl-5" id="consos_list_div">
<div class="card bg-light border-info mb-4">
<div class="card-header">
<p class="card-text font-weight-bold">
{% trans "Challenges" %}
</p>
</div>
<div class="card-body p-0" style="min-height:125px;">
<ul class="list-group list-group-flush" id="consos_list"></ul>
</div>
<div class="card-footer text-center">
<span id="consume_all" class="btn btn-primary">
{% trans "Validate!" %}
</span>
</div>
</div>
</div>
</div>
{% endif %}
{# Create family/challenge buttons #}
{% if can_add_family or can_add_challenge %}
<div class="card bg-light border-success mb-4">
<h3 class="card-header font-weight-bold text-center">
{% trans "Create a family or challenge" %}
</h3>
<div class="card-body text-center">
{% if can_add_family %}
<a class="btn btn-sm btn-primary mx-2" href="{% url "family:family_create" %}">
{% trans "Add a family" %}
</a>
{% endif %}
{% if can_add_challenge %}
<a class="btn btn-sm btn-primary mx-2" href="{% url "family:challenge_create" %}">
{% trans "Add a challenge" %}
</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{# Buttons column #}
<div class="col">
{% if can_add_achievement %}
<div class="card bg-light border-primary text-center mb-4">
{# Tabs for list and search #}
<div class="card-header">
<ul class="nav nav-tabs nav-fill card-header-tabs">
<li class="nav-item">
<a class="nav-link font-weight-bold" data-toggle="tab" href="#list">
{% trans "List" %}
</a>
</li>
<li class="nav-item">
<a class="nav-link font-weight-bold" data-toggle="tab" href="#search">
{% trans "Search" %}
</a>
</li>
</ul>
</div>
{# Tabs content #}
<div class="card-body">
<div class="tab-content">
<div class="tab-pane" id="list">
<div class="d-inline-flex flex-wrap justify-content-center">
{% for challenge in all_challenges %}
<button class="btn btn-outline-dark rounded-0 flex-fill"
id="challenge{{ challenge.id }}" name="button" value="{{ challenge.name }}">
{{ challenge.name }} ({{ challenge.points }} {% trans "points" %})
</button>
{% endfor %}
</div>
</div>
<div class="tab-pane" id="search">
<input class="form-control mx-auto d-block mb-3" placeholder="{% trans "Search challenge..." %}" type="search" id="search-input"/>
<div class="d-inline-flex flex-wrap justify-content-center" id="search-results">
{% for challenge in all_challenges %}
<button class="btn btn-outline-dark rounded-0 flex-fill" hidden
id="search_challenge{{ challenge.id }}" name="button" value="{{ challenge.name }}">
{{ challenge.name }} ({{ challenge.points }} {% trans "points" %})
</button>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{# achievement history #}
{% if table.data %}
<div class="card">
<div class="card-header position-relative" id="historyListHeading">
<a class="stretched-link font-weight-bold"
href="{% url 'family:achievement_list' %}" >
{% trans "Recent achievements history" %}
</a>
</div>
<div id="history">
{% render_table table %}
</div>
</div>
<!-- Popup de validation -->
<div class="modal fade" id="validationModal" tabindex="-1" role="dialog" aria-labelledby="validationModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content border-success">
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="validationModalLabel">{% trans "Confirmation" %}</h5>
<button type="button" class="close text-white" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p><strong>{% trans "Are you sure you want to validate this challenge?" %}</strong></p>
<p>{% trans "To have your challenge officially validated, please send a message with:" %}</p>
<ul>
<li>{% trans "The name of the family" %}</li>
<li>{% trans "The name of the challenge" %}</li>
<li>{% trans "A photo or video as proof" %}</li>
</ul>
<p>
<strong>{% trans "Send it via WhatsApp to:" %}</strong>
{% if phone_numbers %}"
{% for num in phone_numbers %}
<a href="https://wa.me/{{ num }}" target="_blank">{{ num }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
{% endif %}
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-dismiss="modal">{% trans "OK" %}</button>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extrajavascript %}
<script type="text/javascript" src="{% static "family/js/achievements.js" %}"></script>
<script type="text/javascript">
{% for challenge in all_challenges %}
document.getElementById("challenge{{ challenge.id }}").addEventListener("click", function() {
addChallenge({{ challenge.id}}, "{{ challenge.name|escapejs }}", {{ challenge.points }});
});
{% endfor %}
{% for challenge in all_challenges %}
document.getElementById("search_challenge{{ challenge.id }}").addEventListener("click", function() {
addChallenge({{ challenge.id}}, "{{ challenge.name|escapejs }}", {{ challenge.points }});
});
{% endfor %}
</script>
<script>
document.getElementById("consume_all").addEventListener("click", function () {
$('#validationModal').modal('show');
});
$('#validationModal .btn-primary').on('click', function () {
consumeAll();
});
{% 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 %}
</script>
{% endblock %}

View File

@@ -1,118 +0,0 @@
{% extends "family/base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block profile_content %}
<div class="card bg-light">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body">
<div class="text-center">
<form method="post" enctype="multipart/form-data" id="formUpload">
{% csrf_token %}
{{ form |crispy }}
{% if user.note.display_image != "pic/default.png" %}
<input type="submit" class="btn btn-primary" value="{% trans "Remove" %}">
{% endif %}
</form>
</div>
<!-- MODAL TO CROP THE IMAGE -->
<div class="modal fade" id="modalCrop" data-backdrop="static">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body-wrapper" style="width: 500px; height: 500px; padding: 16px;">
<div class="modal-body" style="width: 100%; height: 100%; padding: 0">
<img src="" id="modal-image" style="display: block; max-width: 100%;">
</div>
</div>
<div class="modal-footer">
<div class="btn-group pull-left" role="group">
<button type="button" class="btn btn-default" id="js-zoom-in">
<span class="glyphicon glyphicon-zoom-in"></span>
</button>
<button type="button" class="btn btn-default js-zoom-out">
<span class="glyphicon glyphicon-zoom-out"></span>
</button>
</div>
<button type="button" class="btn btn-default" data-dismiss="modal">{% trans "Nevermind" %}</button>
<button type="button" class="btn btn-primary js-crop-and-upload">{% trans "Crop and upload" %}</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extracss %}
<link href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.6/cropper.min.css" rel="stylesheet">
{% endblock %}
{% block extrajavascript%}
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.6/cropper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery-cropper@1.0.1/dist/jquery-cropper.min.js"></script>
<script>
$(function () {
/* SCRIPT TO OPEN THE MODAL WITH THE PREVIEW */
$("#id_image").change(function (e) {
if (this.files && this.files[0]) {
// Check the image size
if (this.files[0].size > 2*1024*1024) {
alert("Ce fichier est trop volumineux.")
} else {
// Read the selected image file
var reader = new FileReader();
reader.onload = function (e) {
$("#modal-image").attr("src", e.target.result);
$("#modalCrop").modal("show");
}
reader.readAsDataURL(this.files[0]);
}
}
});
/* SCRIPTS TO HANDLE THE CROPPER BOX */
var $image = $("#modal-image");
var cropBoxData;
var canvasData;
$("#modalCrop").on("shown.bs.modal", function () {
$image.cropper({
viewMode: 1,
aspectRatio: 1 / 1,
minCropBoxWidth: 200,
minCropBoxHeight: 200,
ready: function () {
$image.cropper("setCanvasData", canvasData);
$image.cropper("setCropBoxData", cropBoxData);
}
});
}).on("hidden.bs.modal", function () {
cropBoxData = $image.cropper("getCropBoxData");
canvasData = $image.cropper("getCanvasData");
$image.cropper("destroy");
});
$(".js-zoom-in").click(function () {
$image.cropper("zoom", 0.1);
});
$(".js-zoom-out").click(function () {
$image.cropper("zoom", -0.1);
});
/* SCRIPT TO COLLECT THE DATA AND POST TO THE SERVER */
$(".js-crop-and-upload").click(function () {
var cropData = $image.cropper("getData");
$("#id_x").val(cropData["x"]);
$("#id_y").val(cropData["y"]);
$("#id_height").val(cropData["height"]);
$("#id_width").val(cropData["width"]);
$("#formUpload").submit();
});
});
</script>
{% endblock %}

View File

@@ -1,328 +0,0 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import os
from api.tests import TestAPI
from django.contrib.auth.models import User
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from rest_framework.test import APITestCase
from django.urls import reverse
from django.utils import timezone
from ..api.views import FamilyViewSet, FamilyMembershipViewSet, ChallengeViewSet, AchievementViewSet
from ..models import Family, FamilyMembership, Challenge, Achievement
class TestFamily(TestCase):
"""
Test family
"""
def setUp(self):
self.user = User.objects.create_superuser(
username='admintoto',
password='toto1234',
email='toto@example.com',
)
self.client.force_login(self.user)
sess = self.client.session
sess['permission_mask'] = 42
sess.save()
self.family = Family.objects.create(
name='Test family',
description='',
)
self.challenge = Challenge.objects.create(
name='Test challenge',
description='',
points=100,
)
self.achievement = Achievement.objects.create(
family=self.family,
challenge=self.challenge,
valid=False,
)
def test_family_list(self):
"""
Test display family list
"""
response = self.client.get(reverse("family:family_list"))
self.assertEqual(response.status_code, 200)
def test_family_create(self):
"""
Test create a family
"""
response = self.client.get(reverse("family:family_create"))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("family:family_create"), data={
"name": "Family toto",
"description": "A test family",
})
self.assertTrue(Family.objects.filter(name="Family toto").exists())
self.assertRedirects(response, reverse("family:manage"), 302, 200)
def test_family_detail(self):
"""
Test display the detail of a family
"""
response = self.client.get(reverse("family:family_detail", args=(self.family.pk,)))
self.assertEqual(response.status_code, 200)
def test_family_update(self):
"""
Test update a family
"""
response = self.client.get(reverse("family:family_update", args=(self.family.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("family:family_update", args=(self.family.pk,)), data=dict(
name="Toto family updated",
description="A larger description for the test family"
))
self.assertRedirects(response, self.family.get_absolute_url(), 302, 200)
self.assertTrue(Family.objects.filter(name="Toto family updated").exists())
def test_family_update_picture(self):
"""
Test update the picture of a family
"""
response = self.client.get(reverse("family:update_pic", args=(self.family.pk,)))
self.assertEqual(response.status_code, 200)
old_pic = self.family.display_image
with open("apps/family/static/family/img/default_picture.png", "rb") as f:
image = SimpleUploadedFile("image.png", f.read(), "image/png")
response = self.client.post(reverse("family:update_pic", args=(self.family.pk,)), dict(
image=image,
x=0,
y=0,
width=200,
height=200,
))
self.assertRedirects(response, self.family.get_absolute_url(), 302, 200)
self.family.refresh_from_db()
self.assertTrue(os.path.exists(self.family.display_image.path))
os.remove(self.family.display_image.path)
self.family.display_image = old_pic
self.family.save()
def test_family_add_member(self):
"""
Test add memberships to a family
"""
response = self.client.get(reverse("family:family_add_member", args=(self.family.pk,)))
self.assertEqual(response.status_code, 200)
user = User.objects.create(username="totototo")
user.profile.registration_valid = True
user.profile.email_confirmed = True
user.profile.save()
user.save()
response = self.client.post(reverse("family:family_add_member", args=(self.family.pk,)), data=dict(
user=user.pk,
))
self.assertRedirects(response, self.family.get_absolute_url(), 302, 200)
self.assertTrue(FamilyMembership.objects.filter(user=user, family=self.family, year=timezone.now().year).exists())
def test_challenge_list(self):
"""
Test display challenge list
"""
response = self.client.get(reverse('family:challenge_list'))
self.assertEqual(response.status_code, 200)
def test_challenge_create(self):
"""
Test create a challenge
"""
response = self.client.get(reverse("family:challenge_create"))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("family:challenge_create"), data={
"name": "Challenge for toto",
"description": "A test challenge",
"points": 50,
})
self.assertTrue(Challenge.objects.filter(name="Challenge for toto").exists())
self.assertRedirects(response, reverse("family:manage"), 302, 200)
def test_challenge_detail(self):
"""
Test display the detail of a challenge
"""
response = self.client.get(reverse("family:challenge_detail", args=(self.challenge.pk,)))
self.assertEqual(response.status_code, 200)
def test_challenge_update(self):
"""
Test update a challenge
"""
response = self.client.get(reverse("family:challenge_update", args=(self.challenge.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("family:challenge_update", args=(self.challenge.pk,)), data=dict(
name="Challenge updated",
description="Another description",
points=10,
))
self.assertRedirects(response, self.challenge.get_absolute_url(), 302, 200)
self.assertTrue(Challenge.objects.filter(name="Challenge updated").exists())
def test_render_manage_page(self):
"""
Test render manage page
"""
response = self.client.get(reverse("family:manage"))
self.assertEqual(response.status_code, 200)
def test_validate_achievement(self):
"""
Test validate an achievement
"""
old_family_score = self.family.score
response = self.client.get(reverse("family:achievement_validate", args=(self.achievement.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("family:achievement_validate", args=(self.achievement.pk,)))
self.assertRedirects(response, reverse("family:achievement_list"), 302, 200)
self.achievement.refresh_from_db()
self.assertIs(self.achievement.valid, True)
self.family.refresh_from_db()
self.assertEqual(self.family.score, old_family_score + self.achievement.challenge.points)
def test_delete_achievement(self):
"""
Test delete an achievement
"""
response = self.client.get(reverse("family:achievement_delete", args=(self.achievement.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.delete(reverse("family:achievement_delete", args=(self.achievement.pk,)))
self.assertRedirects(response, reverse("family:achievement_list"), 302, 200)
self.assertFalse(Achievement.objects.filter(pk=self.achievement.pk).exists())
class TestBatchAchievements(APITestCase):
def setUp(self):
self.user = User.objects.create_superuser(
username='admintoto',
password='toto1234',
email='toto@example.com',
)
self.client.force_login(self.user)
sess = self.client.session
sess['permission_mask'] = 42
sess.save()
self.families = [
Family.objects.create(name=f'Famille {i}', description='') for i in range(2)
]
self.challenges = [
Challenge.objects.create(name=f'Challenge {i}', description='', points=50) for i in range(3)
]
self.achievement = Achievement.objects.create(
family=self.families[0],
challenge=self.challenges[0],
valid=False,
)
self.url = reverse("family:api:batch_achievements")
def test_batch_achievement_creation(self):
family_ids = [f.id for f in self.families]
challenge_ids = [c.id for c in self.challenges]
response = self.client.post(
self.url,
data={
'families': family_ids,
'challenges': challenge_ids
},
format='json'
)
self.assertEqual(response.status_code, 201)
for result in response.data['results']:
if result['family'] == self.families[0].name and result['challenge'] == self.challenges[0].name:
self.assertEqual(result['status'], 'existed')
else:
self.assertEqual(result['status'], 'created')
expected_count = len(family_ids) * len(challenge_ids)
self.assertEqual(Achievement.objects.count(), expected_count)
# Check that correct couples family/challenge exist
for f in self.families:
for c in self.challenges:
self.assertTrue(
Achievement.objects.filter(family=f, challenge=c).exists()
)
class TestFamilyAPI(TestAPI):
def setUp(self):
super().setUp()
self.family = Family.objects.create(
name='Test family',
description='',
)
self.familymembership = FamilyMembership.objects.create(
user=self.user,
family=self.family,
)
self.challenge = Challenge.objects.create(
name='Test challenge',
description='',
points=100,
)
self.achievement = Achievement.objects.create(
family=self.family,
challenge=self.challenge,
valid=False,
)
def test_family_api(self):
"""
Load Family API page and test all filters and permissions
"""
self.check_viewset(FamilyViewSet, '/api/family/family/')
def test_familymembership_api(self):
"""
Load FamilyMembership API page and test all filters and permissions
"""
self.check_viewset(FamilyMembershipViewSet, '/api/family/familymembership/')
def test_challenge_api(self):
"""
Load Challenge API page and test all filters and permissions
"""
self.check_viewset(ChallengeViewSet, '/api/family/challenge/')
def test_achievement_api(self):
"""
Load Achievement API page and test all filters and permissions
"""
self.check_viewset(AchievementViewSet, '/api/family/achievement/')

View File

@@ -1,25 +0,0 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path, include
from . import views
app_name = 'family'
urlpatterns = [
path('list/', views.FamilyListView.as_view(), name="family_list"),
path('create/', views.FamilyCreateView.as_view(), name="family_create"),
path('<int:pk>/detail/', views.FamilyDetailView.as_view(), name="family_detail"),
path('<int:pk>/update/', views.FamilyUpdateView.as_view(), name="family_update"),
path('<int:pk>/update_pic/', views.FamilyPictureUpdateView.as_view(), name="update_pic"),
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/create/', views.ChallengeCreateView.as_view(), name="challenge_create"),
path('challenge/<int:pk>/detail/', views.ChallengeDetailView.as_view(), name="challenge_detail"),
path('challenge/<int:pk>/update/', views.ChallengeUpdateView.as_view(), name="challenge_update"),
path('manage/', views.FamilyManageView.as_view(), name="manage"),
path('achievement/list/', views.AchievementListView.as_view(), name="achievement_list"),
path('achievement/<int:pk>/validate/', views.AchievementValidateView.as_view(), name="achievement_validate"),
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

@@ -1,469 +0,0 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import date
from django.conf import settings
from django.shortcuts import redirect
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.views.generic import DetailView, UpdateView, ListView
from django.views.generic.edit import DeleteView, FormMixin
from django.views.generic.base import TemplateView
from django.utils.translation import gettext_lazy as _
from django_tables2 import SingleTableView, MultiTableMixin
from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from django.urls import reverse_lazy
from member.forms import ImageForm
import phonenumbers
from .models import Family, Challenge, FamilyMembership, User, Achievement
from .tables import FamilyTable, ChallengeTable, FamilyMembershipTable, AchievementTable, FamilyAchievementTable
from .forms import ChallengeForm, FamilyMembershipForm, FamilyForm
class FamilyCreateView(ProtectQuerysetMixin, ProtectedCreateView):
"""
Create family
"""
model = Family
extra_context = {"title": _('Create family')}
form_class = FamilyForm
def get_sample_object(self):
return Family(
name="",
description="Sample family",
score=0,
rank=0,
)
def get_success_url(self):
self.object.refresh_from_db()
return reverse_lazy("family:manage")
class FamilyListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
"""
List existing Families
"""
model = Family
table_class = FamilyTable
extra_context = {"title": _('Families list')}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
fake_family = Family(name="", description="")
fake_challenge = Challenge(name="", description="", points=0)
can_add_family = PermissionBackend.check_perm(self.request, "family.add_family", fake_family)
can_add_challenge = PermissionBackend.check_perm(self.request, "family.add_challenge", fake_challenge)
if Family.objects.exists() and Challenge.objects.exists():
fake_achievement = Achievement(family=Family.objects.first(), challenge=Challenge.objects.first(), valid=False)
can_add_achievement = PermissionBackend.check_perm(self.request, "family.add_achievement", fake_achievement)
else:
can_add_achievement = False
context["can_manage"] = can_add_family or can_add_challenge or can_add_achievement
return context
class FamilyDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
Display details of a family
"""
model = Family
context_object_name = "family"
extra_context = {"title": _('Family detail')}
def get_context_data(self, **kwargs):
"""
Add members list
"""
context = super().get_context_data(**kwargs)
family = self.object
# member list
family_member = FamilyMembership.objects.filter(
family=family,
year=date.today().year,
).filter(PermissionBackend.filter_queryset(self.request, FamilyMembership, "view"))\
.order_by("user__username")
family_member = family_member.distinct("user__username")\
if settings.DATABASES["default"]["ENGINE"] == 'django.db.backends.postgresql' else family_member
membership_table = FamilyMembershipTable(data=family_member, prefix="membership-")
membership_table.paginate(per_page=5, page=self.request.GET.get('membership-page', 1))
context['member_list'] = membership_table
# Check if the user has the right to create a membership, to display the button.
empty_membership = FamilyMembership(
family=family,
user=User.objects.first(),
year=date.today().year,
)
context["can_add_members"] = PermissionBackend()\
.has_perm(self.request.user, "family.add_membership", empty_membership)
# Défis réalisé par la famille
achievements = Achievement.objects.filter(family=family)
achievements_table = FamilyAchievementTable(data=achievements, prefix="achievement-")
achievements_table.paginate(per_page=5, page=self.request.GET.get('achievement-page', 1))
context["achievement_list"] = achievements_table
return context
class FamilyUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
"""
Update the information of a family.
"""
model = Family
context_object_name = "family"
form_class = FamilyForm
extra_context = {"title": _('Update family')}
def get_success_url(self):
return reverse_lazy('family:family_detail', kwargs={'pk': self.object.pk})
class FamilyPictureUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin, DetailView):
"""
Update profile picture of the family
"""
model = Family
extra_context = {"title": _("Update family picture")}
template_name = 'family/picture_update.html'
form_class = ImageForm
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['form'] = self.form_class(self.request.POST, self.request.FILES)
return context
def get_success_url(self):
"""Redirect to family page after upload"""
return reverse_lazy('family:family_detail', kwargs={'pk': self.object.pk})
def post(self, request, *args, **kwargs):
form = self.get_form()
self.object = self.get_object()
return self.form_valid(form) if form.is_valid() else self.form_invalid(form)
@transaction.atomic
def form_valid(self, form):
"""
Save the image
"""
image = form.cleaned_data['image']
if image is None:
image = "pic/default.png"
else:
# Rename as PNG or GIF
extension = image.name.split(".")[-1]
if extension == "gif":
image.name = "{}_pic.gif".format(self.object.pk)
else:
image.name = "{}_pic.png".format(self.object.pk)
# Save
self.object.display_image = image
self.object.save()
return super().form_valid(form)
class FamilyAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
"""
Add a membership to a family
"""
model = FamilyMembership
form_class = FamilyMembershipForm
template_name = 'family/add_member.html'
extra_context = {"title": _("Add a new member to the family")}
def get_sample_object(self):
if "family_pk" in self.kwargs:
family = Family.objects.get(pk=self.kwargs["family_pk"])
else:
family = FamilyMembership.objects.get(pk=self.kwargs["pk"]).family
return FamilyMembership(
user=self.request.user,
family=family,
year=date.today().year,
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
family = Family.objects.filter(PermissionBackend.filter_queryset(self.request, Family, "view"))\
.get(pk=self.kwargs['family_pk'])
context['family'] = family
return context
@transaction.atomic
def form_valid(self, form):
"""
Create family membership, check that everythinf is good
"""
family = Family.objects.filter(PermissionBackend.filter_queryset(self.request, Family, "view")) \
.get(pk=self.kwargs["family_pk"])
form.instance.family = family
return super().form_valid(form)
def get_success_url(self):
return reverse_lazy('family:family_detail', kwargs={'pk': self.object.family.id})
class ChallengeCreateView(ProtectQuerysetMixin, ProtectedCreateView):
"""
Create challenge
"""
model = Challenge
extra_context = {"title": _('Create challenge')}
form_class = ChallengeForm
def get_sample_object(self):
return Challenge(
name="",
description="Sample challenge",
points=0,
)
def get_success_url(self):
return reverse_lazy('family:manage')
class ChallengeListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
"""
List all challenges
"""
model = Challenge
table_class = ChallengeTable
extra_context = {"title": _('Challenges list')}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
fake_family = Family(name="", description="")
fake_challenge = Challenge(name="", description="", points=0)
can_add_family = PermissionBackend.check_perm(self.request, "family.add_family", fake_family)
can_add_challenge = PermissionBackend.check_perm(self.request, "family.add_challenge", fake_challenge)
if Family.objects.exists() and Challenge.objects.exists():
fake_achievement = Achievement(family=Family.objects.first(), challenge=Challenge.objects.first(), valid=False)
can_add_achievement = PermissionBackend.check_perm(self.request, "family.add_achievement", fake_achievement)
else:
can_add_achievement = False
context["can_manage"] = can_add_family or can_add_challenge or can_add_achievement
return context
class ChallengeDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
Display details of a challenge
"""
model = Challenge
context_object_name = "challenge"
extra_context = {"title": _('Details of:')}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
fields = ["name", "description", "points",]
fields = dict([(field, getattr(self.object, field)) for field in fields])
context["fields"] = [(
Challenge._meta.get_field(field).verbose_name.capitalize(),
value) for field, value in fields.items()]
context["obtained"] = self.object.obtained
context["update"] = PermissionBackend.check_perm(self.request, "family.change_challenge")
return context
class ChallengeUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
"""
Update the information of a challenge
"""
model = Challenge
context_object_name = "challenge"
extra_context = {"title": _('Update challenge')}
form_class = ChallengeForm
def get_success_url(self, **kwargs):
self.object.refresh_from_db()
return reverse_lazy('family:challenge_detail', kwargs={'pk': self.object.pk})
class FamilyManageView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
"""
Manage families and challenges
"""
model = Achievement
template_name = 'family/manage.html'
table_class = AchievementTable
extra_context = {'title': _('Manage families and challenges')}
def dispatch(self, request, *args, **kwargs):
# Check that the user is authenticated
if not request.user.is_authenticated:
return self.handle_no_permission()
perm = PermissionBackend.has_model_perm(self.request, Achievement(), "add")
perm = perm or PermissionBackend.has_model_perm(self.request, Challenge(), "add")
perm = perm or PermissionBackend.has_model_perm(self.request, Family(), "add")
if not perm:
raise PermissionDenied(_("You are not able to manage families and challenges."))
return super().dispatch(request, *args, **kwargs)
def get_queryset(self, **kwargs):
# retrieves only Transaction that user has the right to see.
return Achievement.objects.filter(
PermissionBackend.filter_queryset(self.request, Achievement, "view")
).order_by("-obtained_at").all()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['all_challenges'] = Challenge.objects.filter(
PermissionBackend.filter_queryset(self.request, Challenge, "view")
).order_by('name')
context["can_add_family"] = PermissionBackend.has_model_perm(self.request, Family(), "add")
context["can_add_challenge"] = PermissionBackend.has_model_perm(self.request, Challenge(), "add")
context["can_add_achievement"] = PermissionBackend.has_model_perm(self.request, Achievement(), "add")
# Get the user's family if they have one
try:
user_family_membership = FamilyMembership.objects.get(user=self.request.user)
context["user_family"] = user_family_membership.family
except FamilyMembership.DoesNotExist:
context["user_family"] = None
phone_numbers = [
u.profile.phone_number for u in User.objects.filter(
memberships__roles__id=35,
memberships__date_end__gte=date.today(),
profile__phone_number__isnull=False
).distinct()
]
formatted_phone_numbers = [phonenumbers.format_number(num, phonenumbers.PhoneNumberFormat.INTERNATIONAL) for num in phone_numbers if num]
context["phone_numbers"] = formatted_phone_numbers
return context
def get_table(self, **kwargs):
table = super().get_table(**kwargs)
table.exclude = ('delete', 'validate',)
table.orderable = False
return table
def get_table_data(self, **kwargs):
qs = super().get_queryset(**kwargs)
qs = qs.filter(PermissionBackend.filter_queryset(self.request, Achievement, "view"))
return qs
class AchievementListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView):
"""
List all achievements
"""
model = Achievement
tables = [AchievementTable, AchievementTable, ]
extra_context = {'title': _('Achievement list')}
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
if not PermissionBackend.has_model_perm(self.request, Achievement(), "change"):
raise PermissionDenied(_("You are not able to see the achievement validation interface."))
return super().dispatch(request, *args, **kwargs)
def get_tables(self, **kwargs):
tables = super().get_tables(**kwargs)
tables[0].prefix = 'invalid-'
tables[1].prefix = 'valid-'
tables[1].exclude = ('validate', 'delete',)
return tables
def get_tables_data(self):
table_valid = self.get_queryset().filter(valid=True)
table_invalid = self.get_queryset().filter(valid=False)
return [table_invalid, table_valid, ]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
tables = context['tables']
context['invalid'] = tables[0]
context['valid'] = tables[1]
return context
class AchievementValidateView(ProtectQuerysetMixin, LoginRequiredMixin, TemplateView):
"""
Validate an achievement obtained by a family
"""
template_name = 'family/achievement_confirm_validate.html'
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
fake_achievement = Achievement(
family=Family.objects.first(),
challenge=Challenge.objects.first(),
valid=False,
)
if not PermissionBackend.check_perm(self.request, "family.change_achievement_valid", fake_achievement):
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
def post(self, request, pk):
achievement = Achievement.objects.get(pk=pk)
achievement.valid = True
achievement.save()
return redirect(reverse_lazy('family:achievement_list'))
class AchievementDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView):
"""
Delete an Achievement
"""
model = Achievement
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
fake_achievement = Achievement(
family=Family.objects.first(),
challenge=Challenge.objects.first(),
valid=False,
)
if not PermissionBackend.check_perm(self.request, "family.change_achievement_valid", fake_achievement):
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
def get_success_url(self):
return reverse_lazy('family:achievement_list')

View File

@@ -3,7 +3,7 @@
from rest_framework import serializers
from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order, FoodTransaction
from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode
class AllergenSerializer(serializers.ModelSerializer):
@@ -21,13 +21,9 @@ class FoodSerializer(serializers.ModelSerializer):
REST API Serializer for Food.
The djangorestframework plugin will analyse the model `Food` and parse all fields in the API.
"""
# This fields is used for autocompleting food in ManageIngredientsView
# TODO Find a better way to do it
owner_name = serializers.CharField(source='owner.name', read_only=True)
class Meta:
model = Food
fields = ['name', 'owner', 'allergens', 'expiry_date', 'end_of_life', 'is_ready', 'order', 'owner_name']
fields = '__all__'
class BasicFoodSerializer(serializers.ModelSerializer):
@@ -58,43 +54,3 @@ class QRCodeSerializer(serializers.ModelSerializer):
class Meta:
model = QRCode
fields = '__all__'
class DishSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Dish.
The djangorestframework plugin will analyse the model `Dish` and parse all fields in the API.
"""
class Meta:
model = Dish
fields = '__all__'
class SupplementSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Supplement.
The djangorestframework plugin will analyse the model `Supplement` and parse all fields in the API.
"""
class Meta:
model = Supplement
fields = '__all__'
class OrderSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Order.
The djangorestframework plugin will analyse the model `Order` and parse all fields in the API.
"""
class Meta:
model = Order
fields = '__all__'
class FoodTransactionSerializer(serializers.ModelSerializer):
"""
REST API Serializer for FoodTransaction.
The djangorestframework plugin will analyse the model `FoodTransaction` and parse all fields in the API.
"""
class Meta:
model = FoodTransaction
fields = '__all__'

View File

@@ -1,8 +1,7 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from .views import AllergenViewSet, FoodViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet, \
DishViewSet, SupplementViewSet, OrderViewSet, FoodTransactionViewSet
from .views import AllergenViewSet, FoodViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet
def register_food_urls(router, path):
@@ -14,7 +13,3 @@ def register_food_urls(router, path):
router.register(path + '/basicfood', BasicFoodViewSet)
router.register(path + '/transformedfood', TransformedFoodViewSet)
router.register(path + '/qrcode', QRCodeViewSet)
router.register(path + '/dish', DishViewSet)
router.register(path + '/supplement', SupplementViewSet)
router.register(path + '/order', OrderViewSet)
router.register(path + '/foodtransaction', FoodTransactionViewSet)

View File

@@ -5,9 +5,8 @@ from api.viewsets import ReadProtectedModelViewSet
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter
from .serializers import AllergenSerializer, FoodSerializer, BasicFoodSerializer, TransformedFoodSerializer, QRCodeSerializer, \
DishSerializer, SupplementSerializer, OrderSerializer, FoodTransactionSerializer
from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order, FoodTransaction
from .serializers import AllergenSerializer, FoodSerializer, BasicFoodSerializer, TransformedFoodSerializer, QRCodeSerializer
from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode
class AllergenViewSet(ReadProtectedModelViewSet):
@@ -73,55 +72,3 @@ class QRCodeViewSet(ReadProtectedModelViewSet):
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['qr_code_number', ]
search_fields = ['$qr_code_number', ]
class DishViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Dish` objects, serialize it to JSON with the given serializer,
then render it on /api/food/dish/
"""
queryset = Dish.objects.order_by('id')
serializer_class = DishSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['main__name', 'activity', ]
search_fields = ['$main__name', '$activity', ]
class SupplementViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Supplement` objects, serialize it to JSON with the given serializer,
then render it on /api/food/supplement/
"""
queryset = Supplement.objects.order_by('id')
serializer_class = SupplementSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['food__name', 'dish__activity', ]
search_fields = ['$food__name', '$dish__activity', ]
class OrderViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Order` objects, serialize it to JSON with the given serializer,
then render it on /api/food/order/
"""
queryset = Order.objects.order_by('id')
serializer_class = OrderSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['user', 'activity', 'dish', 'supplements', 'number', ]
search_fields = ['$user', '$activity', '$dish', '$supplements', '$number', ]
class FoodTransactionViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `FoodTransaction` objects, serialize it to JSON with the given serializer,
then render it on /api/food/foodtransaction/
"""
queryset = FoodTransaction.objects.order_by('id')
serializer_class = FoodTransactionSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['order', ]
search_fields = ['$order', ]

View File

@@ -4,16 +4,15 @@
from random import shuffle
from bootstrap_datepicker_plus.widgets import DateTimePickerInput
from crispy_forms.helper import FormHelper
from django import forms
from django.forms.widgets import NumberInput
from django.utils.translation import gettext_lazy as _
from member.models import Club
from note_kfet.inputs import Autocomplete, AmountInput
from note_kfet.inputs import Autocomplete
from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend
from .models import Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order
from .models import Food, BasicFood, TransformedFood, QRCode
class QRCodeForms(forms.ModelForm):
@@ -146,7 +145,7 @@ class AddIngredientForms(forms.ModelForm):
polymorphic_ctype__model="transformedfood",
is_ready=False,
end_of_life='',
).filter(PermissionBackend.filter_queryset(get_current_request(), Food, "change")).exclude(pk=pk)
).filter(PermissionBackend.filter_queryset(get_current_request(), TransformedFood, "change")).exclude(pk=pk)
class Meta:
model = TransformedFood
@@ -167,7 +166,7 @@ class ManageIngredientsForm(forms.Form):
model=Food,
resetable=True,
attrs={"api_url": "/api/food/food",
"class": "autocomplete manageingredients-autocomplete"},
"class": "autocomplete"},
)
name.label = _('Name')
@@ -181,70 +180,8 @@ class ManageIngredientsForm(forms.Form):
)
qrcode.label = _('QR code number')
add_all_same_name = forms.BooleanField(
required=False,
label=_("Add all identical food")
)
ManageIngredientsFormSet = forms.formset_factory(
ManageIngredientsForm,
extra=1,
)
class DishForm(forms.ModelForm):
"""
Form to create a dish
"""
class Meta:
model = Dish
fields = ('main', 'price', 'available')
widgets = {
"price": AmountInput(),
}
class SupplementForm(forms.ModelForm):
"""
Form to create a dish
"""
class Meta:
model = Supplement
fields = '__all__'
widgets = {
"price": AmountInput(),
}
# The 2 following classes are copied from treasury app
# Add a subform per supplement in the dish form, and manage correctly the link between the dish and
# its supplements. The FormSet will search automatically the ForeignKey in the Supplement model.
SupplementFormSet = forms.inlineformset_factory(
Dish,
Supplement,
form=SupplementForm,
extra=1,
)
class SupplementFormSetHelper(FormHelper):
"""
Specify some template information for the supplement form
"""
def __init__(self, form=None):
super().__init__(form)
self.form_tag = False
self.form_method = 'POST'
self.form_class = 'form-inline'
self.template = 'bootstrap4/table_inline_formset.html'
class OrderForm(forms.ModelForm):
"""
Form to order food
"""
class Meta:
model = Order
exclude = ("activity", "number", "ordered_at", "served", "served_at")

View File

@@ -1,23 +0,0 @@
# Generated by Django 5.2.4 on 2025-08-30 00:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('food', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='food',
name='end_of_life',
field=models.CharField(blank=True, max_length=255, verbose_name='end of life'),
),
migrations.AlterField(
model_name='food',
name='order',
field=models.CharField(blank=True, max_length=255, verbose_name='order'),
),
]

View File

@@ -1,86 +0,0 @@
# Generated by Django 5.2.6 on 2025-10-30 22:46
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('activity', '0007_alter_guest_activity'),
('food', '0002_alter_food_end_of_life_alter_food_order'),
('note', '0007_alter_note_polymorphic_ctype_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Dish',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('price', models.PositiveIntegerField(verbose_name='price')),
('available', models.BooleanField(default=True, verbose_name='available')),
('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dishes', to='activity.activity', verbose_name='activity')),
('main', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='dishes_as_main', to='food.transformedfood', verbose_name='main food')),
],
options={
'verbose_name': 'Dish',
'verbose_name_plural': 'Dishes',
'unique_together': {('main', 'activity')},
},
),
migrations.CreateModel(
name='Order',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('request', models.TextField(blank=True, help_text='A specific request (to remove an ingredient for example)', verbose_name='request')),
('number', models.PositiveIntegerField(default=1, verbose_name='number')),
('ordered_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='order date')),
('served', models.BooleanField(default=False, verbose_name='served')),
('served_at', models.DateTimeField(blank=True, null=True, verbose_name='served date')),
('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='food_orders', to='activity.activity', verbose_name='activity')),
('dish', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='food.dish', verbose_name='dish')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='food_orders', to=settings.AUTH_USER_MODEL, verbose_name='user')),
],
options={
'verbose_name': 'Order',
'verbose_name_plural': 'Orders',
},
),
migrations.CreateModel(
name='FoodTransaction',
fields=[
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='note.transaction')),
('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transaction', to='food.order', verbose_name='order')),
],
options={
'verbose_name': 'food transaction',
'verbose_name_plural': 'food transactions',
},
bases=('note.transaction',),
),
migrations.CreateModel(
name='Supplement',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('price', models.PositiveIntegerField(verbose_name='price')),
('dish', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='supplements', to='food.dish', verbose_name='dish')),
('food', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='supplements', to='food.food', verbose_name='food')),
],
options={
'verbose_name': 'Supplement',
'verbose_name_plural': 'Supplements',
},
),
migrations.AddField(
model_name='order',
name='supplements',
field=models.ManyToManyField(blank=True, related_name='orders', to='food.supplement', verbose_name='supplements'),
),
migrations.AlterUniqueTogether(
name='order',
unique_together={('activity', 'number')},
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 5.2.6 on 2025-10-31 17:46
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('food', '0003_dish_order_foodtransaction_supplement_and_more'),
]
operations = [
migrations.AlterField(
model_name='foodtransaction',
name='order',
field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='transaction', to='food.order', verbose_name='order'),
),
]

View File

@@ -4,14 +4,10 @@
from datetime import timedelta
from django.db import models, transaction
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.contrib.auth.models import User
from django.utils.translation import gettext_lazy as _
from polymorphic.models import PolymorphicModel
from member.models import Club
from activity.models import Activity
from note.models import Transaction
class Allergen(models.Model):
@@ -256,7 +252,7 @@ class TransformedFood(Food):
self.allergens.set(self.allergens.union(child.allergens.all()))
if not (child.polymorphic_ctype.model == 'basicfood' and child.date_type == 'DDM'):
self.expiry_date = min(self.expiry_date, child.expiry_date)
return super().save(force_insert=False, force_update=force_update, using=using, update_fields=update_fields)
return super().save(force_insert, force_update, using, update_fields)
class Meta:
verbose_name = _('Transformed food')
@@ -288,196 +284,3 @@ class QRCode(models.Model):
def __str__(self):
return _('QR-code number') + ' ' + str(self.qr_code_number)
class Dish(models.Model):
"""
A dish is a food proposed during a meal
"""
main = models.ForeignKey(
TransformedFood,
on_delete=models.PROTECT,
related_name='dishes_as_main',
verbose_name=_('main food'),
)
price = models.PositiveIntegerField(
verbose_name=_('price')
)
activity = models.ForeignKey(
Activity,
on_delete=models.CASCADE,
related_name='dishes',
verbose_name=_('activity'),
)
available = models.BooleanField(
default=True,
verbose_name=_('available'),
)
class Meta:
verbose_name = _('Dish')
verbose_name_plural = _('Dishes')
unique_together = ('main', 'activity')
def __str__(self):
return self.main.name + ' (' + str(self.activity) + ')'
def save(self, *args, **kwargs):
"Check the type of activity"
if self.activity.activity_type.name != 'Perm bouffe':
raise ValidationError(_('(You cannot select this type of activity.'))
return super().save(*args, **kwargs)
class Supplement(models.Model):
"""
A supplement is a food added to a dish
"""
dish = models.ForeignKey(
Dish,
on_delete=models.CASCADE,
related_name='supplements',
verbose_name=_('dish'),
)
food = models.ForeignKey(
Food,
on_delete=models.PROTECT,
related_name='supplements',
verbose_name=_('food'),
)
price = models.PositiveIntegerField(
verbose_name=_('price')
)
class Meta:
verbose_name = _('Supplement')
verbose_name_plural = _('Supplements')
def __str__(self):
return _("Supplement {food} for {dish}").format(
food=str(self.food), dish=str(self.dish))
class Order(models.Model):
"""
An order is a dish ordered by a member during an activity
"""
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='food_orders',
verbose_name=_('user'),
)
activity = models.ForeignKey(
Activity,
on_delete=models.CASCADE,
related_name='food_orders',
verbose_name=_('activity'),
)
dish = models.ForeignKey(
Dish,
on_delete=models.CASCADE,
related_name='orders',
verbose_name=_('dish'),
)
supplements = models.ManyToManyField(
Supplement,
related_name='orders',
verbose_name=_('supplements'),
blank=True,
)
request = models.TextField(
blank=True,
verbose_name=_('request'),
help_text=_('A specific request (to remove an ingredient for example)')
)
number = models.PositiveIntegerField(
verbose_name=_('number'),
default=1,
)
ordered_at = models.DateTimeField(
default=timezone.now,
verbose_name=_('order date'),
)
served = models.BooleanField(
default=False,
verbose_name=_('served'),
)
served_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_('served date'),
)
class Meta:
verbose_name = _('Order')
verbose_name_plural = _('Orders')
unique_together = ('activity', 'number', )
@property
def amount(self):
return self.dish.price + sum(s.price for s in self.supplements.all())
def __str__(self):
return _("Order of {dish} by {user}").format(
dish=str(self.dish),
user=str(self.user))
def save(self, *args, **kwargs):
created = self.pk is None
if created:
last_order = Order.objects.filter(activity=self.activity).last()
if last_order is None:
self.number = 1
else:
self.number = last_order.number + 1
super().save(*args, **kwargs)
transaction = FoodTransaction(
order=self,
source=self.user.note,
destination=self.activity.organizer.note,
amount=self.amount,
quantity=1,
)
transaction.save()
else:
old_object = Order.objects.get(pk=self.pk)
if not old_object.served and self.served:
self.served_at = timezone.now()
self.transaction.save()
super().save(*args, **kwargs)
class FoodTransaction(Transaction):
"""
Special type of :model:`note.Transaction` associated to a :model:`food.Order`.
"""
order = models.OneToOneField(
Order,
on_delete=models.PROTECT,
related_name='transaction',
verbose_name=_('order')
)
class Meta:
verbose_name = _("food transaction")
verbose_name_plural = _("food transactions")
def save(self, *args, **kwargs):
self.valid = self.order.served
super().save(*args, **kwargs)

View File

@@ -1,45 +0,0 @@
/**
* On click of "delete", delete the order
* @param button_id:Integer Order id to remove
* @param table_id: Id of the table to reload
*/
function delete_button (button_id, table_id) {
$.ajax({
url: '/api/food/order/' + button_id + '/',
method: 'DELETE',
headers: { 'X-CSRFTOKEN': CSRF_TOKEN }
}).done(function () {
$('#' + table_id).load(location.pathname + ' #' + table_id + ' > *')
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON, 10000)
})
}
/**
* On click of "Serve", mark the order as served
* @param button_id: Order id
* @param table_id: Id of the table to reload
*/
function serve_button(button_id, table_id, current_state) {
const new_state = !current_state;
$.ajax({
url: '/api/food/order/' + button_id + '/',
method: 'PATCH',
headers: { 'X-CSRFTOKEN': CSRF_TOKEN },
contentType: 'application/json',
data: JSON.stringify({
served: new_state
})
})
.done(function () {
if (current_state) {
$('table').load(location.pathname + ' table')
}
else {
$('#' + table_id).load(location.pathname + ' #' + table_id + ' > *');
}
})
.fail(function (xhr) {
errMsg(xhr.responseJSON, 10000);
});
}

View File

@@ -2,116 +2,20 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from note_kfet.middlewares import get_current_request
from note.templatetags.pretty_money import pretty_money
from permission.backends import PermissionBackend
from .models import Food, Dish, Order
from .models import Food
class FoodTable(tables.Table):
"""
List all foods.
"""
qr_code_numbers = tables.Column(empty_values=(), verbose_name=_("QR Codes"), orderable=False)
date = tables.Column(empty_values=(), verbose_name=_("Arrival/creation date"), orderable=False)
def render_date(self, record):
if record.__class__.__name__ == "BasicFood":
return record.arrival_date.strftime("%d/%m/%Y %H:%M")
elif record.__class__.__name__ == "TransformedFood":
return record.creation_date.strftime("%d/%m/%Y %H:%M")
else:
return "--"
def render_qr_code_numbers(self, record):
return ", ".join(str(q.qr_code_number) for q in record.QR_code.all())
class Meta:
model = Food
template_name = 'django_tables2/bootstrap4.html'
fields = ('name', 'owner', 'qr_code_numbers', 'allergens', 'date', 'expiry_date')
fields = ('name', 'owner', 'allergens', 'expiry_date')
row_attrs = {
'class': 'table-row',
'data-href': lambda record: 'detail/' + str(record.pk),
'style': 'cursor:pointer',
}
class DishTable(tables.Table):
"""
List dishes
"""
supplements = tables.Column(empty_values=(), verbose_name=_('Available supplements'), orderable=False)
def render_supplements(self, record):
return ", ".join(str(q.food) for q in record.supplements.all())
def render_price(self, value):
return pretty_money(value)
class Meta:
model = Dish
template_name = 'django_tables2/bootstrap4.html'
fields = ('main', 'supplements', 'price', 'available')
row_attrs = {
'class': 'table-row',
'data-href': lambda record: str(record.pk),
'style': 'cursor:pointer',
}
DELETE_TEMPLATE = """
<button id="{{ record.pk }}"
class="btn btn-danger btn-sm"
onclick="delete_button(this.id, 'orders_table_{{ table.prefix }}')">
{{ delete_trans }}
</button>
"""
SERVE_TEMPLATE = """
<button id="{{ record.pk }}"
class="btn btn-sm {% if record.served %}btn-secondary{% else %}btn-success{% endif %}"
onclick="serve_button(this.id, 'orders_table_{{ table.prefix }}', {{ record.served|yesno:'true,false' }})">
{% if record.served %}
{{ record.served_at|date:"d/m/Y H:i" }}
{% else %}""" + _('Serve') + """
{% endif %}
</button>
"""
class OrderTable(tables.Table):
"""
Lis all orders.
"""
delete = tables.TemplateColumn(
template_code=DELETE_TEMPLATE,
extra_context={"delete_trans": _('Delete')},
orderable=False,
attrs={'td': {'class': lambda record: 'col-sm-1' + (
' d-none' if not PermissionBackend.check_perm(
get_current_request(), "food.delete_order",
record) else '')}}, verbose_name=_("Delete"), )
serve = tables.TemplateColumn(
template_code=SERVE_TEMPLATE,
extra_context={"serve_trans": _('Serve')},
orderable=False,
attrs={'td': {'class': lambda record: 'col-sm-1' + (
' d-none' if not PermissionBackend.check_perm(
get_current_request(), "food.change_order_saved",
record) else '')}}, verbose_name=_("Serve"), )
class Meta:
model = Order
template_name = 'django_tables2/bootstrap4.html'
fields = ('number', 'ordered_at', 'user', 'dish', 'supplements', 'request', 'serve', 'delete')
order_by = ('ordered_at', )
row_attrs = {
'class': 'table-row',
'style': 'cursor:pointer',
}

View File

@@ -1,25 +0,0 @@
{% 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 "Delete dish" %}</h4>
</div>
<div class="card-body">
<div class="alert alert-warning">
{% blocktrans %}Are you sure you want to delete this dish? 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 'food:dish_detail' activity_pk=object.activity.pk pk=object.pk%}">{% trans "Return to dish detail" %}</a>
<button class="btn btn-danger" type="submit">{% trans "Delete" %}</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -1,44 +0,0 @@
{% 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 pretty_money %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }} {{ food.name }}
</h3>
<div class="card-body">
<ul>
<li> {% trans "Associated food" %} :
<a href="{% url "food:transformedfood_view" pk=food.pk %}">
{{ food.name }}
</a>
</li>
<li> {% trans "Sell price" %} : {{ dish.price|pretty_money }}</li>
<li> {% trans "Available" %} : {{ dish.available|yesno }}</li>
<li> {% trans "Possible supplements" %} :
{% for supp in supplements %}
<a href="{% url "food:food_view" pk=supp.food.pk %}">{{ supp.food.name }} ({{ supp.price|pretty_money }})</a>{% if not forloop.last %},{% endif %}
{% endfor %}
</li>
</ul>
{% if update %}
<a class="btn btn-sm btn-secondary" href="{% url "food:dish_update" activity_pk=dish.activity.pk pk=dish.pk %}">
{% trans "Update" %}
</a>
{% endif %}
<a class="btn btn-sm btn-primary" href="{% url "food:dish_list" activity_pk=dish.activity.pk %}">
{% trans "Return to dish list" %}
</a>
{% if delete %}
<a class="btn btn-sm btn-danger" href="{% url "food:dish_delete" activity_pk=dish.activity.pk pk=dish.pk %}">
{% trans "Delete" %}
</a>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -1,94 +0,0 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
<form method="post" action="">
{% csrf_token %}
<div class="card-body">
{% crispy form %}
</div>
<h3 class="card-header text-center">
{% trans "Add supplements (optional)" %}
</h3>
{{ formset.management_form }}
<table class="table table-condensed table-striped">
{% for form in formset %}
{% if forloop.first %}
<thead>
<tr>
<th>{{ form.food.label }}<span class="asteriskField">*</span></th>
<th>{{ form.price.label }}<span class="asteriskField">*</span></th>
</tr>
</thead>
<tbody id="form_body">
{% endif %}
<tr class="row-formset">
<td>{{ form.food }}</td>
<td>{{ form.price }}</td>
{# These fields are hidden but handled by the formset to link the id and the invoice id #}
{{ form.dish }}
{{ form.id }}
</tr>
{% endfor %}
</tbody>
</table>
{# Display buttons to add and remove supplements #}
<div class="card-body">
<div class="btn-group btn-block" role="group">
<button type="button" id="add_more" class="btn btn-success">{% trans "Add supplement" %}</button>
<button type="button" id="remove_one" class="btn btn-danger">{% trans "Remove supplement" %}</button>
</div>
<button type="submit" class="btn btn-block btn-primary">{% trans "Submit" %}</button>
</div>
</form>
</div>
{# Hidden div that store an empty supplement form, to be copied into new forms #}
<div id="empty_form" style="display: none;">
<table class='no_error'>
<tbody id="for_real">
<tr class="row-formset">
<td>{{ formset.empty_form.food }}</td>
<td>{{ formset.empty_form.price }} </td>
{{ formset.empty_form.dish }}
{{ formset.empty_form.id }}
</tr>
</tbody>
</table>
</div>
{% endblock %}
{% block extrajavascript %}
<script>
/* script that handles add and remove lines */
IDS = {};
$("#id_supplements-TOTAL_FORMS").val($(".row-formset").length - 1);
$('#add_more').click(function () {
let form_idx = $('#id_supplements-TOTAL_FORMS').val();
$('#form_body').append($('#for_real').html().replace(/__prefix__/g, form_idx));
$('#id_supplements-TOTAL_FORMS').val(parseInt(form_idx) + 1);
$('#id_supplements-' + parseInt(form_idx) + '-id').val(IDS[parseInt(form_idx)]);
});
$('#remove_one').click(function () {
let form_idx = $('#id_supplements-TOTAL_FORMS').val();
if (form_idx > 0) {
IDS[parseInt(form_idx) - 1] = $('#id_supplements-' + (parseInt(form_idx) - 1) + '-id').val();
$('#form_body tr:last-child').remove();
$('#id_supplements-TOTAL_FORMS').val(parseInt(form_idx) - 1);
}
});
</script>
{% endblock %}

View File

@@ -1,33 +0,0 @@
{% 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 %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }} {{activity.name}}
</h3>
{% render_table table %}
<div class="card-footer">
{% if can_add_dish %}
<a class="btn btn-sm btn-success" href="{% url 'food:dish_create' activity_pk=activity.pk %}">{% trans "New dish" %}</a>
{% endif %}
<a class="btn btn-sm btn-secondary" href="{% url 'activity:activity_detail' pk=activity.pk %}">{% trans "Activity page" %}</a>
<a class="btn btn-sm btn-primary" href="{% url "food:food_list" %}">
{% trans "Return to the food list" %}
</a>
</div>
</div>
{% endblock %}
{% block extrajavascript %}
<script type="text/javascript">
$(".table-row").click(function () {
window.document.location = $(this).data("href");
});
</script>
{% endblock %}

View File

@@ -12,21 +12,18 @@ SPDX-License-Identifier: GPL-3.0-or-later
</h3>
<div class="card-body">
<ul>
{% if QR_code %}
<li> {{QR_code}} </li>
{% endif %}
{% for field, value in fields %}
<li> {{ field }} : {{ value }}</li>
{% endfor %}
{% if meals %}
<li> {% trans "Contained in" %} :
<li> {% trans "Contained in" %} :
{% for meal in meals %}
<a href="{% url "food:transformedfood_view" pk=meal.pk %}">{{ meal.name }}</a>{% if not forloop.last %},{% endif %}
<a href="{% url "food:transformedfood_view" pk=meal.pk %}">{{ meal.name }}</a>{% if not forloop.last %},{% endif %}
{% endfor %}
</li>
{% endif %}
{% if foods %}
<li> {% trans "Contain" %} :
<li> {% trans "Contain" %} :
{% for food in foods %}
<a href="{% url "food:food_view" pk=food.pk %}">{{ food.name }}</a>{% if not forloop.last %},{% endif %}
{% endfor %}
@@ -34,23 +31,23 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endif %}
</ul>
{% if update %}
<a class="btn btn-sm btn-secondary" href="{% url "food:food_update" pk=food.pk %}">
{% trans "Update" %}
</a>
<a class="btn btn-sm btn-secondary" href="{% url "food:food_update" pk=food.pk %}">
{% trans "Update" %}
</a>
{% endif %}
{% if add_ingredient %}
<a class="btn btn-sm btn-primary" href="{% url "food:add_ingredient" pk=food.pk %}">
{% trans "Add to a meal" %}
</a>
<a class="btn btn-sm btn-primary" href="{% url "food:add_ingredient" pk=food.pk %}">
{% trans "Add to a meal" %}
</a>
{% endif %}
{% if manage_ingredients %}
<a class="btn btn-sm btn-secondary" href="{% url "food:manage_ingredients" pk=food.pk %}">
{% trans "Manage ingredients" %}
</a>
{% trans "Manage ingredients" %}
</a>
{% endif %}
<a class="btn btn-sm btn-primary" href="{% url "food:food_list" %}">
{% trans "Return to the food list" %}
</a>
<a class="btn btn-sm btn-primary" href="{% url "food:food_list" %}">
{% trans "Return to the food list" %}
</a>
</div>
</div>
{% endblock %}
{% endblock %}

View File

@@ -7,76 +7,19 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% load i18n %}
{% block content %}
<div class="card bg-light">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body">
<style>
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type=number] {
appearance: textfield;
padding: 6px;
border: 1px solid #ccc;
border-radius: 4px;
width: 100px;
}
</style>
<div class="d-flex align-items-center" style="max-width: 300px;">
<form method="get" action="{% url 'food:redirect_view' %}" class="d-flex w-100">
<input type="number" name="slug" placeholder="QR-code" required class="form-control form-control-sm" style="max-width: 120px;">
<button type="submit" class="btn btn-sm btn-primary">{% trans "View food" %}</button>
</form>
</div>
</div>
<div class="card-body">
<div class="form-check">
<label for="stock_only" class="form-check-label">
<input id="stock_only" name="stock_only" type="checkbox" class="checkboxinput form-check-input" checked>
{% trans "Filter with only food in stock" %}
</label>
</div>
<input id="searchbar" type="text" class="form-control"
placeholder="{% trans "Search by attribute such as name..." %}">
</div>
{% block extra_inside_card %}
{% endblock %}
<div id="dynamic-table">
{% if table.data %}
{% render_table table %}
{% else %}
<div class="card-body">
<div class="alert alert-warning">
{% trans "There is no results." %}
</div>
</div>
{% endif %}
</div>
</div>
{{ block.super }}
<br>
<div class="card bg-light mb-3">
<h3 class="card-header text-center">
{% trans "Meal served" %}
</h3>
{% if can_add_meal %}
<div class="card-footer">
{% if can_add_meal %}
<a class="btn btn-sm btn-primary" href="{% url 'food:transformedfood_create' %}">
{% trans "New meal" %}
</a>
{% endif %}
{% for activity in open_activities %}
<a class="btn btn-sm btn-secondary" href="{% url 'food:dish_list' activity_pk=activity.pk %}">
{% trans "View" %} {{ activity.name }}
</a>
{% endfor %}
</div>
{% endif %}
{% if served.data %}
{% render_table served %}
{% else %}
@@ -125,39 +68,4 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endfor %}
{% endif %}
</div>
<script type="text/javascript">
let old_pattern = null;
let searchbar_obj = $("#searchbar");
let stock_only_obj = $("#stock_only");
function reloadTable() {
let pattern = searchbar_obj.val();
$("#dynamic-table").load(location.pathname + "?search=" + pattern.replace(" ", "%20") + (
stock_only_obj.is(':checked') ? "" : "&stock=1") + " #dynamic-table");
}
searchbar_obj.keyup(reloadTable);
stock_only_obj.change(reloadTable);
$(document).on("click", ".table-row", function () {
window.document.location = $(this).data("href");
});
</script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('goButton').addEventListener('click', function(event) {
event.preventDefault();
const slug = document.getElementById('slugInput').value;
if (slug && !isNaN(slug)) {
window.location.href = `/food/${slug}/`;
} else {
alert("Veuillez entrer un nombre valide.");
}
});
});
</script>
{% endblock %}
{% endblock %}

View File

@@ -1,41 +0,0 @@
{% 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 %}
<!-- Colonne de plats -->
<div style="display: flex; flex-wrap: wrap; gap: 1rem; margin-bottom: 2rem;">
{% for food, quantity in orders.items %}
<div class="card bg-white mb-3" style="flex: 1 1 calc(33.333% - 1rem); border: 1px solid #ccc; padding: 1rem; border-radius: 0.5rem; box-sizing: border-box;">
<h3 class="card-header text-center">
<strong>{{ food }}</strong><br>
</h3>
<h1 class="card-body text-center">
{{ quantity }}</h1>
</div>
{% endfor %}
</div>
<!-- Colonne de la table -->
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{% trans "Special orders" %}
</h3>
{% if table.data %}
{% render_table table %}
{% else %}
<div class="card-body">
<div class="alert alert-warning">
{% trans "There are no special orders." %}
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -22,7 +22,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
<th>{{ form.name.label }}</th>
<th>{{ form.qrcode.label }}</th>
<th>{{ form.fully_used.label }}</th>
<th>{{ form.add_all_same_name.label }}</th>
</tr>
</thead>
<tbody id="form_body">
@@ -35,7 +34,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
<td>{{ form.name }}</td>
<td>{{ form.qrcode }}</td>
<td>{{ form.fully_used }}</td>
<td>{{ form.add_all_same_name }}</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -1,25 +0,0 @@
{% 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 "Delete order" %}</h4>
</div>
<div class="card-body">
<div class="alert alert-warning">
{% blocktrans %}Are you sure you want to delete this order? 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 'food:order_list' activity_pk=object.activity.pk%}">{% trans "Return to order list" %}</a>
<button class="btn btn-danger" type="submit">{% trans "Delete" %}</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -1,21 +0,0 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body" id="form">
<form method="post">
{% csrf_token %}
{{ form | crispy }}
<button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -1,30 +0,0 @@
{% 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 static i18n %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
<a class="btn btn-primary" href="{% url 'food:served_order_list' activity_pk=activity.pk %}">{% trans "View served orders" %}</a>
{% for table in tables %}
<div class="card bg-light mb-3" id="orders_table_{{ table.prefix }}">
<h3 class="card-header text-center">
{% trans "Orders of " %} {{ table.prefix }}
</h3>
{% if table.data %}
{% render_table table %}
{% endif %}
</div>
{% endfor %}
</div>
{% endblock %}
{% block extrajavascript %}
<script src="{% static "food/js/order.js" %}"></script>
{% endblock%}

View File

@@ -1,21 +0,0 @@
{% 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 static i18n %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }} {{activity.name}}
</h3>
<a class="btn btn-primary" href="{% url 'food:order_list' activity_pk=activity.pk %}">{% trans "View unserved orders" %}</a>
{% render_table table %}
</div>
{% endblock %}
{% block extrajavascript %}
<script src="{% static "food/js/order.js" %}"></script>
{% endblock%}

View File

@@ -1,17 +0,0 @@
{% 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 pretty_money %}
{% block content %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }} {{ supplement.name }}
</h3>
<div class="card-body">
</div>
</div>
{% endblock %}

View File

@@ -6,12 +6,9 @@ from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from activity.models import Activity, ActivityType
from member.models import Club
from ..api.views import AllergenViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet, \
DishViewSet, SupplementViewSet, OrderViewSet, FoodTransactionViewSet
from ..models import Allergen, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order, FoodTransaction
from ..api.views import AllergenViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet
from ..models import Allergen, BasicFood, TransformedFood, QRCode
class TestFood(TestCase):
@@ -56,293 +53,73 @@ class TestFood(TestCase):
food_container=self.basicfood,
)
def test_food_list(self):
"""
Display food list
"""
response = self.client.get(reverse('food:food_list'))
self.assertEqual(response.status_code, 200)
def test_food_list(self):
"""
Display food list
"""
response = self.client.get(reverse('food:food_list'))
self.assertEqual(response.status_code, 200)
def test_qrcode_create(self):
"""
Display QRCode creation
"""
response = self.client.get(reverse('food:qrcode_create', kwargs={"slug": 2}))
self.assertEqual(response.status_code, 200)
def test_qrcode_create(self):
"""
Display QRCode creation
"""
response = self.client.get(reverse('food:qrcode_create'))
self.assertEqual(response.status_code, 200)
def test_basicfood_create(self):
"""
Display BasicFood creation
"""
response = self.client.get(reverse('food:basicfood_create', kwargs={"slug": 2}))
self.assertEqual(response.status_code, 200)
def test_basicfood_create(self):
"""
Display BasicFood creation
"""
response = self.client.get(reverse('food:basicfood_create'))
self.assertEqual(response.status_code, 200)
def test_transformedfood_create(self):
"""
Display TransformedFood creation
"""
response = self.client.get(reverse('food:transformedfood_create'))
self.assertEqual(response.status_code, 200)
def test_transformedfood_create(self):
"""
Display TransformedFood creation
"""
response = self.client.get(reverse('food:transformedfood_create'))
self.assertEqual(response.status_code, 200)
def test_food_update(self):
"""
Display Food update
"""
response = self.client.get(reverse('food:food_update', args=(self.basicfood.pk,)))
self.assertEqual(response.status_code, 200)
def test_food_create(self):
"""
Display Food update
"""
response = self.client.get(reverse('food:food_update'))
self.assertEqual(response.status_code, 200)
def test_food_view(self):
"""
Display Food detail
"""
response = self.client.get(reverse('food:food_view', args=(self.basicfood.pk,)))
self.assertEqual(response.status_code, 302)
def test_food_view(self):
"""
Display Food detail
"""
response = self.client.get(reverse('food:food_view'))
self.assertEqual(response.status_code, 302)
def test_basicfood_view(self):
"""
Display BasicFood detail
"""
response = self.client.get(reverse('food:basicfood_view', args=(self.basicfood.pk,)))
self.assertEqual(response.status_code, 200)
def test_basicfood_view(self):
"""
Display BasicFood detail
"""
response = self.client.get(reverse('food:basicfood_view'))
self.assertEqual(response.status_code, 200)
def test_transformedfood_view(self):
"""
Display TransformedFood detail
"""
response = self.client.get(reverse('food:transformedfood_view', args=(self.transformedfood.pk,)))
self.assertEqual(response.status_code, 200)
def test_transformedfood_view(self):
"""
Display TransformedFood detail
"""
response = self.client.get(reverse('food:transformedfood_view'))
self.assertEqual(response.status_code, 200)
def test_add_ingredient(self):
"""
Display add ingredient view
"""
response = self.client.get(reverse('food:add_ingredient', args=(self.transformedfood.pk,)))
self.assertEqual(response.status_code, 200)
class TestFoodOrder(TestCase):
"""
Test Food Order
"""
fixtures = ('initial',)
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.basicfood = BasicFood.objects.create(
id=1,
name='basicfood',
owner=Club.objects.get(name="BDE"),
expiry_date=timezone.now(),
is_ready=True,
date_type='DLC',
)
self.transformedfood = TransformedFood.objects.create(
id=2,
name='transformedfood',
owner=Club.objects.get(name="BDE"),
expiry_date=timezone.now(),
is_ready=True,
)
self.second_transformedfood = TransformedFood.objects.create(
id=3,
name='second transformedfood',
owner=Club.objects.get(name="BDE"),
expiry_date=timezone.now(),
is_ready=True,
)
self.third_transformedfood = TransformedFood.objects.create(
id=4,
name='third transformedfood',
owner=Club.objects.get(name="BDE"),
expiry_date=timezone.now(),
is_ready=True,
)
self.activity = Activity.objects.create(
activity_type=ActivityType.objects.get(name="Perm bouffe"),
organizer=Club.objects.get(name="BDE"),
creater=self.user,
attendees_club_id=1,
date_start=timezone.now(),
date_end=timezone.now(),
name="Test activity",
open=True,
valid=True,
)
self.dish = Dish.objects.create(
main=self.transformedfood,
price=500,
activity=self.activity,
available=True,
)
self.second_dish = Dish.objects.create(
main=self.second_transformedfood,
price=1000,
activity=self.activity,
available=True,
)
self.supplement = Supplement.objects.create(
dish=self.dish,
food=self.basicfood,
price=100,
)
self.order = Order.objects.create(
user=self.user,
activity=self.activity,
dish=self.dish,
)
self.order.supplements.add(self.supplement)
self.order.save()
def test_dish_list(self):
"""
Try to display dish list
"""
response = self.client.get(reverse("food:dish_list", kwargs={"activity_pk": self.activity.pk}))
self.assertEqual(response.status_code, 200)
def test_dish_create(self):
"""
Try to create a dish
"""
response = self.client.get(reverse("food:dish_create", kwargs={"activity_pk": self.activity.pk}))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("food:dish_create", kwargs={"activity_pk": self.activity.pk}), data={
"main": self.third_transformedfood.pk,
"price": 4,
"activity": self.activity.pk,
"supplements-0-food": self.basicfood.pk,
"supplements-0-price": 0.5,
"supplements-TOTAL_FORMS": 1,
"supplements-INITIAL_FORMS": 0,
"supplements-MIN_NUM_FORMS": 0,
"supplements-MAX_NUM_FORMS": 1000,
})
self.assertRedirects(response, reverse("food:dish_list", kwargs={"activity_pk": self.activity.pk}), 302, 200)
self.assertTrue(Dish.objects.filter(main=self.third_transformedfood).exists())
self.assertTrue(Supplement.objects.filter(food=self.basicfood, price=50).exists())
def test_dish_update(self):
"""
Try to update a dish
"""
response = self.client.get(reverse("food:dish_update", kwargs={"activity_pk": self.activity.pk, "pk": self.dish.pk}))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("food:dish_update", kwargs={"activity_pk": self.activity.pk, "pk": self.dish.pk}), data={
"price": 6,
"supplements-0-food": self.basicfood.pk,
"supplements-0-price": 1,
"supplements-1-food": self.basicfood.pk,
"supplements-1-price": 0.25,
"supplements-TOTAL_FORMS": 2,
"supplements-INITIAL_FORMS": 0,
"supplements-MIN_NUM_FORMS": 0,
"supplements-MAX_NUM_FORMS": 1000,
})
self.assertRedirects(response, reverse("food:dish_detail", kwargs={"activity_pk": self.activity.pk, "pk": self.dish.pk}), 302, 200)
self.dish.refresh_from_db()
self.assertTrue(Dish.objects.filter(main=self.transformedfood, price=600).exists())
self.assertTrue(Supplement.objects.filter(dish=self.dish, food=self.basicfood, price=25).exists())
def test_dish_detail(self):
"""
Try to display dish details
"""
response = self.client.get(reverse("food:dish_detail", kwargs={"activity_pk": self.activity.pk, "pk": self.dish.pk}))
self.assertEqual(response.status_code, 200)
def test_dish_delete(self):
"""
Try to delete a dish
"""
response = self.client.get(reverse("food:dish_delete", kwargs={"activity_pk": self.activity.pk, "pk": self.dish.pk}))
self.assertEqual(response.status_code, 200)
# Cannot delete already ordered Dish
response = self.client.delete(reverse("food:dish_delete", kwargs={"activity_pk": self.activity.pk, "pk": self.dish.pk}))
self.assertEqual(response.status_code, 403)
self.assertTrue(Dish.objects.filter(pk=self.dish.pk).exists())
# Can delete a Dish with no order
response = self.client.delete(reverse("food:dish_delete", kwargs={"activity_pk": self.activity.pk, "pk": self.second_dish.pk}))
self.assertRedirects(response, reverse("food:dish_list", kwargs={"activity_pk": self.activity.pk}))
self.assertFalse(Dish.objects.filter(pk=self.second_dish.pk).exists())
def test_order_food(self):
"""
Try to make an order
"""
response = self.client.get(reverse("food:order_create", kwargs={"activity_pk": self.activity.pk}))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("food:order_create", kwargs={"activity_pk": self.activity.pk}), data=dict(
user=self.user.pk,
activity=self.activity.pk,
dish=self.second_dish.pk,
supplements=self.supplement.pk
))
self.assertRedirects(response, reverse("food:food_list"))
self.assertTrue(Order.objects.filter(user=self.user, dish=self.second_dish, activity=self.activity).exists())
def test_order_list(self):
"""
Try to display order list
"""
response = self.client.get(reverse("food:order_list", kwargs={"activity_pk": self.activity.pk}))
self.assertEqual(response.status_code, 200)
def test_served_order_list(self):
"""
Try to display served order list
"""
response = self.client.get(reverse("food:served_order_list", kwargs={"activity_pk": self.activity.pk}))
self.assertEqual(response.status_code, 200)
def test_serve_order(self):
"""
Try to serve an order, then to unserve it
"""
response = self.client.patch("/api/food/order/" + str(self.order.pk) + "/", data=dict(
served=True
), content_type="application/json")
self.assertEqual(response.status_code, 200)
self.order.refresh_from_db()
self.assertTrue(Order.objects.filter(dish=self.dish, user=self.user, served=True).exists())
self.assertIsNotNone(self.order.served_at)
self.assertTrue(FoodTransaction.objects.filter(order=self.order, valid=True).exists())
response = self.client.patch("/api/food/order/" + str(self.order.pk) + "/", data=dict(
served=False
), content_type="application/json")
self.assertEqual(response.status_code, 200)
self.assertTrue(Order.objects.filter(dish=self.dish, user=self.user, served=False).exists())
self.assertTrue(FoodTransaction.objects.filter(order=self.order, valid=False).exists())
def test_add_ingredient(self):
"""
Display add ingredient view
"""
response = self.client.get(reverse('food:add_ingredient'))
self.assertEqual(response.status_code, 200)
class TestFoodAPI(TestAPI):
def setUp(self) -> None:
super().setUp()
super().setUP()
self.allergen = Allergen.objects.create(
name='name',
@@ -368,84 +145,26 @@ class TestFoodAPI(TestAPI):
food_container=self.basicfood,
)
self.activity = Activity.objects.create(
activity_type=ActivityType.objects.get(name="Perm bouffe"),
organizer=Club.objects.get(name="BDE"),
creater=self.user,
attendees_club_id=1,
date_start=timezone.now(),
date_end=timezone.now(),
name="Test activity",
open=True,
valid=True,
)
def test_allergen_api(self):
"""
Load Allergen API page and test all filters and permissions
"""
self.check_viewset(AllergenViewSet, '/api/food/allergen/')
self.dish = Dish.objects.create(
main=self.transformedfood,
price=500,
activity=self.activity,
available=True,
)
def test_basicfood_api(self):
"""
Load BasicFood API page and test all filters and permissions
"""
self.check_viewset(BasicFoodViewSet, '/api/food/basicfood/')
self.supplement = Supplement.objects.create(
dish=self.dish,
food=self.basicfood,
price=100,
)
self.order = Order.objects.create(
user=self.user,
activity=self.activity,
dish=self.dish,
)
self.order.supplements.add(self.supplement)
self.order.save()
def test_allergen_api(self):
"""
Load Allergen API page and test all filters and permissions
"""
self.check_viewset(AllergenViewSet, '/api/food/allergen/')
def test_basicfood_api(self):
"""
Load BasicFood API page and test all filters and permissions
"""
self.check_viewset(BasicFoodViewSet, '/api/food/basicfood/')
# TODO Repair and detabulate this test
def test_transformedfood_api(self):
"""
Load TransformedFood API page and test all filters and permissions
"""
self.check_viewset(TransformedFoodViewSet, '/api/food/transformedfood/')
def test_qrcode_api(self):
"""
Load QRCode API page and test all filters and permissions
"""
self.check_viewset(QRCodeViewSet, '/api/food/qrcode/')
def test_dish_api(self):
"""
Load Dish API page and test all filters and permissions
"""
self.check_viewset(DishViewSet, '/api/food/dish/')
def test_supplement_api(self):
"""
Load Supplement API page and test all filters and permissions
"""
self.check_viewset(SupplementViewSet, '/api/food/supplement/')
def test_order_api(self):
"""
Load Order API page and test all filters and permissions
"""
self.check_viewset(OrderViewSet, '/api/food/order/')
def test_foodtransaction_api(self):
"""
Load FoodTransaction API page and test all filters and permissions
"""
self.check_viewset(FoodTransactionViewSet, '/api/food/foodtransaction/')
def test_qrcode_api(self):
"""
Load QRCode API page and test all filters and permissions
"""
self.check_viewset(QRCodeViewSet, '/api/food/qrcode/')

View File

@@ -18,15 +18,4 @@ urlpatterns = [
path('detail/basic/<int:pk>', views.BasicFoodDetailView.as_view(), name='basicfood_view'),
path('detail/transformed/<int:pk>', views.TransformedFoodDetailView.as_view(), name='transformedfood_view'),
path('add/ingredient/<int:pk>', views.AddIngredientView.as_view(), name='add_ingredient'),
path('redirect/', views.QRCodeRedirectView.as_view(), name='redirect_view'),
# TODO not always store activity_pk in url
path('activity/<int:activity_pk>/dishes/add/', views.DishCreateView.as_view(), name='dish_create'),
path('activity/<int:activity_pk>/dishes/', views.DishListView.as_view(), name='dish_list'),
path('activity/<int:activity_pk>/dishes/<int:pk>/', views.DishDetailView.as_view(), name='dish_detail'),
path('activity/<int:activity_pk>/dishes/<int:pk>/update/', views.DishUpdateView.as_view(), name='dish_update'),
path('activity/<int:activity_pk>/dishes/<int:pk>/delete/', views.DishDeleteView.as_view(), name='dish_delete'),
path('activity/<int:activity_pk>/order/', views.OrderCreateView.as_view(), name='order_create'),
path('activity/<int:activity_pk>/orders/', views.OrderListView.as_view(), name='order_list'),
path('activity/<int:activity_pk>/orders/served', views.ServedOrderListView.as_view(), name='served_order_list'),
path('activity/<int:activity_pk>/kitchen/', views.KitchenView.as_view(), name='kitchen'),
]

View File

@@ -4,30 +4,24 @@
from datetime import timedelta
from api.viewsets import is_regex
from crispy_forms.helper import FormHelper
from django_tables2.views import SingleTableView, MultiTableMixin
from django.core.exceptions import PermissionDenied
from django_tables2.views import MultiTableMixin
from django.db import transaction
from django.db.models import Q, Count
from django.db.models import Q
from django.http import HttpResponseRedirect, Http404
from django.views.generic import DetailView, UpdateView, CreateView
from django.views.generic.list import ListView
from django.views.generic.base import RedirectView
from django.views.generic.edit import DeleteView
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from member.models import Club, Membership
from activity.models import Activity
from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin, ProtectedCreateView, LoginRequiredMixin
from .models import Food, BasicFood, TransformedFood, QRCode, Order, Dish, Supplement
from .models import Food, BasicFood, TransformedFood, QRCode
from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \
ManageIngredientsForm, ManageIngredientsFormSet, AddIngredientForms, \
BasicFoodUpdateForms, TransformedFoodUpdateForms, \
DishForm, SupplementFormSet, SupplementFormSetHelper, OrderForm
from .tables import FoodTable, DishTable, OrderTable
BasicFoodUpdateForms, TransformedFoodUpdateForms
from .tables import FoodTable
from .utils import pretty_duration
@@ -69,14 +63,9 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li
valid_regex = is_regex(pattern)
suffix = '__iregex' if valid_regex else '__istartswith'
prefix = '^' if valid_regex else ''
qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern})
| Q(**{f'owner__name{suffix}': prefix + pattern})
| Q(**{f'owner__note__alias__name{suffix}': prefix + pattern}))
qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern}))
else:
qs = qs.none()
if "stock" not in self.request.GET or not self.request.GET["stock"] == '1':
qs = qs.filter(end_of_life='')
search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Food, 'view'))
# table open
open_table = self.get_queryset().order_by('expiry_date').filter(
@@ -104,7 +93,6 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li
owner=club, end_of_life='').filter(
PermissionBackend.filter_queryset(self.request, Food, 'view')
))
return [search_table, open_table, served_table] + club_table
def get_context_data(self, **kwargs):
@@ -117,9 +105,6 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li
context['club_tables'] = tables[3:]
context['can_add_meal'] = PermissionBackend.check_perm(self.request, 'food.transformedfood_add')
context["open_activities"] = Activity.objects.filter(activity_type__name="Perm bouffe", open=True)
return context
@@ -183,8 +168,7 @@ class BasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
template_name = "food/food_update.html"
def get_sample_object(self):
# We choose a club which may work or BDE else
food = BasicFood(
return BasicFood(
name="",
owner_id=1,
expiry_date=timezone.now(),
@@ -193,14 +177,6 @@ class BasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
date_type='DLC',
)
for membership in self.request.user.memberships.all():
club_id = membership.club.id
food.owner_id = club_id
if PermissionBackend.check_perm(self.request, "food.add_basicfood", food):
return food
return food
@transaction.atomic
def form_valid(self, form):
if QRCode.objects.filter(qr_code_number=self.kwargs['slug']).count() > 0:
@@ -231,7 +207,7 @@ class BasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
copy = self.request.GET.get('copy', None)
if copy is not None:
food = BasicFood.objects.get(pk=copy)
print(context['form'].fields)
for field in context['form'].fields:
if field == 'allergens':
context['form'].fields[field].initial = getattr(food, field).all()
@@ -251,22 +227,13 @@ class TransformedFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
template_name = "food/food_update.html"
def get_sample_object(self):
# We choose a club which may work or BDE else
food = TransformedFood(
return TransformedFood(
name="",
owner_id=1,
expiry_date=timezone.now(),
is_ready=True,
)
for membership in self.request.user.memberships.all():
club_id = membership.club.id
food.owner_id = club_id
if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food):
return food
return food
@transaction.atomic
def form_valid(self, form):
form.instance.expiry_date = timezone.now() + timedelta(days=3)
@@ -278,10 +245,10 @@ class TransformedFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
return reverse_lazy('food:transformedfood_view', kwargs={"pk": self.object.pk})
MAX_FORMS = 100
MAX_FORMS = 10
class ManageIngredientsView(LoginRequiredMixin, UpdateView):
class ManageIngredientsView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
"""
A view to manage ingredient for a transformed food
"""
@@ -307,27 +274,11 @@ class ManageIngredientsView(LoginRequiredMixin, UpdateView):
elif form.data[prefix + 'name'] != '':
ingredient = Food.objects.get(pk=form.data[prefix + 'name'])
if form.data.get(prefix + 'add_all_same_name') == 'on':
ingredients = Food.objects.filter(name=ingredient.name, owner=ingredient.owner, end_of_life='')
for ingredient in ingredients:
self.object.ingredients.add(ingredient)
if form.data.get(prefix + 'fully_used') == 'on':
ingredient.end_of_life = _('Fully used in {meal}'.format(meal=self.object.name))
ingredient.save()
else:
self.object.ingredients.add(ingredient)
if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on':
ingredient.end_of_life = _('Fully used in {meal}'.format(
meal=self.object.name))
ingredient.save()
# We recalculate new expiry date and allergens
self.object.expiry_date = self.object.creation_date + self.object.shelf_life
self.object.allergens.clear()
for ingredient in self.object.ingredients.iterator():
if not (ingredient.polymorphic_ctype.model == 'basicfood' and ingredient.date_type == 'DDM'):
self.object.expiry_date = min(self.object.expiry_date, ingredient.expiry_date)
self.object.allergens.set(self.object.allergens.union(ingredient.allergens.all()))
self.object.ingredients.add(ingredient)
if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on':
ingredient.end_of_life = _('Fully used in {meal}'.format(
meal=self.object.name))
ingredient.save()
self.object.save(old_ingredients=old_ingredients, old_allergens=old_allergens)
return HttpResponseRedirect(self.get_success_url())
@@ -476,8 +427,6 @@ class FoodDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
context["fields"] = [(
Food._meta.get_field(field).verbose_name.capitalize(),
value) for field, value in fields.items()]
if self.object.QR_code.exists():
context["QR_code"] = self.object.QR_code.first()
context["meals"] = self.object.transformed_ingredient_inv.all()
context["update"] = PermissionBackend.check_perm(self.request, "food.change_food")
context["add_ingredient"] = (self.object.end_of_life == '' and PermissionBackend.check_perm(self.request, "food.change_transformedfood"))
@@ -531,339 +480,3 @@ class TransformedFoodDetailView(FoodDetailView):
if Food.objects.filter(pk=kwargs['pk']).count() == 1:
kwargs['stop_redirect'] = (Food.objects.get(pk=kwargs['pk']).polymorphic_ctype.model == 'transformedfood')
return super().get(*args, **kwargs)
class QRCodeRedirectView(RedirectView):
"""
Redirects to the QR code creation page from Food List
"""
def get_redirect_url(self, *args, **kwargs):
slug = self.request.GET.get('slug')
if slug:
return reverse_lazy('food:qrcode_create', kwargs={'slug': slug})
return reverse_lazy('food:list')
class DishCreateView(ProtectQuerysetMixin, ProtectedCreateView):
"""
Create a dish
"""
model = Dish
form_class = DishForm
extra_context = {"title": _('Create dish')}
def get_sample_object(self):
activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
sample_food = TransformedFood(
name="Sample food",
owner=activity.organizer,
expiry_date=timezone.now() + timedelta(days=7),
is_ready=True,
)
sample_dish = Dish(
main=sample_food,
price=100,
activity=activity,
)
return sample_dish
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
form = context['form']
form.helper = FormHelper()
# Remove form tag on the generation of the form in the template (already present on the template)
form.helper.form_tag = False
# The formset handles the set of the supplements
form_set = SupplementFormSet(instance=form.instance)
context['formset'] = form_set
context['helper'] = SupplementFormSetHelper()
return context
def get_form(self, form_class=None):
form = super().get_form(form_class)
if "available" in form.fields:
del form.fields["available"]
return form
@transaction.atomic
def form_valid(self, form):
activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
form.instance.activity = activity
ret = super().form_valid(form)
# For each supplement, we save it
formset = SupplementFormSet(self.request.POST, instance=form.instance)
if formset.is_valid():
for f in formset:
# We don't save the product if the price is not entered, ie. if the line is empty
if f.is_valid() and f.instance.price:
f.save()
f.instance.save()
else:
f.instance = None
return ret
def get_success_url(self):
return reverse_lazy('food:dish_list', kwargs={"activity_pk": self.kwargs["activity_pk"]})
class DishListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
"""
List dishes for this activity
"""
model = Dish
table_class = DishTable
extra_context = {"title": _('Dishes served during')}
template_name = 'food/dish_list.html'
def get_queryset(self):
return super().get_queryset().filter(activity__pk=self.kwargs["activity_pk"])
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
context["activity"] = activity
context["can_add_dish"] = PermissionBackend.check_perm(self.request, 'food.dish_add')
return context
class DishDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
View a dish for this activity
"""
model = Dish
extra_context = {"title": _('Details of:')}
context_oject_name = "dish"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["food"] = self.object.main
context["supplements"] = self.object.supplements.all()
context["update"] = PermissionBackend.check_perm(self.request, "food.change_dish")
context["delete"] = not Order.objects.filter(dish=self.get_object()).exists() and PermissionBackend.check_perm(self.request, "food.delete_dish")
return context
class DishUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
"""
A view to update a dish
"""
model = Dish
form_class = DishForm
extra_context = {"title": _("Update a dish")}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
form = context['form']
form.helper = FormHelper()
# Remove form tag on the generation of the form in the template (already present on the template)
form.helper.form_tag = False
# The formset handles the set of the supplements
form_set = SupplementFormSet(instance=form.instance)
context['formset'] = form_set
context['helper'] = SupplementFormSetHelper()
return context
def get_form(self, form_class=None):
form = super().get_form(form_class)
if 'main' in form.fields:
del form.fields["main"]
return form
@transaction.atomic
def form_valid(self, form):
activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
form.instance.activity = activity
ret = super().form_valid(form)
# For each supplement, we save it
formset = SupplementFormSet(self.request.POST, instance=form.instance)
saved = []
if formset.is_valid():
for f in formset:
# We don't save the product if the price is not entered, ie. if the line is empty
if f.is_valid() and f.instance.price:
f.save()
f.instance.save()
saved.append(f.instance.pk)
else:
f.instance = None
# Remove old supplements that weren't given in the form
Supplement.objects.filter(~Q(pk__in=saved), dish=form.instance).delete()
return ret
def get_success_url(self):
return reverse_lazy('food:dish_detail', kwargs={"activity_pk": self.kwargs["activity_pk"], "pk": self.kwargs["pk"]})
class DishDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView):
"""
Delete a dish with no order yet
"""
model = Dish
extra_context = {"title": _('Delete dish')}
def delete(self, request, *args, **kwargs):
if Order.objects.filter(dish=self.get_object()).exists():
raise PermissionDenied(_("This dish cannot be deleted because it has already been ordered"))
return super().delete(request, *args, **kwargs)
def get_success_url(self):
return reverse_lazy('food:dish_list', kwargs={"activity_pk": self.kwargs["activity_pk"]})
class OrderCreateView(ProtectQuerysetMixin, ProtectedCreateView):
"""
Order a meal
"""
model = Order
form_class = OrderForm
extra_context = {"title": _('Order food')}
def get_sample_object(self):
activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
sample_order = Order(
user=self.request.user,
activity=activity,
dish=Dish.objects.filter(activity=activity).last(),
)
return sample_order
def get_form(self):
form = super().get_form()
form.fields["user"].initial = self.request.user
form.fields["user"].disabled = True
return form
def form_valid(self, form):
activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
form.instance.activity = activity
return super().form_valid(form)
def get_success_url(self):
return reverse_lazy('food:food_list')
class OrderListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView):
"""
List existing Families
"""
model = Order
table_class = OrderTable
extra_context = {"title": _('Order list')}
paginate_by = 10
def get_queryset(self, **kwargs):
activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
return Order.objects.filter(activity=activity).order_by('number')
def get_tables(self):
activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
dishes = Dish.objects.filter(activity=activity)
tables = [OrderTable] * dishes.count()
self.tables = tables
tables = super().get_tables()
for i in range(dishes.count()):
tables[i].prefix = dishes[i].main.name
return tables
def get_tables_data(self):
activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
dishes = Dish.objects.filter(activity=activity)
tables = []
for dish in dishes:
tables.append(self.get_queryset().order_by('ordered_at').filter(
dish=dish, served=False).filter(
PermissionBackend.filter_queryset(self.request, Order, 'view')
))
return tables
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["activity"] = Activity.objects.get(pk=self.kwargs["activity_pk"])
return context
class ServedOrderListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
"""
View served orders
"""
model = Order
template_name = 'food/served_order_list.html'
table_class = OrderTable
def get_queryset(self):
return super().get_queryset().filter(activity__pk=self.kwargs["activity_pk"], served=True).order_by('-served_at')
def get_table(self, **kwargs):
table = super().get_table(**kwargs)
table.columns.hide("delete")
return table
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["activity"] = Activity.objects.get(pk=self.kwargs["activity_pk"])
return context
class KitchenView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
"""
The view to display useful information for the kitchen
"""
model = Order
table_class = OrderTable
template_name = 'food/kitchen.html'
extra_context = {'title': _('Kitchen')}
def get_queryset(self):
return super().get_queryset().filter(~Q(supplements__isnull=True, request=''), activity__pk=self.kwargs["activity_pk"])
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
orders_count = Order.objects.values('dish__main__name').annotate(quantity=Count('id'))
context["orders"] = {o['dish__main__name']: o['quantity'] for o in orders_count}
return context
def get_table(self, **kwargs):
table = super().get_table(**kwargs)
hide = ["ordered_at", "serve", "delete"]
for field in hide:
table.columns.hide(field)
return table

View File

@@ -6,7 +6,7 @@ from django.conf import settings
from django.db.models.signals import post_save
from django.utils.translation import gettext_lazy as _
from .signals import save_user_profile, update_wei_registration_fee_on_membership_creation, update_wei_registration_fee_on_club_change
from .signals import save_user_profile
class MemberConfig(AppConfig):
@@ -17,16 +17,7 @@ class MemberConfig(AppConfig):
"""
Define app internal signals to interact with other apps
"""
from .models import Membership, Club
post_save.connect(
save_user_profile,
sender=settings.AUTH_USER_MODEL,
)
post_save.connect(
update_wei_registration_fee_on_membership_creation,
sender=Membership
)
post_save.connect(
update_wei_registration_fee_on_club_change,
sender=Club
)

View File

@@ -10,7 +10,6 @@ from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.models import User
from django.db import transaction
from django.forms import CheckboxSelectMultiple
from phonenumber_field.formfields import PhoneNumberField
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from note.models import NoteSpecial, Alias
@@ -46,11 +45,6 @@ class ProfileForm(forms.ModelForm):
A form for the extras field provided by the :model:`member.Profile` model.
"""
# Remove widget=forms.HiddenInput() if you want to use report frequency.
phone_number = PhoneNumberField(
widget=forms.TextInput(attrs={"type": "tel", "class": "form-control"}),
required=False
)
report_frequency = forms.IntegerField(required=False, initial=0, label=_("Report frequency"))
last_report = forms.DateTimeField(required=False, disabled=True, label=_("Last report date"))
@@ -78,12 +72,7 @@ class ProfileForm(forms.ModelForm):
if not self.instance.section or (("department" in self.changed_data
or "promotion" in self.changed_data) and "section" not in self.changed_data):
self.instance.section = self.instance.section_generated
instance = super().save(commit=False)
if instance.phone_number:
instance.phone_number = instance.phone_number.as_e164
if commit:
instance.save()
return instance
return super().save(commit)
class Meta:
model = Profile

View File

@@ -1,46 +0,0 @@
from django.db import migrations
def create_bda(apps, schema_editor):
"""
The club BDA is now pre-injected.
"""
Club = apps.get_model("member", "club")
NoteClub = apps.get_model("note", "noteclub")
Alias = apps.get_model("note", "alias")
ContentType = apps.get_model('contenttypes', 'ContentType')
polymorphic_ctype_id = ContentType.objects.get_for_model(NoteClub).id
Club.objects.get_or_create(
id=10,
name="BDA",
email="bda.ensparissaclay@gmail.com",
require_memberships=True,
membership_fee_paid=750,
membership_fee_unpaid=750,
membership_duration=396,
membership_start="2024-08-01",
membership_end="2025-09-30",
)
NoteClub.objects.get_or_create(
id=1937,
club_id=10,
polymorphic_ctype_id=polymorphic_ctype_id,
)
Alias.objects.get_or_create(
id=1937,
note_id=1937,
name="BDA",
normalized_name="bda",
)
class Migration(migrations.Migration):
dependencies = [
('member', '0013_auto_20240801_1436'),
]
operations = [
migrations.RunPython(create_bda),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.2.4 on 2025-08-02 13:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('member', '0014_create_bda'),
]
operations = [
migrations.AlterField(
model_name='profile',
name='promotion',
field=models.PositiveSmallIntegerField(default=2025, help_text='Year of entry to the school (None if not ENS student)', null=True, verbose_name='promotion'),
),
]

View File

@@ -438,6 +438,8 @@ class Membership(models.Model):
)
if hasattr(self, '_force_renew_parent') and self._force_renew_parent:
new_membership._force_renew_parent = True
if hasattr(self, '_soge') and self._soge:
new_membership._soge = True
if hasattr(self, '_force_save') and self._force_save:
new_membership._force_save = True
new_membership.save()
@@ -456,6 +458,8 @@ class Membership(models.Model):
# Renew the previous membership of the parent club
parent_membership = parent_membership.first()
parent_membership._force_renew_parent = True
if hasattr(self, '_soge'):
parent_membership._soge = True
if hasattr(self, '_force_save'):
parent_membership._force_save = True
parent_membership.renew()
@@ -467,6 +471,8 @@ class Membership(models.Model):
date_start=self.date_start,
)
parent_membership._force_renew_parent = True
if hasattr(self, '_soge'):
parent_membership._soge = True
if hasattr(self, '_force_save'):
parent_membership._force_save = True
parent_membership.save()

View File

@@ -1,8 +1,6 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
def save_user_profile(instance, created, raw, **_kwargs):
"""
@@ -15,27 +13,3 @@ def save_user_profile(instance, created, raw, **_kwargs):
instance.profile.email_confirmed = True
instance.profile.registration_valid = True
instance.profile.save()
def update_wei_registration_fee_on_membership_creation(sender, instance, created, **kwargs):
if not hasattr(instance, "_no_signal") and 'wei' in settings.INSTALLED_APPS and created:
from wei.models import WEIRegistration
if instance.club.id == 1 or instance.club.id == 2:
registrations = WEIRegistration.objects.filter(
user=instance.user,
wei__year=instance.date_start.year,
)
for r in registrations:
r._force_save = True
r.save()
def update_wei_registration_fee_on_club_change(sender, instance, **kwargs):
if not hasattr(instance, "_no_signal") and 'wei' in settings.INSTALLED_APPS and (instance.id == 1 or instance.id == 2):
from wei.models import WEIRegistration
registrations = WEIRegistration.objects.filter(
wei__year=instance.membership_start.year,
)
for r in registrations:
r._force_save = True
r.save()

View File

@@ -92,20 +92,6 @@ class MembershipTable(tables.Table):
}
)
user_email = tables.Column(
verbose_name="Email",
accessor="user.email",
orderable=False,
visible=False,
)
user_full_name = tables.Column(
verbose_name=_("Full name"),
accessor="user.get_full_name",
orderable=False,
visible=False,
)
def render_user(self, value):
# If the user has the right, link the displayed user with the page of its detail.
s = value.username
@@ -163,16 +149,6 @@ class MembershipTable(tables.Table):
+ "'>" + s + "</a>")
return s
def value_user(self, record):
return record.user.username if record.user else ""
def value_club(self, record):
return record.club.name if record.club else ""
def value_roles(self, record):
roles = record.roles.all()
return ", ".join(str(role) for role in roles)
class Meta:
attrs = {
'class': 'table table-condensed table-striped',

View File

@@ -36,13 +36,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% trans "There is no membership found with this pattern." %}
</div>
{% endif %}
<div class="card-footer text-center">
<button class="btn btn-block btn-primary mb-3" onclick="window.location.href='?_export=csv'">
{% trans "Export to CSV" %}
</button>
</div>
</div>
</div>
{% endblock %}

View File

@@ -7,19 +7,6 @@
<dt class="col-xl-6">{% trans 'username'|capfirst %}</dt>
<dd class="col-xl-6">{{ user_object.username }}</dd>
{% if family_app_installed %}
<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>
{% endif %}
{% if user_object.pk == user.pk %}
<dt class="col-xl-6">{% trans 'password'|capfirst %}</dt>
<dd class="col-xl-6">

View File

@@ -10,7 +10,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{{ title }}
</h3>
<div class="card-body">
<form method="post" id="profile-form">
<form method="post">
{% csrf_token %}
{{ form | crispy }}
{{ profile_form | crispy }}
@@ -20,46 +20,4 @@ SPDX-License-Identifier: GPL-3.0-or-later
</form>
</div>
</div>
{% endblock %}
{% block extrajavascript %}
<!-- intl-tel-input CSS/JS -->
<script>
(() => {
const input = document.querySelector("input[name='phone_number']");
const form = document.querySelector("#profile-form");
if (!input || !form || input.type === "hidden" || input.disabled || input.readOnly) {
return;
}
const iti = window.intlTelInput(input, {
initialCountry: "auto",
nationalMode: false,
autoPlaceholder: "off",
geoIpLookup: callback => {
fetch("https://ipapi.co/json")
.then(res => res.json())
.then(data => callback(data.country_code))
.catch(() => callback("fr"));
},
loadUtils: () => import("https://cdn.jsdelivr.net/npm/intl-tel-input@25.5.2/build/js/utils.js"),
});
form.addEventListener("submit", function(e){
if (!input.value.trim()) {
return;
}
const number = iti.getNumber(intlTelInput.utils.numberFormat.E164);
if (number) {
input.value = number;
form.submit();
} else {
e.preventDefault();
input.focus();
}
});
})();
</script>
{% endblock %}

View File

@@ -44,7 +44,7 @@ class TemplateLoggedInTests(TestCase):
self.assertRedirects(response, settings.LOGIN_REDIRECT_URL, 302, 302)
def test_logout(self):
response = self.client.post(reverse("logout"))
response = self.client.get(reverse("logout"))
self.assertEqual(response.status_code, 200)
def test_admin_index(self):

View File

@@ -17,7 +17,6 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, UpdateView, TemplateView
from django.views.generic.edit import FormMixin
from django_tables2.views import MultiTableMixin, SingleTableMixin, SingleTableView
from django_tables2.export.views import ExportMixin
from rest_framework.authtoken.models import Token
from api.viewsets import is_regex
from note.models import Alias, NoteClub, NoteUser, Trust
@@ -27,7 +26,6 @@ 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, \
@@ -208,10 +206,6 @@ 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)
if 'family' in settings.INSTALLED_APPS:
context["family_app_installed"] = True
families = Family.objects.filter(memberships__user=user).distinct()
context["families"] = families
return context
@@ -951,12 +945,11 @@ class ClubManageRolesView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
return reverse_lazy('member:user_detail', kwargs={'pk': self.object.user.id})
class ClubMembersListView(ExportMixin, ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
model = Membership
table_class = MembershipTable
template_name = "member/club_members.html"
extra_context = {"title": _("Members of the club")}
export_formats = ["csv"]
def get_queryset(self, **kwargs):
qs = super().get_queryset().filter(club_id=self.kwargs["pk"])
@@ -988,14 +981,6 @@ class ClubMembersListView(ExportMixin, ProtectQuerysetMixin, LoginRequiredMixin,
return qs.distinct()
def get_export_filename(self, export_format):
return "members.csv"
def get_export_content_type(self, export_format):
if export_format == "csv":
return "text/csv"
return super().get_export_content_type(export_format)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
club = Club.objects.filter(

View File

@@ -13,7 +13,7 @@ def register_note_urls(router, path):
router.register(path + '/note', NotePolymorphicViewSet)
router.register(path + '/alias', AliasViewSet)
router.register(path + '/trust', TrustViewSet)
router.register(path + '/consumer', ConsumerViewSet, basename='alias2')
router.register(path + '/consumer', ConsumerViewSet)
router.register(path + '/transaction/category', TemplateCategoryViewSet)
router.register(path + '/transaction/transaction', TransactionViewSet)

View File

@@ -66,8 +66,6 @@ $(document).ready(function () {
arr.push(last)
last.quantity = 1
if (last.note.club) {
$('#last_name').val(last.note.name)
@@ -113,8 +111,7 @@ $(document).ready(function () {
dest.removeClass('d-none')
$('#dest_note_list').removeClass('d-none')
$('#debit_type').addClass('d-none')
$('#reason').val('')
$('#source_note_label').text(select_emitters_label)
$('#dest_note_label').text(select_receveirs_label)
@@ -137,7 +134,6 @@ $(document).ready(function () {
dest.val('')
dest.tooltip('hide')
$('#debit_type').addClass('d-none')
$('#reason').val('Rechargement note')
$('#source_note_label').text(transfer_type_label)
$('#dest_note_label').text(select_receveir_label)
@@ -166,7 +162,6 @@ $(document).ready(function () {
dest.addClass('d-none')
dest.tooltip('hide')
$('#debit_type').removeClass('d-none')
$('#reason').val('')
$('#source_note_label').text(select_emitter_label)
$('#dest_note_label').text(transfer_type_label)

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,8 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from oauth2_provider.oauth2_validators import OAuth2Validator
from oauth2_provider.scopes import BaseScopes
from member.models import Club
from note.models import Alias
from note_kfet.middlewares import get_current_request
from .backends import PermissionBackend
@@ -18,58 +16,26 @@ class PermissionScopes(BaseScopes):
and can be useful to make queries through the API with limited privileges.
"""
def get_all_scopes(self, **kwargs):
scopes = {}
if 'scopes' in kwargs:
for scope in kwargs['scopes']:
if scope == 'openid':
scopes['openid'] = "OpenID Connect"
else:
p = Permission.objects.get(id=scope.split('_')[0])
club = Club.objects.get(id=scope.split('_')[1])
scopes[scope] = f"{p.description} (club {club.name})"
return scopes
scopes = {f"{p.id}_{club.id}": f"{p.description} (club {club.name})"
for p in Permission.objects.all() for club in Club.objects.all()}
scopes['openid'] = "OpenID Connect"
return scopes
def get_all_scopes(self):
return {f"{p.id}_{club.id}": f"{p.description} (club {club.name})"
for p in Permission.objects.all() for club in Club.objects.all()}
def get_available_scopes(self, application=None, request=None, *args, **kwargs):
if not application:
return []
scopes = [f"{p.id}_{p.membership.club.id}"
for t in Permission.PERMISSION_TYPES
for p in PermissionBackend.get_raw_permissions(get_current_request(), t[0])]
scopes.append('openid')
return scopes
return [f"{p.id}_{p.membership.club.id}"
for t in Permission.PERMISSION_TYPES
for p in PermissionBackend.get_raw_permissions(get_current_request(), t[0])]
def get_default_scopes(self, application=None, request=None, *args, **kwargs):
if not application:
return []
scopes = [f"{p.id}_{p.membership.club.id}"
for p in PermissionBackend.get_raw_permissions(get_current_request(), 'view')]
scopes.append('openid')
return scopes
return [f"{p.id}_{p.membership.club.id}"
for p in PermissionBackend.get_raw_permissions(get_current_request(), 'view')]
class PermissionOAuth2Validator(OAuth2Validator):
oidc_claim_scope = OAuth2Validator.oidc_claim_scope
oidc_claim_scope.update({"name": 'openid',
"normalized_name": 'openid',
"email": 'openid',
})
def get_additional_claims(self, request):
return {
"name": request.user.username,
"normalized_name": Alias.normalize(request.user.username),
"email": request.user.email,
}
def get_discovery_claims(self, request):
claims = super().get_discovery_claims(self)
return claims + ["name", "normalized_name", "email"]
oidc_claim_scope = None # fix breaking change of django-oauth-toolkit 2.0.0
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
"""
@@ -88,8 +54,6 @@ class PermissionOAuth2Validator(OAuth2Validator):
if scope in scopes:
valid_scopes.add(scope)
if 'openid' in scopes:
valid_scopes.add('openid')
request.scopes = valid_scopes
return valid_scopes

View File

@@ -13,14 +13,12 @@ EXCLUDED = [
'cas_server.serviceticket',
'cas_server.user',
'cas_server.userattributes',
'constance.constance',
'contenttypes.contenttype',
'logs.changelog',
'migrations.migration',
'oauth2_provider.accesstoken',
'oauth2_provider.grant',
'oauth2_provider.refreshtoken',
'oauth2_provider.idtoken',
'sessions.session',
]

View File

@@ -10,7 +10,7 @@ from django.utils import timezone
from django.utils.crypto import get_random_string
from activity.models import Activity
from member.models import Club, Membership
from note.models import NoteUser, NoteClub
from note.models import NoteUser
from wei.models import WEIClub, Bus, WEIRegistration
@@ -122,13 +122,10 @@ class TestPermissionDenied(TestCase):
def test_validate_weiregistration(self):
wei = WEIClub.objects.create(
name="WEI Test",
membership_start=date.today(),
date_start=date.today() + timedelta(days=1),
date_end=date.today() + timedelta(days=1),
parent_club=Club.objects.get(name="Kfet"),
)
NoteClub.objects.create(club=wei)
registration = WEIRegistration.objects.create(wei=wei, user=self.user, birth_date="2000-01-01")
response = self.client.get(reverse("wei:validate_registration", kwargs=dict(pk=registration.pk)))
self.assertEqual(response.status_code, 403)

View File

@@ -164,24 +164,14 @@ class ScopesView(LoginRequiredMixin, TemplateView):
from oauth2_provider.models import Application
from .scopes import PermissionScopes
oidc = False
scopes = PermissionScopes()
context["scopes"] = {}
all_scopes = scopes.get_all_scopes()
for app in Application.objects.filter(user=self.request.user).all():
available_scopes = PermissionScopes().get_available_scopes(app)
available_scopes = scopes.get_available_scopes(app)
context["scopes"][app] = OrderedDict()
all_scopes = PermissionScopes().get_all_scopes(scopes=available_scopes)
scopes = {}
for scope in available_scopes:
scopes[scope] = all_scopes[scope]
# remove OIDC scope for sort
if 'openid' in scopes:
del scopes['openid']
oidc = True
items = [(k, v) for (k, v) in scopes.items()]
items = [(k, v) for (k, v) in all_scopes.items() if k in available_scopes]
items.sort(key=lambda x: (int(x[0].split("_")[1]), int(x[0].split("_")[0])))
# add oidc if necessary
if oidc:
items.append(('openid', PermissionScopes().get_all_scopes(scopes=['openid'])['openid']))
for k, v in items:
context["scopes"][app][k] = v

View File

@@ -353,11 +353,13 @@ class SogeCredit(models.Model):
def amount(self):
if self.valid:
return self.credit_transaction.total
amount = 0
transactions_wei = self.transactions.filter(membership__club__weiclub__isnull=False)
amount += sum(max(transaction.total - transaction.membership.club.weiclub.fee_soge_credit, 0) for transaction in transactions_wei)
transactions_not_wei = self.transactions.filter(membership__club__weiclub__isnull=True)
amount += sum(transaction.total for transaction in transactions_not_wei)
amount = sum(transaction.total for transaction in self.transactions.all())
if 'wei' in settings.INSTALLED_APPS:
from wei.models import WEIMembership
if not WEIMembership.objects\
.filter(club__weiclub__year=self.credit_transaction.created_at.year, user=self.user).exists():
# 80 € for people that don't go to WEI
amount += 8000
return amount
def update_transactions(self):
@@ -439,7 +441,7 @@ class SogeCredit(models.Model):
With Great Power Comes Great Responsibility...
"""
total_fee = self.amount
total_fee = sum(transaction.total for transaction in self.transactions.all() if not transaction.valid)
if self.user.note.balance < total_fee:
raise ValidationError(_("This user doesn't have enough money to pay the memberships with its note. "
"Please ask her/him to credit the note before invalidating this credit."))

View File

@@ -168,7 +168,7 @@ class InvoiceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
class InvoiceDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView):
"""
Delete a non-locked Invoice
Delete a non-validated WEI registration
"""
model = Invoice
extra_context = {"title": _("Delete invoice")}

View File

@@ -77,7 +77,7 @@ class WEIRegistrationViewSet(ReadProtectedModelViewSet):
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
filterset_fields = ['user', 'user__username', 'user__first_name', 'user__last_name', 'user__email',
'user__note__alias__name', 'user__note__alias__normalized_name', 'wei', 'wei__name',
'wei__email', 'wei__year', 'soge_credit', 'deposit_given', 'birth_date', 'gender',
'wei__email', 'wei__year', 'soge_credit', 'caution_check', 'birth_date', 'gender',
'clothing_cut', 'clothing_size', 'first_year', 'emergency_contact_name',
'emergency_contact_phone', ]
search_fields = ['$user__username', '$user__first_name', '$user__last_name', '$user__email',

View File

@@ -1,8 +1,7 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from .registration import WEIForm, WEIRegistrationForm, WEIMembership1AForm, \
WEIMembershipForm, BusForm, BusTeamForm
from .registration import WEIForm, WEIRegistrationForm, WEIMembership1AForm, WEIMembershipForm, BusForm, BusTeamForm
from .surveys import WEISurvey, WEISurveyInformation, WEISurveyAlgorithm, CurrentSurvey
__all__ = [

View File

@@ -5,7 +5,7 @@ from bootstrap_datepicker_plus.widgets import DatePickerInput
from django import forms
from django.contrib.auth.models import User
from django.db.models import Q
from django.forms import CheckboxSelectMultiple, RadioSelect
from django.forms import CheckboxSelectMultiple
from django.utils.translation import gettext_lazy as _
from note.models import NoteSpecial, NoteUser
from note_kfet.inputs import AmountInput, Autocomplete, ColorWidget
@@ -24,8 +24,6 @@ class WEIForm(forms.ModelForm):
"membership_end": DatePickerInput(),
"date_start": DatePickerInput(),
"date_end": DatePickerInput(),
"deposit_amount": AmountInput(),
"fee_soge_credit": AmountInput(),
}
@@ -41,11 +39,7 @@ class WEIRegistrationForm(forms.ModelForm):
class Meta:
model = WEIRegistration
fields = [
'user', 'soge_credit', 'birth_date', 'gender', 'clothing_size',
'health_issues', 'emergency_contact_name', 'emergency_contact_phone',
'first_year', 'information_json', 'deposit_given', 'deposit_type'
]
exclude = ('wei', 'clothing_cut')
widgets = {
"user": Autocomplete(
User,
@@ -55,21 +49,15 @@ class WEIRegistrationForm(forms.ModelForm):
'placeholder': 'Nom ...',
},
),
"birth_date": DatePickerInput(options={
'minDate': '1900-01-01',
'maxDate': '2100-01-01'
}),
"deposit_given": forms.CheckboxInput(
attrs={'class': 'form-check-input'},
),
"deposit_type": forms.RadioSelect(),
"birth_date": DatePickerInput(options={'minDate': '1900-01-01',
'maxDate': '2100-01-01'}),
}
class WEIChooseBusForm(forms.Form):
bus = forms.ModelMultipleChoiceField(
queryset=Bus.objects,
label=_("Bus"),
label=_("bus"),
help_text=_("This choice is not definitive. The WEI organizers are free to attribute for you a bus and a team,"
+ " in particular if you are a free eletron."),
widget=CheckboxSelectMultiple(),
@@ -84,17 +72,22 @@ class WEIChooseBusForm(forms.Form):
)
roles = forms.ModelMultipleChoiceField(
queryset=WEIRole.objects.filter(~Q(name="1A") & ~Q(name="GC WEI")),
queryset=WEIRole.objects.filter(~Q(name="1A")),
label=_("WEI Roles"),
help_text=_("Select the roles that you are interested in."),
initial=WEIRole.objects.filter(Q(name="Adhérent⋅e WEI") | Q(name="\u00c9lectron libre")).all(),
initial=WEIRole.objects.filter(name="Adhérent⋅e WEI").all(),
widget=CheckboxSelectMultiple(),
)
class WEIMembershipForm(forms.ModelForm):
caution_check = forms.BooleanField(
required=False,
label=_("Caution check given"),
)
roles = forms.ModelMultipleChoiceField(
queryset=WEIRole.objects.filter(~Q(name="GC WEI")),
queryset=WEIRole.objects,
label=_("WEI Roles"),
widget=CheckboxSelectMultiple(),
)
@@ -128,19 +121,6 @@ class WEIMembershipForm(forms.ModelForm):
required=False,
)
def __init__(self, *args, wei=None, **kwargs):
super().__init__(*args, **kwargs)
if 'bus' in self.fields:
if wei is not None:
self.fields['bus'].queryset = Bus.objects.filter(wei=wei)
else:
self.fields['bus'].queryset = Bus.objects.none()
if 'team' in self.fields:
if wei is not None:
self.fields['team'].queryset = BusTeam.objects.filter(bus__wei=wei)
else:
self.fields['team'].queryset = BusTeam.objects.none()
def clean(self):
cleaned_data = super().clean()
if 'team' in cleaned_data and cleaned_data["team"] is not None \
@@ -152,8 +132,21 @@ class WEIMembershipForm(forms.ModelForm):
model = WEIMembership
fields = ('roles', 'bus', 'team',)
widgets = {
"bus": RadioSelect(),
"team": RadioSelect(),
"bus": Autocomplete(
Bus,
attrs={
'api_url': '/api/wei/bus/',
'placeholder': 'Bus ...',
}
),
"team": Autocomplete(
BusTeam,
attrs={
'api_url': '/api/wei/team/',
'placeholder': 'Équipe ...',
},
resetable=True,
),
}
@@ -161,7 +154,7 @@ class WEIMembership1AForm(WEIMembershipForm):
"""
Used to confirm registrations of first year members without choosing a bus now.
"""
deposit_given = None
caution_check = None
roles = None
def clean(self):

Some files were not shown because too many files have changed in this diff Show More