1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-12-08 21:07:41 +01:00

Compare commits

..

8 Commits

Author SHA1 Message Date
Otthorn
6dc25e2645 Merge branch 'qrcode' into 'main'
Draft: Qrcode

See merge request bde/nk20!196
2025-07-13 20:52:39 +02:00
Nicolas Margulies
e6f3084588 Added a first pass for automatically entering an activity with a qrcode 2023-10-11 18:01:51 +02:00
otthorn
145e55da75 remove useless comment 2022-03-22 15:06:04 +01:00
otthorn
d3ba95cdca Insecable space for more clarity 2022-03-22 15:04:41 +01:00
otthorn
8ffb0ebb56 Use DetailView 2022-03-22 14:59:01 +01:00
otthorn
5038af9e34 Final html template 2022-03-22 14:58:26 +01:00
otthorn
819b4214c9 Add QRCode View, URL and test template 2022-03-22 12:26:44 +01:00
otthorn
b8a93b0b75 Add link to QR code 2022-03-19 16:25:15 +01:00
149 changed files with 2882 additions and 10075 deletions

View File

@@ -24,7 +24,3 @@ WIKI_PASSWORD=
# OIDC # OIDC
OIDC_RSA_PRIVATE_KEY=CHANGE_ME OIDC_RSA_PRIVATE_KEY=CHANGE_ME
# Activity configuration
TRUSTED_ACTIVITY_MAIL=
ACTIVITY_EMAIL_MANAGER=

View File

@@ -7,10 +7,10 @@ stages:
variables: variables:
GIT_SUBMODULE_STRATEGY: recursive GIT_SUBMODULE_STRATEGY: recursive
# Ubuntu 24.04 # Ubuntu 22.04
py312-django52: py310-django52:
stage: test stage: test
image: ubuntu:24.04 image: ubuntu:22.04
before_script: before_script:
# Fix tzdata prompt # Fix tzdata prompt
- ln -sf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone - ln -sf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone
@@ -22,12 +22,12 @@ py312-django52:
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
python3-bs4 python3-setuptools tox texlive-xetex python3-bs4 python3-setuptools tox texlive-xetex
script: tox -e py312-django52 script: tox -e py310-django52
# Debian Bookworm # Debian Bookworm
py313-django52: py311-django52:
stage: test stage: test
image: debian:trixie image: debian:bookworm
before_script: before_script:
- > - >
apt-get update && apt-get update &&
@@ -37,11 +37,11 @@ py313-django52:
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
python3-bs4 python3-setuptools tox texlive-xetex python3-bs4 python3-setuptools tox texlive-xetex
script: tox -e py313-django52 script: tox -e py311-django52
linters: linters:
stage: quality-assurance stage: quality-assurance
image: debian:trixie image: debian:bookworm
before_script: before_script:
- apt-get update && apt-get install -y tox - apt-get update && apt-get install -y tox
script: tox -e linters script: tox -e linters

View File

@@ -48,15 +48,5 @@
"can_invite": true, "can_invite": true,
"guest_entry_fee": 0 "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): def clean_organizer(self):
organizer = self.cleaned_data['organizer'] organizer = self.cleaned_data['organizer']
if not organizer.note.is_active: 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 return organizer
def clean_date_end(self): def clean_date_end(self):
@@ -120,12 +120,3 @@ class GuestForm(forms.ModelForm):
}, },
), ),
} }
class EmailForm(forms.Form):
"""
Form to export guest list by email
"""
emails = forms.CharField()
emails.label = _("Emails")
emails.widget.attrs['placeholder'] = _("Emails, separated by a comma")

View File

@@ -2,7 +2,7 @@
{% comment %} {% comment %}
SPDX-License-Identifier: GPL-3.0-or-later SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %} {% endcomment %}
{% load i18n perms crispy_forms_tags %} {% load i18n perms %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% load static django_tables2 i18n %} {% load static django_tables2 i18n %}
@@ -37,20 +37,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div id="guests_table"> <div id="guests_table">
{% render_table guests %} {% render_table guests %}
</div> </div>
{% if export %}
<div class="card-footer text-center">
<a href="{% url 'activity:guest_pdf' activity_pk=activity.pk %}" data-turbolinks="false">
<button class="btn btn-block btn-danger"><i class="fa fa-file-pdf-o"></i> {% trans "Export to PDF" %}</button>
</a>
</div>
<div class="card-body">
<form action="{% url 'activity:guest_pdf' activity_pk=activity.pk %}" method="post">
{% csrf_token %}
{{ email_form|crispy }}
<button class="btn btn-primary" type="submit">{% trans "Share" %}</button>
</form>
</div>
{% endif %}
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@@ -127,11 +113,5 @@ SPDX-License-Identifier: GPL-3.0-or-later
errMsg(xhr.responseJSON); errMsg(xhr.responseJSON);
}); });
}); });
{% if mail %}
var mails = {{ mail|safe }};
for (const mail of mails) {
addMsg(gettext("An email has been sent to") + " " + mail, "success");
}
{% endif %}
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -38,6 +38,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
</a> </a>
<input id="alias" type="text" class="form-control" placeholder="Nom/note ..."> <input id="alias" type="text" class="form-control" placeholder="Nom/note ...">
<button id="trigger" class="btn btn-secondary">Click me !</button>
<hr> <hr>
@@ -63,15 +64,46 @@ SPDX-License-Identifier: GPL-3.0-or-later
refreshBalance(); refreshBalance();
} }
function process_qrcode() {
let name = alias_obj.val();
$.get("/api/note/note?search=" + name + "&format=json").done(
function (res) {
let note = res.results[0];
$.post("/api/activity/entry/?format=json", {
csrfmiddlewaretoken: CSRF_TOKEN,
activity: {{ activity.id }},
note: note.id,
guest: null
}).done(function () {
addMsg(interpolate(gettext(
"Entry made for %s whose balance is %s €"),
[note.name, note.balance / 100]), "success", 4000);
reloadTable(true);
}).fail(function (xhr) {
errMsg(xhr.responseJSON, 4000);
});
}).fail(function (xhr) {
errMsg(xhr.responseJSON, 4000);
});
}
alias_obj.keyup(function(event) { alias_obj.keyup(function(event) {
let code = event.originalEvent.keyCode let code = event.originalEvent.keyCode
if (65 <= code <= 122 || code === 13) { if (65 <= code <= 122 || code === 13) {
debounce(reloadTable)() debounce(reloadTable)()
} }
if (code === 0)
process_qrcode();
}); });
$(document).ready(init); $(document).ready(init);
alias_obj2 = document.getElementById("alias");
$("#trigger").click(function (e) {
addMsg("Clicked", "success", 1000);
alias_obj.val(alias_obj.val() + "\0");
alias_obj2.dispatchEvent(new KeyboardEvent('keyup'));
})
function init() { function init() {
$(".table-row").click(function (e) { $(".table-row").click(function (e) {
let target = e.target.parentElement; let target = e.target.parentElement;

View File

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

View File

@@ -1,30 +0,0 @@
{% load i18n %}
{% now "Y-m-d" as today %}
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>[Note Kfet] Liste des invité·e·s à l'activité {{ activity.name }}</title>
</head>
<body>
<p>
Bonjour,
</p>
<p>
Vous trouverez en pièce-jointe la liste des invité·e·s à l'activité : {{ activity.name }}
</p>
<p>
Cette liste vous est partagée par {{ user_identity }} (en copie de ce mail).
</p>
<p>
Bonne journée
</p>
--
<p>
Le BDE<br>
{% trans "Mail generated by the Note Kfet on the" %} {% now "j F Y à H:i:s" %}
</p>
</body>
</html>

View File

@@ -1,13 +0,0 @@
{% load i18n %}
Bonjour,
Vous trouverez en pièce-jointe la liste des invité·e·s à l'activité : {{ activity.name }}
Cette liste vous est partagée par {{ user_identity }} (en copie de ce mail).
Bonne journée
--
Le BDE
{% trans "Mail generated by the Note Kfet on the" %} {% now "j F Y à H:i:s" %}

View File

@@ -1,42 +0,0 @@
\documentclass[a4paper,portrait,12pt]{article}
\usepackage{fontspec}
\usepackage[margin=1.5cm]{geometry}
\usepackage{longtable}
\begin{document}
\begin{center}
\LARGE{Liste des personnes invitées à l'activité « {{ activity.name }} »}
\end{center}
\normalsize
\noindent En tout,\textbf{ {{total}} }personnes sont invitées à l'activité {{ activity.name }}. \\
Elle aura lieu du {{ activity.date_start.astimezone.date }} à {{ activity.date_start.astimezone.time }}
jusqu'au {{ activity.date_end.astimezone.date }} à {{ activity.date_end.astimezone.time }}.
\begin{center}
\normalsize
\begin{longtable}{c||c|c|c|c|}
& \textbf{Nom} & \textbf{Prénom} & \textbf{École} & \textbf{Entrée} \\
\hline\hline
{% for guest in guests %}
{{ forloop.counter }} & {{ guest.last_name|safe }} & {{ guest.first_name|safe }} & {{ guest.school|safe }} & \\
\hline
{% endfor %}
\end{longtable}
\end{center}
\footnotesize
\kern -3pt
\hrule width 2in
\kern 2.6pt
\noindent AVERTISSEMENT :
Cette liste contient des données personnelles (prénom, nom, école)
et doit être traitée conformément au RGPD.
Elle ne doit être utilisée que pour les besoins stricts de
lorganisation de l'activité et ne doit pas être diffusée.
Toute copie, extraction ou conservation non nécessaire est interdite.
\end{document}

View File

@@ -1,7 +1,7 @@
{% comment %} {% comment %}
SPDX-License-Identifier: GPL-3.0-or-later SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %} {% endcomment %}
{% load i18n perms pretty_money dict_get %} {% load i18n perms pretty_money %}
{% url 'activity:activity_detail' activity.pk as activity_detail_url %} {% url 'activity:activity_detail' activity.pk as activity_detail_url %}
<div id="activity_info" class="card bg-light shadow mb-3"> <div id="activity_info" class="card bg-light shadow mb-3">
@@ -53,23 +53,12 @@ SPDX-License-Identifier: GPL-3.0-or-later
<dt class="col-xl-6">{% trans 'opened'|capfirst %}</dt> <dt class="col-xl-6">{% trans 'opened'|capfirst %}</dt>
<dd class="col-xl-6">{{ activity.open|yesno }}</dd> <dd class="col-xl-6">{{ activity.open|yesno }}</dd>
</dl> </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>
<div class="card-footer text-center"> <div class="card-footer text-center">
{% if activity.open and activity.activity_type.manage_entries and ".change__open"|has_perm:activity %} {% if activity.open and activity.activity_type.manage_entries and ".change__open"|has_perm:activity %}
<a class="btn btn-warning btn-sm my-1" href="{% url 'activity:activity_entry' pk=activity.pk %}"> {% trans "Entry page" %}</a> <a class="btn btn-warning btn-sm my-1" href="{% url 'activity:activity_entry' pk=activity.pk %}"> {% trans "Entry page" %}</a>
{% endif %} {% endif %}
{% if false %}
{% 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 %}
{% endif %}
{% if request.path_info == activity_detail_url %} {% if request.path_info == activity_detail_url %}
{% if activity.valid and ".change__open"|has_perm:activity %} {% if activity.valid and ".change__open"|has_perm:activity %}

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

@@ -10,7 +10,6 @@ app_name = 'activity'
urlpatterns = [ urlpatterns = [
path('', views.ActivityListView.as_view(), name='activity_list'), path('', views.ActivityListView.as_view(), name='activity_list'),
path('<int:pk>/', views.ActivityDetailView.as_view(), name='activity_detail'), path('<int:pk>/', views.ActivityDetailView.as_view(), name='activity_detail'),
path('<int:activity_pk>/pdf/', views.GuestListRenderView.as_view(), name="guest_pdf"),
path('<int:pk>/invite/', views.ActivityInviteView.as_view(), name='activity_invite'), path('<int:pk>/invite/', views.ActivityInviteView.as_view(), name='activity_invite'),
path('<int:pk>/entry/', views.ActivityEntryView.as_view(), name='activity_entry'), path('<int:pk>/entry/', views.ActivityEntryView.as_view(), name='activity_entry'),
path('<int:pk>/update/', views.ActivityUpdateView.as_view(), name='activity_update'), path('<int:pk>/update/', views.ActivityUpdateView.as_view(), name='activity_update'),

View File

@@ -1,38 +1,30 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay # Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import os
import shutil
import subprocess
from hashlib import md5 from hashlib import md5
from tempfile import mkdtemp
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.core.mail import EmailMultiAlternatives
from django.db import transaction from django.db import transaction
from django.db.models import F, Q from django.db.models import F, Q
from django.db.models.functions.text import Lower from django.http import HttpResponse, JsonResponse
from django.http import HttpResponse, JsonResponse, HttpResponseRedirect from django.urls import reverse_lazy
from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.template.loader import render_to_string
from django.views import View from django.views import View
from django.views.decorators.cache import cache_page from django.views.decorators.cache import cache_page
from django.views.generic import DetailView, TemplateView, UpdateView from django.views.generic import DetailView, TemplateView, UpdateView
from django.views.generic.list import ListView from django.views.generic.list import ListView
from django_tables2.views import MultiTableMixin, SingleTableMixin from django_tables2.views import MultiTableMixin, SingleTableMixin
from note_kfet.settings import BASE_DIR
from api.viewsets import is_regex from api.viewsets import is_regex
from note.models import Alias, NoteSpecial, NoteUser from note.models import Alias, NoteSpecial, NoteUser
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin, ProtectedCreateView from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from .forms import ActivityForm, GuestForm, EmailForm from .forms import ActivityForm, GuestForm
from .models import Activity, Entry, Guest, Opener from .models import Activity, Entry, Guest, Opener
from .tables import ActivityTable, EntryTable, GuestTable, OpenerTable from .tables import ActivityTable, EntryTable, GuestTable, OpenerTable
@@ -75,65 +67,32 @@ class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin
tables = [ tables = [
lambda data: ActivityTable(data, prefix="all-"), lambda data: ActivityTable(data, prefix="all-"),
lambda data: ActivityTable(data, prefix="upcoming-"), lambda data: ActivityTable(data, prefix="upcoming-"),
lambda data: ActivityTable(data, prefix="search-"),
] ]
extra_context = {"title": _("Activities")} extra_context = {"title": _("Activities")}
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
""" return super().get_queryset(**kwargs).distinct()
Filter the user list with the given pattern.
"""
return super().get_queryset().distinct()
def get_tables_data(self): def get_tables_data(self):
# first table = all activities, second table = upcoming, third table = search # first table = all activities, second table = upcoming
# 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'))
return [ return [
self.get_queryset().order_by("-date_start"), self.get_queryset().order_by("-date_start"),
Activity.objects.filter(date_end__gt=timezone.now()) Activity.objects.filter(date_end__gt=timezone.now())
.filter(PermissionBackend.filter_queryset(self.request, Activity, "view")) .filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))
.distinct() .distinct()
.order_by("date_start"), .order_by("date_start")
search_table,
] ]
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
tables = context["tables"] tables = context["tables"]
for name, table in zip(["all", "upcoming", "table"], tables): for name, table in zip(["table", "upcoming"], tables):
context[name] = table context[name] = table
started_activities = self.get_queryset().filter(open=True, valid=True).distinct().all() started_activities = self.get_queryset().filter(open=True, valid=True).distinct().all()
context["started_activities"] = started_activities 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 return context
@@ -144,27 +103,18 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix
model = Activity model = Activity
context_object_name = "activity" context_object_name = "activity"
extra_context = {"title": _("Activity detail")} extra_context = {"title": _("Activity detail")}
export_formats = ["csv"]
tables = [ tables = [
GuestTable, lambda data: GuestTable(data, prefix="guests-"),
OpenerTable, 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): def get_tables_data(self):
return [ return [
Guest.objects.filter(activity=self.object) Guest.objects.filter(activity=self.object)
.filter(PermissionBackend.filter_queryset(self.request, Guest, "view")) .filter(PermissionBackend.filter_queryset(self.request, Guest, "view")),
.distinct(),
self.object.opener.filter(activity=self.object) self.object.opener.filter(activity=self.object)
.filter(PermissionBackend.filter_queryset(self.request, Opener, "view")) .filter(PermissionBackend.filter_queryset(self.request, Opener, "view")),
.distinct(),
] ]
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@@ -187,22 +137,7 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix
"placeholder": "" "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}
guests = Guest.objects.filter(activity=self.object)
guests_view = guests.filter(PermissionBackend.filter_queryset(self.request, Guest, "view"))
if guests.exists() and guests.count() == guests_view.count():
context["export"] = True
context["email_form"] = EmailForm
if 'mail' in self.request.GET:
context["mail"] = self.request.GET['mail'].split(',')
return context return context
@@ -281,7 +216,7 @@ class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView):
@transaction.atomic @transaction.atomic
def form_valid(self, form): def form_valid(self, form):
form.instance.activity = Activity.objects\ form.instance.activity = Activity.objects\
.filter(PermissionBackend.filter_queryset(self.request, Activity, "view")).distinct().get(pk=self.kwargs["pk"]) .filter(PermissionBackend.filter_queryset(self.request, Activity, "view")).get(pk=self.kwargs["pk"])
return super().form_valid(form) return super().form_valid(form)
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
@@ -433,118 +368,6 @@ class ActivityEntryView(LoginRequiredMixin, SingleTableMixin, TemplateView):
return context return context
class GuestListRenderView(LoginRequiredMixin, View):
"""
Render a generated PDF with the given information and a LaTeX template
"""
def get_queryset(self, **kwargs):
qs = Guest.objects.filter(PermissionBackend.filter_queryset(self.request, Guest, "view"))
qs = qs.filter(activity__pk=self.kwargs["activity_pk"]).order_by(
Lower('last_name'),
Lower('first_name'),
'id',
)
return qs.distinct()
def get(self, request, **kwargs):
pdf = self.generate_pdf(request)
return self.view_pdf(request, pdf)
def post(self, request, **kwargs):
recipients = []
emails = request.POST['emails'].split(',')
trust_address = os.getenv('TRUSTED_ACTIVITY_MAIL', '').split(',')
for email_address in emails:
if email_address in trust_address:
recipients.append(email_address)
# don't send email if no recipient
if not recipients:
raise PermissionDenied(_("Emails are not trusted!"))
pdf = self.generate_pdf(request)
self.send_pdf(request, recipients, pdf)
url = reverse('activity:activity_detail', kwargs={"pk": self.kwargs["activity_pk"]})
url += '?mail='
for email in recipients:
url += email + ','
url = url[:-1] # delete last comma
return HttpResponseRedirect(url)
def generate_pdf(self, request, **kwargs):
qs = self.get_queryset()
activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
if not qs.exists() or qs.count() != Guest.objects.filter(activity=activity).count():
raise PermissionDenied(_("You are not allowed to export the guest list for this activity."))
# Fill the template with the information
tex = render_to_string("activity/guestlist_sample.tex", dict(guests=qs.all(), activity=activity, total=qs.count()))
try:
os.mkdir(BASE_DIR + "/tmp")
except FileExistsError:
pass
# We render the file in a temporary directory
tmp_dir = mkdtemp(prefix=BASE_DIR + "/tmp/")
try:
with open("{}/guest-list.tex".format(tmp_dir), "wb") as f:
f.write(tex.encode("UTF-8"))
del tex
with open(os.devnull, "wb") as devnull:
error = subprocess.Popen(
["/usr/bin/xelatex", "-interaction=nonstopmode", "{}/guest-list.tex".format(tmp_dir)],
cwd=tmp_dir,
stderr=devnull,
stdout=devnull,
).wait()
if error:
with open("{}/guest-list.log".format(tmp_dir), "r") as f:
log = f.read()
raise IOError("An error attempted while generating a Guest list (code=" + str(error) + ")\n\n" + log)
with open("{}/guest-list.pdf".format(tmp_dir), 'rb') as f:
pdf = f.read()
return pdf
except IOError as e:
raise e
finally:
# Delete all temporary files
shutil.rmtree(tmp_dir)
def view_pdf(self, request, pdf):
response = HttpResponse(pdf, content_type="application/pdf")
response['Content-Disposition'] = "inline;filename=Liste des invité·e·s.pdf"
return response
def send_pdf(self, request, recipients, pdf):
user_identity = request.user.first_name.capitalize() + ' ' + request.user.last_name.upper()
activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
subject = _(f"Guest list of the activity {activity.name} share by {user_identity}")
# add the user in cc
cc = [request.user.email]
context = {'activity': activity, 'user_identity': user_identity}
message = render_to_string("activity/guest_list.txt", context=context)
html_message = render_to_string("activity/guest_list.html", context=context)
if os.getenv('ACTIVITY_EMAIL_MANAGER', ''):
cc.append(os.getenv('ACTIVITY_EMAIL_MANAGER'))
email = EmailMultiAlternatives(
subject=subject,
to=recipients,
cc=cc,
body=message,
)
email.attach("Liste des invité·e·s.pdf", pdf)
email.attach_alternative(html_message, "text/html")
email.send()
return
# Cache for 1 hour # Cache for 1 hour
@method_decorator(cache_page(60 * 60), name='dispatch') @method_decorator(cache_page(60 * 60), name='dispatch')
class CalendarView(View): class CalendarView(View):

View File

@@ -19,10 +19,6 @@ if "activity" in settings.INSTALLED_APPS:
from activity.api.urls import register_activity_urls from activity.api.urls import register_activity_urls
register_activity_urls(router, 'activity') 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: if "food" in settings.INSTALLED_APPS:
from food.api.urls import register_food_urls from food.api.urls import register_food_urls
register_food_urls(router, 'food') 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 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): class AllergenSerializer(serializers.ModelSerializer):
@@ -21,13 +21,9 @@ class FoodSerializer(serializers.ModelSerializer):
REST API Serializer for Food. REST API Serializer for Food.
The djangorestframework plugin will analyse the model `Food` and parse all fields in the API. 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: class Meta:
model = Food model = Food
fields = ['name', 'owner', 'allergens', 'expiry_date', 'end_of_life', 'is_ready', 'order', 'owner_name'] fields = '__all__'
class BasicFoodSerializer(serializers.ModelSerializer): class BasicFoodSerializer(serializers.ModelSerializer):
@@ -58,43 +54,3 @@ class QRCodeSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = QRCode model = QRCode
fields = '__all__' 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 # Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from .views import AllergenViewSet, FoodViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet, \ from .views import AllergenViewSet, FoodViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet
DishViewSet, SupplementViewSet, OrderViewSet, FoodTransactionViewSet
def register_food_urls(router, path): def register_food_urls(router, path):
@@ -14,7 +13,3 @@ def register_food_urls(router, path):
router.register(path + '/basicfood', BasicFoodViewSet) router.register(path + '/basicfood', BasicFoodViewSet)
router.register(path + '/transformedfood', TransformedFoodViewSet) router.register(path + '/transformedfood', TransformedFoodViewSet)
router.register(path + '/qrcode', QRCodeViewSet) 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 django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter from rest_framework.filters import SearchFilter
from .serializers import AllergenSerializer, FoodSerializer, BasicFoodSerializer, TransformedFoodSerializer, QRCodeSerializer, \ from .serializers import AllergenSerializer, FoodSerializer, BasicFoodSerializer, TransformedFoodSerializer, QRCodeSerializer
DishSerializer, SupplementSerializer, OrderSerializer, FoodTransactionSerializer from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode
from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order, FoodTransaction
class AllergenViewSet(ReadProtectedModelViewSet): class AllergenViewSet(ReadProtectedModelViewSet):
@@ -73,55 +72,3 @@ class QRCodeViewSet(ReadProtectedModelViewSet):
filter_backends = [DjangoFilterBackend, SearchFilter] filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['qr_code_number', ] filterset_fields = ['qr_code_number', ]
search_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,17 +4,15 @@
from random import shuffle from random import shuffle
from bootstrap_datepicker_plus.widgets import DateTimePickerInput from bootstrap_datepicker_plus.widgets import DateTimePickerInput
from crispy_forms.helper import FormHelper
from django import forms from django import forms
from django.forms import CheckboxSelectMultiple from django.forms.widgets import NumberInput
from django.forms.widgets import NumberInput, TextInput
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from member.models import Club 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 note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from .models import Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order, Recipe from .models import Food, BasicFood, TransformedFood, QRCode
class QRCodeForms(forms.ModelForm): class QRCodeForms(forms.ModelForm):
@@ -56,7 +54,7 @@ class BasicFoodForms(forms.ModelForm):
class Meta: class Meta:
model = BasicFood model = BasicFood
fields = ('name', 'owner', 'date_type', 'expiry_date', 'allergens', 'traces', 'order',) fields = ('name', 'owner', 'date_type', 'expiry_date', 'allergens', 'order',)
widgets = { widgets = {
"owner": Autocomplete( "owner": Autocomplete(
model=Club, model=Club,
@@ -99,7 +97,7 @@ class BasicFoodUpdateForms(forms.ModelForm):
""" """
class Meta: class Meta:
model = BasicFood model = BasicFood
fields = ('name', 'owner', 'date_type', 'expiry_date', 'end_of_life', 'is_ready', 'order', 'allergens', 'traces') fields = ('name', 'owner', 'date_type', 'expiry_date', 'end_of_life', 'is_ready', 'order', 'allergens')
widgets = { widgets = {
"owner": Autocomplete( "owner": Autocomplete(
model=Club, model=Club,
@@ -135,7 +133,7 @@ class AddIngredientForms(forms.ModelForm):
Form for add an ingredient Form for add an ingredient
""" """
fully_used = forms.BooleanField() fully_used = forms.BooleanField()
fully_used.initial = False fully_used.initial = True
fully_used.required = False fully_used.required = False
fully_used.label = _("Fully used") fully_used.label = _("Fully used")
@@ -143,14 +141,11 @@ class AddIngredientForms(forms.ModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# TODO find a better way to get pk (be not url scheme dependant) # TODO find a better way to get pk (be not url scheme dependant)
pk = get_current_request().path.split('/')[-1] pk = get_current_request().path.split('/')[-1]
qs = self.fields['ingredients'].queryset.filter( self.fields['ingredients'].queryset = self.fields['ingredients'].queryset.filter(
polymorphic_ctype__model="transformedfood", polymorphic_ctype__model="transformedfood",
is_ready=False, is_ready=False,
end_of_life='', end_of_life='',
).filter(PermissionBackend.filter_queryset(get_current_request(), Food, "change")) ).filter(PermissionBackend.filter_queryset(get_current_request(), Food, "change")).exclude(pk=pk)
if pk:
qs = qs.exclude(pk=pk)
self.fields['ingredients'].queryset = qs
class Meta: class Meta:
model = TransformedFood model = TransformedFood
@@ -162,7 +157,7 @@ class ManageIngredientsForm(forms.Form):
Form to manage ingredient Form to manage ingredient
""" """
fully_used = forms.BooleanField() fully_used = forms.BooleanField()
fully_used.initial = False fully_used.initial = True
fully_used.required = True fully_used.required = True
fully_used.label = _('Fully used') fully_used.label = _('Fully used')
@@ -171,7 +166,7 @@ class ManageIngredientsForm(forms.Form):
model=Food, model=Food,
resetable=True, resetable=True,
attrs={"api_url": "/api/food/food", attrs={"api_url": "/api/food/food",
"class": "autocomplete manageingredients-autocomplete"}, "class": "autocomplete"},
) )
name.label = _('Name') name.label = _('Name')
@@ -185,116 +180,8 @@ class ManageIngredientsForm(forms.Form):
) )
qrcode.label = _('QR code number') qrcode.label = _('QR code number')
add_all_same_name = forms.BooleanField(
required=False,
label=_("Add all identical food")
)
ManageIngredientsFormSet = forms.formset_factory( ManageIngredientsFormSet = forms.formset_factory(
ManageIngredientsForm, ManageIngredientsForm,
extra=1, 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")
class RecipeForm(forms.ModelForm):
"""
Form to create a recipe
"""
class Meta:
model = Recipe
fields = ('name', 'creater',)
widgets = {
"creater": Autocomplete(
model=Club,
attrs={"api_url": "/api/members/club/"},
),
}
class RecipeIngredientsForm(forms.Form):
"""
Form to add ingredients to a recipe
"""
name = forms.CharField()
name.widget = TextInput()
name.label = _("Name")
RecipeIngredientsFormSet = forms.formset_factory(
RecipeIngredientsForm,
extra=1,
)
class UseRecipeForm(forms.Form):
"""
Form to add ingredients to a TransformedFood using a Recipe
"""
recipe = forms.ModelChoiceField(
queryset=Recipe.objects,
label=_('Recipe'),
)
ingredients = forms.ModelMultipleChoiceField(
queryset=Food.objects,
label=_("Ingredients"),
widget=CheckboxSelectMultiple(),
)

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

@@ -1,18 +0,0 @@
# Generated by Django 5.2.6 on 2025-11-02 17:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('food', '0004_alter_foodtransaction_order'),
]
operations = [
migrations.AddField(
model_name='food',
name='traces',
field=models.ManyToManyField(blank=True, related_name='food_with_traces', to='food.allergen', verbose_name='traces'),
),
]

View File

@@ -1,29 +0,0 @@
# Generated by Django 5.2.6 on 2025-11-06 17:02
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('food', '0005_food_traces'),
('member', '0015_alter_profile_promotion'),
]
operations = [
migrations.CreateModel(
name='Recipe',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='name')),
('ingredients_json', models.TextField(blank=True, default='[]', help_text='Ingredients of the recipe, encoded in JSON', verbose_name='list of ingredients')),
('creater', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='member.club', verbose_name='creater')),
],
options={
'verbose_name': 'Recipe',
'verbose_name_plural': 'Recipes',
'unique_together': {('name', 'creater')},
},
),
]

View File

@@ -1,18 +1,13 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay # Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import json
from datetime import timedelta from datetime import timedelta
from django.db import models, transaction from django.db import models, transaction
from django.core.exceptions import ValidationError
from django.utils import timezone from django.utils import timezone
from django.contrib.auth.models import User
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from polymorphic.models import PolymorphicModel from polymorphic.models import PolymorphicModel
from member.models import Club from member.models import Club
from activity.models import Activity
from note.models import Transaction
class Allergen(models.Model): class Allergen(models.Model):
@@ -54,13 +49,6 @@ class Food(PolymorphicModel):
verbose_name=_('allergens'), verbose_name=_('allergens'),
) )
traces = models.ManyToManyField(
Allergen,
blank=True,
verbose_name=_('traces'),
related_name='food_with_traces'
)
expiry_date = models.DateTimeField( expiry_date = models.DateTimeField(
verbose_name=_('expiry date'), verbose_name=_('expiry date'),
null=False, null=False,
@@ -99,19 +87,6 @@ class Food(PolymorphicModel):
if old_allergens != list(parent.allergens.all()): if old_allergens != list(parent.allergens.all()):
parent.save(old_allergens=old_allergens) parent.save(old_allergens=old_allergens)
@transaction.atomic
def update_traces(self):
# update parents
for parent in self.transformed_ingredient_inv.iterator():
old_traces = list(parent.traces.all()).copy()
parent.traces.clear()
for child in parent.ingredients.iterator():
if child.pk != self.pk:
parent.traces.set(parent.traces.union(child.traces.all()))
parent.traces.set(parent.traces.union(self.traces.all()))
if old_traces != list(parent.traces.all()):
parent.save(old_traces=old_traces)
def update_expiry_date(self): def update_expiry_date(self):
# update parents # update parents
for parent in self.transformed_ingredient_inv.iterator(): for parent in self.transformed_ingredient_inv.iterator():
@@ -163,10 +138,6 @@ class BasicFood(Food):
and list(self.allergens.all()) != kwargs['old_allergens']): and list(self.allergens.all()) != kwargs['old_allergens']):
self.update_allergens() self.update_allergens()
if ('old_traces' in kwargs
and list(self.traces.all()) != kwargs['old_traces']):
self.update_traces()
# Expiry date # Expiry date
if ((self.expiry_date != old_food.expiry_date if ((self.expiry_date != old_food.expiry_date
and self.date_type == 'DLC') and self.date_type == 'DLC')
@@ -239,7 +210,7 @@ class TransformedFood(Food):
created = self.pk is None created = self.pk is None
if not created: if not created:
# Check if important fields are updated # Check if important fields are updated
update = {'allergens': False, 'traces': False, 'expiry_date': False} update = {'allergens': False, 'expiry_date': False}
old_food = Food.objects.select_for_update().get(pk=self.pk) old_food = Food.objects.select_for_update().get(pk=self.pk)
if not hasattr(self, "_force_save"): if not hasattr(self, "_force_save"):
# Allergens # Allergens
@@ -249,10 +220,6 @@ class TransformedFood(Food):
and list(self.allergens.all()) != kwargs['old_allergens']): and list(self.allergens.all()) != kwargs['old_allergens']):
update['allergens'] = True update['allergens'] = True
if ('old_traces' in kwargs
and list(self.traces.all()) != kwargs['old_traces']):
update['traces'] = True
# Expiry date # Expiry date
update['expiry_date'] = (self.shelf_life != old_food.shelf_life update['expiry_date'] = (self.shelf_life != old_food.shelf_life
or self.creation_date != old_food.creation_date) or self.creation_date != old_food.creation_date)
@@ -263,7 +230,6 @@ class TransformedFood(Food):
if ('old_ingredients' in kwargs if ('old_ingredients' in kwargs
and list(self.ingredients.all()) != list(kwargs['old_ingredients'])): and list(self.ingredients.all()) != list(kwargs['old_ingredients'])):
update['allergens'] = True update['allergens'] = True
update['traces'] = True
update['expiry_date'] = True update['expiry_date'] = True
# it's preferable to keep a queryset but we allow list too # it's preferable to keep a queryset but we allow list too
@@ -273,8 +239,6 @@ class TransformedFood(Food):
self.check_cycle(self.ingredients.all().difference(kwargs['old_ingredients']), self, []) self.check_cycle(self.ingredients.all().difference(kwargs['old_ingredients']), self, [])
if update['allergens']: if update['allergens']:
self.update_allergens() self.update_allergens()
if update['traces']:
self.update_traces()
if update['expiry_date']: if update['expiry_date']:
self.update_expiry_date() self.update_expiry_date()
@@ -286,10 +250,9 @@ class TransformedFood(Food):
for child in self.ingredients.iterator(): for child in self.ingredients.iterator():
self.allergens.set(self.allergens.union(child.allergens.all())) self.allergens.set(self.allergens.union(child.allergens.all()))
self.traces.set(self.traces.union(child.traces.all()))
if not (child.polymorphic_ctype.model == 'basicfood' and child.date_type == 'DDM'): if not (child.polymorphic_ctype.model == 'basicfood' and child.date_type == 'DDM'):
self.expiry_date = min(self.expiry_date, child.expiry_date) 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: class Meta:
verbose_name = _('Transformed food') verbose_name = _('Transformed food')
@@ -321,250 +284,3 @@ class QRCode(models.Model):
def __str__(self): def __str__(self):
return _('QR-code number') + ' ' + str(self.qr_code_number) 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))
def save(self, *args, **kwargs):
# Check the owner of the food
if self.food.owner != self.dish.main.owner:
raise ValidationError(_('You cannot select food that belongs to the same club than the main food.'))
return super().save(*args, **kwargs)
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):
if self.activity != self.dish.activity:
raise ValidationError(_('Activities must be the same.'))
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,
reason=str(self.dish),
)
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)
class Recipe(models.Model):
"""
A recipe is a list of ingredients one can use to easily create a recurrent TransformedFood
"""
name = models.CharField(
verbose_name=_("name"),
max_length=255,
)
ingredients_json = models.TextField(
blank=True,
default="[]",
verbose_name=_("list of ingredients"),
help_text=_("Ingredients of the recipe, encoded in JSON")
)
creater = models.ForeignKey(
Club,
on_delete=models.CASCADE,
verbose_name=_("creater"),
)
class Meta:
verbose_name = _("Recipe")
verbose_name_plural = _("Recipes")
unique_together = ('name', 'creater',)
def __str__(self):
return "{name} ({creater})".format(name=self.name, creater=str(self.creater))
@property
def ingredients(self):
"""
Ingredients are stored in a JSON string
"""
return json.loads(self.ingredients_json)
@ingredients.setter
def ingredients(self, ingredients):
"""
Store ingredients as JSON string
"""
self.ingredients_json = json.dumps(ingredients, indent=2)

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,134 +2,20 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import django_tables2 as tables 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, Recipe from .models import Food
class FoodTable(tables.Table): class FoodTable(tables.Table):
""" """
List all foods. 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: class Meta:
model = Food model = Food
template_name = 'django_tables2/bootstrap4.html' template_name = 'django_tables2/bootstrap4.html'
fields = ('name', 'owner', 'qr_code_numbers', 'allergens', 'traces', 'date', 'expiry_date') fields = ('name', 'owner', 'allergens', 'expiry_date')
row_attrs = { row_attrs = {
'class': 'table-row', 'class': 'table-row',
'data-href': lambda record: 'detail/' + str(record.pk), 'data-href': lambda record: 'detail/' + str(record.pk),
'style': 'cursor:pointer', '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',
}
class RecipeTable(tables.Table):
"""
List all recipes
"""
def render_ingredients(self, record):
return ", ".join(str(q) for q in record.ingredients)
class Meta:
model = Recipe
template_name = 'django_tables2/bootstrap4.html'
fields = ('name', 'creater', 'ingredients',)
row_attrs = {
'class': 'table-row',
'data-href': lambda record: str(record.pk),
'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

@@ -47,11 +47,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
<a class="btn btn-sm btn-secondary" href="{% url "food:manage_ingredients" pk=food.pk %}"> <a class="btn btn-sm btn-secondary" href="{% url "food:manage_ingredients" pk=food.pk %}">
{% trans "Manage ingredients" %} {% trans "Manage ingredients" %}
</a> </a>
{% if false %}
<a class="btn btn-sm btn-secondary" href="{% url "food:recipe_use" pk=food.pk %}">
{% trans "Use a recipe" %}
</a>
{% endif %}
{% endif %} {% endif %}
<a class="btn btn-sm btn-primary" href="{% url "food:food_list" %}"> <a class="btn btn-sm btn-primary" href="{% url "food:food_list" %}">
{% trans "Return to the food list" %} {% trans "Return to the food list" %}

View File

@@ -34,12 +34,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div> </div>
</div> </div>
<div class="card-body"> <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" <input id="searchbar" type="text" class="form-control"
placeholder="{% trans "Search by attribute such as name..." %}"> placeholder="{% trans "Search by attribute such as name..." %}">
</div> </div>
@@ -64,31 +58,13 @@ SPDX-License-Identifier: GPL-3.0-or-later
<h3 class="card-header text-center"> <h3 class="card-header text-center">
{% trans "Meal served" %} {% trans "Meal served" %}
</h3> </h3>
<div class="card-footer">
{% if can_add_meal %} {% if can_add_meal %}
<div class="card-footer">
<a class="btn btn-sm btn-primary" href="{% url 'food:transformedfood_create' %}"> <a class="btn btn-sm btn-primary" href="{% url 'food:transformedfood_create' %}">
{% trans "New meal" %} {% trans "New meal" %}
</a> </a>
{% endif %}
{% if false %}
{% if can_view_recipes %}
<a class="btn btn-sm btn-secondary" href="{% url 'food:recipe_list' %}">
{% trans "View recipes" %}
</a>
{% endif %}
{% if can_add_recipe %}
<a class="btn btn-sm btn-primary" href="{% url 'food:recipe_create' %}">
{% trans "New recipe" %}
</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 %}
{% endif %}
</div> </div>
{% endif %}
{% if served.data %} {% if served.data %}
{% render_table served %} {% render_table served %}
{% else %} {% else %}
@@ -138,26 +114,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endif %} {% endif %}
</div> </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> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
document.getElementById('goButton').addEventListener('click', function(event) { document.getElementById('goButton').addEventListener('click', function(event) {

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.name.label }}</th>
<th>{{ form.qrcode.label }}</th> <th>{{ form.qrcode.label }}</th>
<th>{{ form.fully_used.label }}</th> <th>{{ form.fully_used.label }}</th>
<th>{{ form.add_all_same_name.label }}</th>
</tr> </tr>
</thead> </thead>
<tbody id="form_body"> <tbody id="form_body">
@@ -35,7 +34,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
<td>{{ form.name }}</td> <td>{{ form.name }}</td>
<td>{{ form.qrcode }}</td> <td>{{ form.qrcode }}</td>
<td>{{ form.fully_used }}</td> <td>{{ form.fully_used }}</td>
<td>{{ form.add_all_same_name }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -90,7 +88,7 @@ function delete_form_data (form_id) {
document.getElementById(prefix + "name").value = ""; document.getElementById(prefix + "name").value = "";
document.getElementById(prefix + "qrcode_pk").value = ""; document.getElementById(prefix + "qrcode_pk").value = "";
document.getElementById(prefix + "qrcode").value = ""; document.getElementById(prefix + "qrcode").value = "";
document.getElementById(prefix + "fully_used").checked = false; document.getElementById(prefix + "fully_used").checked = true;
} }
var form_count = {{ ingredients_count }} + 1; var form_count = {{ ingredients_count }} + 1;

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,31 +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 }} {{ recipe.name }}
</h3>
<div class="card-body">
<ul>
<li> {% trans "Creater" %} : {{ recipe.creater }}</li>
<li> {% trans "Ingredients" %} :
{% for ingredient in ingredients %} {{ ingredient }}{% if not forloop.last %},{% endif %}{% endfor %}
</li>
</ul>
{% if update %}
<a class="btn btn-sm btn-secondary" href="{% url "food:recipe_update" pk=recipe.pk %}">
{% trans "Update" %}
</a>
{% endif %}
<a class="btn btn-sm btn-primary" href="{% url "food:recipe_list" %}">
{% trans "Return to recipe list" %}
</a>
</div>
</div>
{% endblock %}

View File

@@ -1,122 +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="" id="recipe_form">
{% csrf_token %}
<div class="card-body">
{% crispy recipe_form %}
{# Keep all form elements in the same card-body for proper structure #}
{{ formset.management_form }}
<h3 class="text-center mt-4">{% trans "Add ingredients" %}</h3>
<table class="table table-condensed table-striped">
{% for form in formset %}
{% if forloop.first %}
<thead>
<tr>
<th>{{ form.name.label }}</th>
</tr>
</thead>
<tbody id="form_body">
{% endif %}
<tr class="row-formset ingredients">
<td>
{# Force prefix on the form fields #}
{{ form.name.as_widget }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# Display buttons to add and remove ingredients #}
<div class="card-body">
<div class="btn-group btn-block" role="group">
<button type="button" id="add_more" class="btn btn-success">{% trans "Add ingredient" %}</button>
<button type="button" id="remove_one" class="btn btn-danger">{% trans "Remove ingredient" %}</button>
</div>
<button class="btn btn-primary" type="submit" form="recipe_form">{% 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.name }}</td>
</tr>
</tbody>
</table>
</div>
{% endblock %}
{% block extrajavascript %}
<script>
/* script that handles add and remove lines */
$(document).ready(function() {
const totalFormsInput = $('input[name$="-TOTAL_FORMS"]');
const initialFormsInput = $('input[name$="-INITIAL_FORMS"]');
function updateTotalForms(n) {
if (totalFormsInput.length) {
totalFormsInput.val(n);
}
}
const initialCount = $('#form_body .row-formset').length;
updateTotalForms(initialCount);
const foods = {{ ingredients | safe }};
function prepopulate () {
for (var i = 0; i < {{ ingredients_count }}; i++) {
let prefix = 'id_form-' + parseInt(i) + '-';
document.getElementById(prefix + 'name').value = foods[i]['name'];
};
}
prepopulate();
$('#add_more').click(function() {
let formIdx = totalFormsInput.length ? parseInt(totalFormsInput.val(), 10) : $('#form_body .row-formset').length;
let newForm = $('#for_real').html().replace(/__prefix__/g, formIdx);
$('#form_body').append(newForm);
updateTotalForms(formIdx + 1);
});
$('#remove_one').click(function() {
let formIdx = totalFormsInput.length ? parseInt(totalFormsInput.val(), 10) : $('#form_body .row-formset').length;
if (formIdx > 1) {
$('#form_body tr.row-formset:last').remove();
updateTotalForms(formIdx - 1);
}
});
$('#recipe_form').on('submit', function() {
const totalInput = $('input[name$="-TOTAL_FORMS"]');
const prefix = totalInput.length ? totalInput.attr('name').replace(/-TOTAL_FORMS$/, '') : 'form';
$('#form_body tr.row-formset').each(function(i) {
const input = $(this).find('input,select,textarea').first();
if (input.length) {
const newName = `${prefix}-${i}-name`;
input.attr('name', newName).attr('id', `id_${newName}`).prop('disabled', false);
}
});
const visibleCount = $('#form_body tr.row-formset').length;
if (totalInput.length) totalInput.val(visibleCount);
});
});
</script>
{% endblock %}

View File

@@ -1,32 +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 }}
</h3>
{% render_table table %}
<div class="card-footer">
{% if can_add_recipe %}
<a class="btn btn-sm btn-success" href="{% url 'food:recipe_create' %}">{% trans "New recipe" %}</a>
{% endif %}
<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

@@ -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

@@ -1,80 +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 }} {{ object.name }}
</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 %}
{% block extrajavascript %}
<script>
$(document).ready(function () {
function refreshIngredients() {
// 1⃣ on récupère l'id de la recette sélectionnée
let recipe_id = $("#id_recipe").val() || $("input[name='recipe']:checked").val();
if (!recipe_id) {
// 2⃣ rien sélectionné → on vide la zone d'ingrédients
$("#div_id_ingredients > div").empty().html("<em>Aucune recette sélectionnée</em>");
return;
}
// 3⃣ on interroge le serveur
$.getJSON("{% url 'food:get_ingredients' %}", { recipe_id: recipe_id })
.done(function (data) {
// 4⃣ on cible le bon conteneur
const $container = $("#div_id_ingredients > div");
$container.empty();
if (data.ingredients && data.ingredients.length > 0) {
// 5⃣ on crée les cases à cocher
data.ingredients.forEach(function (ing, i) {
const html = `
<div class="form-check">
<input type="checkbox"
name="ingredients"
value="${ing.id}"
id="id_ingredients_${i}"
class="form-check-input"
checked>
<label class="form-check-label" for="id_ingredients_${i}">
${ing.name} (${ing.qr_code_numbers})
</label>
</div>
`;
$container.append(html);
});
} else {
$container.html("<em>Aucun ingrédient trouvé</em>");
}
})
.fail(function (xhr) {
console.error("Erreur AJAX:", xhr);
$("#div_id_ingredients > div").html("<em>Erreur de chargement des ingrédients</em>");
});
}
// 6⃣ déclenche quand la recette change
$("#id_recipe, input[name='recipe']").change(refreshIngredients);
// 7⃣ initial
refreshIngredients();
});
</script>
{% endblock %}

View File

@@ -6,12 +6,9 @@ from django.contrib.auth.models import User
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from activity.models import Activity, ActivityType
from member.models import Club
from ..api.views import AllergenViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet, \ from ..api.views import AllergenViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet
DishViewSet, SupplementViewSet, OrderViewSet, FoodTransactionViewSet from ..models import Allergen, BasicFood, TransformedFood, QRCode
from ..models import Allergen, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order # TODO FoodTransaction
class TestFood(TestCase): class TestFood(TestCase):
@@ -67,14 +64,14 @@ class TestFood(TestCase):
""" """
Display QRCode creation Display QRCode creation
""" """
response = self.client.get(reverse('food:qrcode_create', kwargs={"slug": 2})) response = self.client.get(reverse('food:qrcode_create'))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_basicfood_create(self): def test_basicfood_create(self):
""" """
Display BasicFood creation Display BasicFood creation
""" """
response = self.client.get(reverse('food:basicfood_create', kwargs={"slug": 2})) response = self.client.get(reverse('food:basicfood_create'))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_transformedfood_create(self): def test_transformedfood_create(self):
@@ -84,265 +81,45 @@ class TestFood(TestCase):
response = self.client.get(reverse('food:transformedfood_create')) response = self.client.get(reverse('food:transformedfood_create'))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_food_update(self): def test_food_create(self):
""" """
Display Food update Display Food update
""" """
response = self.client.get(reverse('food:food_update', args=(self.basicfood.pk,))) response = self.client.get(reverse('food:food_update'))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_food_view(self): def test_food_view(self):
""" """
Display Food detail Display Food detail
""" """
response = self.client.get(reverse('food:food_view', args=(self.basicfood.pk,))) response = self.client.get(reverse('food:food_view'))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
def test_basicfood_view(self): def test_basicfood_view(self):
""" """
Display BasicFood detail Display BasicFood detail
""" """
response = self.client.get(reverse('food:basicfood_view', args=(self.basicfood.pk,))) response = self.client.get(reverse('food:basicfood_view'))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_transformedfood_view(self): def test_transformedfood_view(self):
""" """
Display TransformedFood detail Display TransformedFood detail
""" """
response = self.client.get(reverse('food:transformedfood_view', args=(self.transformedfood.pk,))) response = self.client.get(reverse('food:transformedfood_view'))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_add_ingredient(self): def test_add_ingredient(self):
""" """
Display add ingredient view Display add ingredient view
""" """
response = self.client.get(reverse('food:add_ingredient', args=(self.transformedfood.pk,))) response = self.client.get(reverse('food:add_ingredient'))
self.assertEqual(response.status_code, 200) 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())'''
class TestFoodAPI(TestAPI): class TestFoodAPI(TestAPI):
def setUp(self) -> None: def setUp(self) -> None:
super().setUp() super().setUP()
self.allergen = Allergen.objects.create( self.allergen = Allergen.objects.create(
name='name', name='name',
@@ -368,39 +145,6 @@ class TestFoodAPI(TestAPI):
food_container=self.basicfood, 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,
)
self.dish = Dish.objects.create(
main=self.transformedfood,
price=500,
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_allergen_api(self): def test_allergen_api(self):
""" """
Load Allergen API page and test all filters and permissions Load Allergen API page and test all filters and permissions
@@ -413,7 +157,6 @@ class TestFoodAPI(TestAPI):
""" """
self.check_viewset(BasicFoodViewSet, '/api/food/basicfood/') self.check_viewset(BasicFoodViewSet, '/api/food/basicfood/')
# TODO Repair and detabulate this test
def test_transformedfood_api(self): def test_transformedfood_api(self):
""" """
Load TransformedFood API page and test all filters and permissions Load TransformedFood API page and test all filters and permissions
@@ -425,27 +168,3 @@ class TestFoodAPI(TestAPI):
Load QRCode API page and test all filters and permissions Load QRCode API page and test all filters and permissions
""" """
self.check_viewset(QRCodeViewSet, '/api/food/qrcode/') 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/')

View File

@@ -9,30 +9,14 @@ app_name = 'food'
urlpatterns = [ urlpatterns = [
path('', views.FoodListView.as_view(), name='food_list'), path('', views.FoodListView.as_view(), name='food_list'),
path('<int:slug>/', views.QRCodeCreateView.as_view(), name='qrcode_create'), path('<int:slug>', views.QRCodeCreateView.as_view(), name='qrcode_create'),
path('<int:slug>/add/basic/', views.BasicFoodCreateView.as_view(), name='basicfood_create'), path('<int:slug>/add/basic', views.BasicFoodCreateView.as_view(), name='basicfood_create'),
path('add/transformed/', views.TransformedFoodCreateView.as_view(), name='transformedfood_create'), path('add/transformed', views.TransformedFoodCreateView.as_view(), name='transformedfood_create'),
path('update/<int:pk>/', views.FoodUpdateView.as_view(), name='food_update'), path('update/<int:pk>', views.FoodUpdateView.as_view(), name='food_update'),
path('update/ingredients/<int:pk>/', views.ManageIngredientsView.as_view(), name='manage_ingredients'), path('update/ingredients/<int:pk>', views.ManageIngredientsView.as_view(), name='manage_ingredients'),
path('detail/<int:pk>/', views.FoodDetailView.as_view(), name='food_view'), path('detail/<int:pk>', views.FoodDetailView.as_view(), name='food_view'),
path('detail/basic/<int:pk>/', views.BasicFoodDetailView.as_view(), name='basicfood_view'), 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('detail/transformed/<int:pk>', views.TransformedFoodDetailView.as_view(), name='transformedfood_view'),
path('add/ingredient/<int:pk>/', views.AddIngredientView.as_view(), name='add_ingredient'), path('add/ingredient/<int:pk>', views.AddIngredientView.as_view(), name='add_ingredient'),
path('redirect/', views.QRCodeRedirectView.as_view(), name='redirect_view'), 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'),
# path('recipe/add/', views.RecipeCreateView.as_view(), name='recipe_create'),
# path('recipe/', views.RecipeListView.as_view(), name='recipe_list'),
# path('recipe/<int:pk>/', views.RecipeDetailView.as_view(), name='recipe_detail'),
# path('recipe/<int:pk>/update/', views.RecipeUpdateView.as_view(), name='recipe_update'),
# path('update/ingredients/<int:pk>/recipe/', views.UseRecipeView.as_view(), name='recipe_use'),
# path('ajax/get_ingredients/', views.get_ingredients_for_recipe, name='get_ingredients'),
] ]

View File

@@ -4,32 +4,25 @@
from datetime import timedelta from datetime import timedelta
from api.viewsets import is_regex from api.viewsets import is_regex
from crispy_forms.helper import FormHelper from django_tables2.views import MultiTableMixin
from django_tables2.views import SingleTableView, MultiTableMixin
from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
from django.db.models import Q, Count from django.db.models import Q
from django.http import HttpResponseRedirect, Http404, JsonResponse from django.http import HttpResponseRedirect, Http404
from django.views.decorators.http import require_GET
from django.views.generic import DetailView, UpdateView, CreateView from django.views.generic import DetailView, UpdateView, CreateView
from django.views.generic.list import ListView from django.views.generic.list import ListView
from django.views.generic.base import RedirectView from django.views.generic.base import RedirectView
from django.views.generic.edit import DeleteView
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from member.models import Club, Membership from member.models import Club, Membership
from activity.models import Activity
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin, ProtectedCreateView, LoginRequiredMixin from permission.views import ProtectQuerysetMixin, ProtectedCreateView, LoginRequiredMixin
from .models import Food, BasicFood, TransformedFood, QRCode, Order, Dish, Supplement, Recipe from .models import Food, BasicFood, TransformedFood, QRCode
from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \ from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \
ManageIngredientsForm, ManageIngredientsFormSet, AddIngredientForms, \ ManageIngredientsForm, ManageIngredientsFormSet, AddIngredientForms, \
BasicFoodUpdateForms, TransformedFoodUpdateForms, \ BasicFoodUpdateForms, TransformedFoodUpdateForms
DishForm, SupplementFormSet, SupplementFormSetHelper, OrderForm, RecipeForm, \ from .tables import FoodTable
RecipeIngredientsForm, RecipeIngredientsFormSet, UseRecipeForm
from .tables import FoodTable, DishTable, OrderTable, RecipeTable
from .utils import pretty_duration from .utils import pretty_duration
@@ -72,24 +65,16 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li
suffix = '__iregex' if valid_regex else '__istartswith' suffix = '__iregex' if valid_regex else '__istartswith'
prefix = '^' if valid_regex else '' prefix = '^' if valid_regex else ''
qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern}) qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern})
| Q(**{f'owner__name{suffix}': prefix + pattern}) | Q(**{f'owner__name{suffix}': prefix + pattern}))
| Q(**{f'owner__note__alias__name{suffix}': prefix + pattern}))
else: else:
qs = qs.none() 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')) search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Food, 'view'))
# table open # table open
open_table = self.get_queryset().filter( open_table = self.get_queryset().order_by('expiry_date').filter(
Q(polymorphic_ctype__model='transformedfood') Q(polymorphic_ctype__model='transformedfood')
| Q(polymorphic_ctype__model='basicfood', basicfood__date_type='DLC')).filter( | Q(polymorphic_ctype__model='basicfood', basicfood__date_type='DLC')).filter(
expiry_date__lt=timezone.now(), end_of_life='').filter( expiry_date__lt=timezone.now(), end_of_life='').filter(
PermissionBackend.filter_queryset(self.request, Food, 'view')) PermissionBackend.filter_queryset(self.request, Food, 'view'))
open_table = open_table.union(self.get_queryset().filter(
Q(end_of_life='', order__iexact='open')
).filter(
PermissionBackend.filter_queryset(self.request, Food, 'view'))).order_by('expiry_date')
# table served # table served
served_table = self.get_queryset().order_by('-pk').filter( served_table = self.get_queryset().order_by('-pk').filter(
end_of_life='', is_ready=True).exclude( end_of_life='', is_ready=True).exclude(
@@ -110,7 +95,6 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li
owner=club, end_of_life='').filter( owner=club, end_of_life='').filter(
PermissionBackend.filter_queryset(self.request, Food, 'view') PermissionBackend.filter_queryset(self.request, Food, 'view')
)) ))
return [search_table, open_table, served_table] + club_table return [search_table, open_table, served_table] + club_table
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@@ -123,13 +107,6 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li
context['club_tables'] = tables[3:] context['club_tables'] = tables[3:]
context['can_add_meal'] = PermissionBackend.check_perm(self.request, 'food.transformedfood_add') context['can_add_meal'] = PermissionBackend.check_perm(self.request, 'food.transformedfood_add')
context['can_add_recipe'] = PermissionBackend.check_perm(self.request, 'food.recipe_add')
context['can_view_recipes'] = PermissionBackend.check_perm(self.request, 'food.recipe_view')
context["open_activities"] = Activity.objects.filter(activity_type__name="Perm bouffe", open=True)
return context return context
@@ -241,12 +218,10 @@ class BasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
copy = self.request.GET.get('copy', None) copy = self.request.GET.get('copy', None)
if copy is not None: if copy is not None:
food = BasicFood.objects.get(pk=copy) food = BasicFood.objects.get(pk=copy)
print(context['form'].fields)
for field in context['form'].fields: for field in context['form'].fields:
if field == 'allergens': if field == 'allergens':
context['form'].fields[field].initial = getattr(food, field).all() context['form'].fields[field].initial = getattr(food, field).all()
elif field == 'traces':
context['form'].fields[field].initial = getattr(food, field).all()
else: else:
context['form'].fields[field].initial = getattr(food, field) context['form'].fields[field].initial = getattr(food, field)
@@ -306,42 +281,34 @@ class ManageIngredientsView(LoginRequiredMixin, UpdateView):
def form_valid(self, form): def form_valid(self, form):
old_ingredients = list(self.object.ingredients.all()).copy() old_ingredients = list(self.object.ingredients.all()).copy()
old_allergens = list(self.object.allergens.all()).copy() old_allergens = list(self.object.allergens.all()).copy()
old_traces = list(self.object.traces.all()).copy()
self.object.ingredients.clear() self.object.ingredients.clear()
for i in range(self.object.ingredients.all().count() + 1 + MAX_FORMS): for i in range(self.object.ingredients.all().count() + 1 + MAX_FORMS):
prefix = 'form-' + str(i) + '-' prefix = 'form-' + str(i) + '-'
if form.data[prefix + 'qrcode'] not in ['0', '']:
ingredient = None
if form.data[prefix + 'qrcode'] not in ['0', '', 'NaN']:
ingredient = QRCode.objects.get(pk=form.data[prefix + 'qrcode']).food_container ingredient = QRCode.objects.get(pk=form.data[prefix + 'qrcode']).food_container
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='')
else:
ingredients = [ingredient]
for ingredient in ingredients:
self.object.ingredients.add(ingredient) self.object.ingredients.add(ingredient)
if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on': if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on':
ingredient.end_of_life = _('Fully used in {meal}'.format( ingredient.end_of_life = _('Fully used in {meal}'.format(
meal=self.object.name)) meal=self.object.name))
ingredient.save() ingredient.save()
elif form.data[prefix + 'name'] != '':
ingredient = Food.objects.get(pk=form.data[prefix + 'name'])
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 # We recalculate new expiry date and allergens
self.object.expiry_date = self.object.creation_date + self.object.shelf_life self.object.expiry_date = self.object.creation_date + self.object.shelf_life
self.object.allergens.clear() self.object.allergens.clear()
self.object.traces.clear()
for ingredient in self.object.ingredients.iterator(): for ingredient in self.object.ingredients.iterator():
if not (ingredient.polymorphic_ctype.model == 'basicfood' and ingredient.date_type == 'DDM'): 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.expiry_date = min(self.object.expiry_date, ingredient.expiry_date)
self.object.allergens.set(self.object.allergens.union(ingredient.allergens.all())) self.object.allergens.set(self.object.allergens.union(ingredient.allergens.all()))
self.object.traces.set(self.object.traces.union(ingredient.traces.all()))
self.object.save(old_ingredients=old_ingredients, old_allergens=old_allergens, old_traces=old_traces) self.object.save(old_ingredients=old_ingredients, old_allergens=old_allergens)
return HttpResponseRedirect(self.get_success_url()) return HttpResponseRedirect(self.get_success_url())
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
@@ -365,7 +332,6 @@ class ManageIngredientsView(LoginRequiredMixin, UpdateView):
'qr_number': '' if qr.count() == 0 else qr[0].qr_code_number, 'qr_number': '' if qr.count() == 0 else qr[0].qr_code_number,
'fully_used': 'true' if ingredient.end_of_life else '', 'fully_used': 'true' if ingredient.end_of_life else '',
}) })
return context return context
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
@@ -394,15 +360,13 @@ class AddIngredientView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
for meal in meals: for meal in meals:
old_ingredients = list(meal.ingredients.all()).copy() old_ingredients = list(meal.ingredients.all()).copy()
old_allergens = list(meal.allergens.all()).copy() old_allergens = list(meal.allergens.all()).copy()
old_traces = list(meal.traces.all()).copy()
meal.ingredients.add(self.object.pk) meal.ingredients.add(self.object.pk)
# update allergen and expiry date if necessary # update allergen and expiry date if necessary
if not (self.object.polymorphic_ctype.model == 'basicfood' if not (self.object.polymorphic_ctype.model == 'basicfood'
and self.object.date_type == 'DDM'): and self.object.date_type == 'DDM'):
meal.expiry_date = min(meal.expiry_date, self.object.expiry_date) meal.expiry_date = min(meal.expiry_date, self.object.expiry_date)
meal.allergens.set(meal.allergens.union(self.object.allergens.all())) meal.allergens.set(meal.allergens.union(self.object.allergens.all()))
meal.traces.set(meal.traces.union(self.object.traces.all())) meal.save(old_ingredients=old_ingredients, old_allergens=old_allergens)
meal.save(old_ingredients=old_ingredients, old_allergens=old_allergens, old_traces=old_traces)
if 'fully_used' in form.data: if 'fully_used' in form.data:
if not self.object.end_of_life: if not self.object.end_of_life:
self.object.end_of_life = _(f'Food fully used in : {meal.name}') self.object.end_of_life = _(f'Food fully used in : {meal.name}')
@@ -432,7 +396,6 @@ class FoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
form.instance.creater = self.request.user form.instance.creater = self.request.user
food = Food.objects.get(pk=self.kwargs['pk']) food = Food.objects.get(pk=self.kwargs['pk'])
old_allergens = list(food.allergens.all()).copy() old_allergens = list(food.allergens.all()).copy()
old_traces = list(food.traces.all()).copy()
if food.polymorphic_ctype.model == 'transformedfood': if food.polymorphic_ctype.model == 'transformedfood':
old_ingredients = food.ingredients.all() old_ingredients = food.ingredients.all()
@@ -446,7 +409,7 @@ class FoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
if food.polymorphic_ctype.model == 'transformedfood': if food.polymorphic_ctype.model == 'transformedfood':
form.instance.save(old_ingredients=old_ingredients) form.instance.save(old_ingredients=old_ingredients)
else: else:
form.instance.save(old_allergens=old_allergens, old_traces=old_traces) form.instance.save(old_allergens=old_allergens)
return ans return ans
def get_form_class(self, **kwargs): def get_form_class(self, **kwargs):
@@ -479,7 +442,7 @@ class FoodDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
fields = ["name", "owner", "expiry_date", "allergens", "traces", "is_ready", "end_of_life", "order"] fields = ["name", "owner", "expiry_date", "allergens", "is_ready", "end_of_life", "order"]
fields = dict([(field, getattr(self.object, field)) for field in fields]) fields = dict([(field, getattr(self.object, field)) for field in fields])
if fields["is_ready"]: if fields["is_ready"]:
@@ -488,8 +451,6 @@ class FoodDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
fields["is_ready"] = _("No") fields["is_ready"] = _("No")
fields["allergens"] = ", ".join( fields["allergens"] = ", ".join(
allergen.name for allergen in fields["allergens"].all()) allergen.name for allergen in fields["allergens"].all())
fields["traces"] = ", ".join(
trace.name for trace in fields["traces"].all())
context["fields"] = [( context["fields"] = [(
Food._meta.get_field(field).verbose_name.capitalize(), Food._meta.get_field(field).verbose_name.capitalize(),
@@ -560,520 +521,3 @@ class QRCodeRedirectView(RedirectView):
if slug: if slug:
return reverse_lazy('food:qrcode_create', kwargs={'slug': slug}) return reverse_lazy('food:qrcode_create', kwargs={'slug': slug})
return reverse_lazy('food:list') 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"], served=False)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
orders_count = Order.objects.filter(activity__pk=self.kwargs["activity_pk"], served=False).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
class RecipeCreateView(ProtectQuerysetMixin, ProtectedCreateView):
"""
Create a recipe
"""
model = Recipe
form_class = RecipeForm
extra_context = {"title": _("Create a recipe")}
def get_sample_object(self):
return Recipe(name='Sample recipe')
@transaction.atomic
def form_valid(self, form):
formset = RecipeIngredientsFormSet(self.request.POST)
if formset.is_valid():
ingredients = [f.cleaned_data['name'] for f in formset if f.cleaned_data.get('name')]
self.object = form.save(commit=False)
self.object.ingredients = ingredients
self.object.save()
return super().form_valid(form)
else:
return self.form_invalid(form)
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['form'] = RecipeIngredientsForm()
context['recipe_form'] = self.get_form()
if self.request.POST:
context['formset'] = RecipeIngredientsFormSet(self.request.POST,)
else:
context['formset'] = RecipeIngredientsFormSet()
return context
def get_success_url(self):
return reverse_lazy('food:recipe_list')
class RecipeListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
"""
List all recipes
"""
model = Recipe
table_class = RecipeTable
extra_context = {"title": _('All recipes')}
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['can_add_recipe'] = PermissionBackend.check_perm(self.request, 'food.recipe_add')
return context
class RecipeDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
List all recipes
"""
model = Recipe
extra_context = {"title": _('Details of:')}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["ingredients"] = self.object.ingredients
context["update"] = PermissionBackend.check_perm(self.request, "food.change_recipe")
return context
class RecipeUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
"""
Create a recipe
"""
model = Recipe
form_class = RecipeForm
extra_context = {"title": _("Create a recipe")}
def get_sample_object(self):
return Recipe(name='Sample recipe')
@transaction.atomic
def form_valid(self, form):
formset = RecipeIngredientsFormSet(self.request.POST)
if formset.is_valid():
ingredients = [f.cleaned_data['name'] for f in formset if f.cleaned_data.get('name')]
self.object = form.save(commit=False)
self.object.ingredients = ingredients
self.object.save()
return super().form_valid(form)
else:
return self.form_invalid(form)
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['form'] = RecipeIngredientsForm()
context['recipe_form'] = self.get_form()
if self.request.POST:
formset = RecipeIngredientsFormSet(self.request.POST,)
else:
formset = RecipeIngredientsFormSet()
ingredients = self.object.ingredients
context["ingredients_count"] = len(ingredients)
formset.extra += len(ingredients)
context["formset"] = formset
context["ingredients"] = []
for ingredient in ingredients:
context["ingredients"].append({"name": ingredient})
return context
def get_success_url(self):
return reverse_lazy('food:recipe_detail', kwargs={"pk": self.object.pk})
class UseRecipeView(LoginRequiredMixin, UpdateView):
"""
Add ingredients to a TransformedFood using a Recipe
"""
model = TransformedFood
fields = ('ingredients',)
template_name = 'food/use_recipe_form.html'
extra_context = {"title": _("Use a recipe for:")}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["form"] = UseRecipeForm()
return context
def form_valid(self, form):
old_ingredients = list(self.object.ingredients.all()).copy()
old_allergens = list(self.object.allergens.all()).copy()
old_traces = list(self.object.traces.all()).copy()
if "ingredients" in form.data:
ingredients_pk = form.data.getlist("ingredients")
ingredients = Food.objects.all().filter(pk__in=ingredients_pk)
for ingredient in ingredients:
self.object.ingredients.add(ingredient)
# We recalculate new expiry date and allergens
self.object.expiry_date = self.object.creation_date + self.object.shelf_life
self.object.allergens.clear()
self.object.traces.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.traces.set(self.object.traces.union(ingredient.traces.all()))
self.object.save(old_ingredients=old_ingredients, old_allergens=old_allergens, old_traces=old_traces)
return HttpResponseRedirect(self.get_success_url())
def get_success_url(self):
return reverse_lazy('food:transformedfood_view', kwargs={"pk": self.object.pk})
@require_GET
def get_ingredients_for_recipe(request):
recipe_id = request.GET.get('recipe_id')
if not recipe_id:
return JsonResponse({'error': 'Missing recipe_id'}, status=400)
try:
recipe = Recipe.objects.get(pk=recipe_id)
except Recipe.DoesNotExist:
return JsonResponse({'error': 'Recipe not found'}, status=404)
# 🔧 Supporte les deux cas : ManyToMany ou simple liste
ingredients_field = recipe.ingredients
if hasattr(ingredients_field, "values_list"):
# Cas ManyToManyField
ingredient_names = list(ingredients_field.values_list('name', flat=True))
elif isinstance(ingredients_field, (list, tuple)):
# Cas liste directe
ingredient_names = ingredients_field
else:
return JsonResponse({'error': 'Unsupported ingredients type'}, status=500)
# Union des Foods dont le nom commence par un nom dingrédient
query = Q()
for name in ingredient_names:
valid_regex = is_regex(name)
suffix = '__iregex' if valid_regex else '__istartswith'
prefix = '.*' if valid_regex else ''
query |= Q(**{f'name{suffix}': prefix + name}, end_of_life='')
qs = Food.objects.filter(query).distinct()
qs = qs.filter(PermissionBackend.filter_queryset(request, Food, 'view'))
data = [{'id': f.id, 'name': f.name, 'qr_code_numbers': ", ".join(str(q.qr_code_number) for q in f.QR_code.all())} for f in qs]
return JsonResponse({'ingredients': data})

View File

@@ -6,7 +6,7 @@ from django.conf import settings
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.utils.translation import gettext_lazy as _ 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): class MemberConfig(AppConfig):
@@ -17,16 +17,7 @@ class MemberConfig(AppConfig):
""" """
Define app internal signals to interact with other apps Define app internal signals to interact with other apps
""" """
from .models import Membership, Club
post_save.connect( post_save.connect(
save_user_profile, save_user_profile,
sender=settings.AUTH_USER_MODEL, 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.contrib.auth.models import User
from django.db import transaction from django.db import transaction
from django.forms import CheckboxSelectMultiple from django.forms import CheckboxSelectMultiple
from phonenumber_field.formfields import PhoneNumberField
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from note.models import NoteSpecial, Alias 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. A form for the extras field provided by the :model:`member.Profile` model.
""" """
# Remove widget=forms.HiddenInput() if you want to use report frequency. # 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")) report_frequency = forms.IntegerField(required=False, initial=0, label=_("Report frequency"))
last_report = forms.DateTimeField(required=False, disabled=True, label=_("Last report date")) 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 if not self.instance.section or (("department" in self.changed_data
or "promotion" in self.changed_data) and "section" not in self.changed_data): or "promotion" in self.changed_data) and "section" not in self.changed_data):
self.instance.section = self.instance.section_generated self.instance.section = self.instance.section_generated
instance = super().save(commit=False) return super().save(commit)
if instance.phone_number:
instance.phone_number = instance.phone_number.as_e164
if commit:
instance.save()
return instance
class Meta: class Meta:
model = Profile model = Profile

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

@@ -417,7 +417,7 @@ class Membership(models.Model):
A membership is valid if today is between the start and the end date. A membership is valid if today is between the start and the end date.
""" """
if self.date_end is not None: if self.date_end is not None:
return self.date_start.toordinal() <= datetime.datetime.now().toordinal() <= self.date_end.toordinal() return self.date_start.toordinal() <= datetime.datetime.now().toordinal() < self.date_end.toordinal()
else: else:
return self.date_start.toordinal() <= datetime.datetime.now().toordinal() return self.date_start.toordinal() <= datetime.datetime.now().toordinal()
@@ -438,6 +438,8 @@ class Membership(models.Model):
) )
if hasattr(self, '_force_renew_parent') and self._force_renew_parent: if hasattr(self, '_force_renew_parent') and self._force_renew_parent:
new_membership._force_renew_parent = True 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: if hasattr(self, '_force_save') and self._force_save:
new_membership._force_save = True new_membership._force_save = True
new_membership.save() new_membership.save()
@@ -456,6 +458,8 @@ class Membership(models.Model):
# Renew the previous membership of the parent club # Renew the previous membership of the parent club
parent_membership = parent_membership.first() parent_membership = parent_membership.first()
parent_membership._force_renew_parent = True parent_membership._force_renew_parent = True
if hasattr(self, '_soge'):
parent_membership._soge = True
if hasattr(self, '_force_save'): if hasattr(self, '_force_save'):
parent_membership._force_save = True parent_membership._force_save = True
parent_membership.renew() parent_membership.renew()
@@ -467,6 +471,8 @@ class Membership(models.Model):
date_start=self.date_start, date_start=self.date_start,
) )
parent_membership._force_renew_parent = True parent_membership._force_renew_parent = True
if hasattr(self, '_soge'):
parent_membership._soge = True
if hasattr(self, '_force_save'): if hasattr(self, '_force_save'):
parent_membership._force_save = True parent_membership._force_save = True
parent_membership.save() parent_membership.save()

View File

@@ -1,8 +1,6 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay # Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
def save_user_profile(instance, created, raw, **_kwargs): 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.email_confirmed = True
instance.profile.registration_valid = True instance.profile.registration_valid = True
instance.profile.save() 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): def render_user(self, value):
# If the user has the right, link the displayed user with the page of its detail. # If the user has the right, link the displayed user with the page of its detail.
s = value.username s = value.username
@@ -163,16 +149,6 @@ class MembershipTable(tables.Table):
+ "'>" + s + "</a>") + "'>" + s + "</a>")
return s 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: class Meta:
attrs = { attrs = {
'class': 'table table-condensed table-striped', '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." %} {% trans "There is no membership found with this pattern." %}
</div> </div>
{% endif %} {% 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>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -7,19 +7,6 @@
<dt class="col-xl-6">{% trans 'username'|capfirst %}</dt> <dt class="col-xl-6">{% trans 'username'|capfirst %}</dt>
<dd class="col-xl-6">{{ user_object.username }}</dd> <dd class="col-xl-6">{{ user_object.username }}</dd>
{% 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 %} {% if user_object.pk == user.pk %}
<dt class="col-xl-6">{% trans 'password'|capfirst %}</dt> <dt class="col-xl-6">{% trans 'password'|capfirst %}</dt>
<dd class="col-xl-6"> <dd class="col-xl-6">
@@ -73,7 +60,10 @@
{% if user_object.pk == user.pk %} {% if user_object.pk == user.pk %}
<div class="text-center"> <div class="text-center">
<a class="small badge badge-secondary" href="{% url 'member:auth_token' %}"> <a class="small badge badge-secondary" href="{% url 'member:auth_token' %}">
<i class="fa fa-cogs"></i>{% trans 'API token' %} <i class="fa fa-cogs"></i>&nbsp;{% trans 'API token' %}
</a>
<a class="small badge badge-secondary" href="{% url 'member:qr_code' user_object.pk %}">
<i class="fa fa-qrcode"></i>&nbsp;{% trans 'QR Code' %}
</a> </a>
</div> </div>
{% endif %} {% endif %}

View File

@@ -10,7 +10,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{{ title }} {{ title }}
</h3> </h3>
<div class="card-body"> <div class="card-body">
<form method="post" id="profile-form"> <form method="post">
{% csrf_token %} {% csrf_token %}
{{ form | crispy }} {{ form | crispy }}
{{ profile_form | crispy }} {{ profile_form | crispy }}
@@ -21,45 +21,3 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div> </div>
</div> </div>
{% endblock %} {% 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

@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n %}
{% block content %}
<div class="card bg-light">
<h3 class="card-header text-center">
{% trans "QR Code for" %} {{ user_object.username }} ({{ user_object.first_name }} {{user_object.last_name }})
</h3>
<div class="text-center" id="qrcode">
</div>
</div>
{% endblock %}
{% block extrajavascript %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSQX0FslNhTDadL4O5SAGapGt4FodqL8My0mA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
var qrc = new QRCode(document.getElementById("qrcode"), {
text: "{{ user_object.pk }}\0",
width: 1024,
height: 1024
});
</script>
{% endblock %}
{% block extracss %}
<style>
img {
width: 100%
}
</style>
{% endblock %}

View File

@@ -25,4 +25,5 @@ urlpatterns = [
path('user/<int:pk>/aliases/', views.ProfileAliasView.as_view(), name="user_alias"), path('user/<int:pk>/aliases/', views.ProfileAliasView.as_view(), name="user_alias"),
path('user/<int:pk>/trust', views.ProfileTrustView.as_view(), name="user_trust"), path('user/<int:pk>/trust', views.ProfileTrustView.as_view(), name="user_trust"),
path('manage-auth-token/', views.ManageAuthTokens.as_view(), name='auth_token'), path('manage-auth-token/', views.ManageAuthTokens.as_view(), name='auth_token'),
path('user/<int:pk>/qr_code/', views.QRCodeView.as_view(), name='qr_code'),
] ]

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 import DetailView, UpdateView, TemplateView
from django.views.generic.edit import FormMixin from django.views.generic.edit import FormMixin
from django_tables2.views import MultiTableMixin, SingleTableMixin, SingleTableView from django_tables2.views import MultiTableMixin, SingleTableMixin, SingleTableView
from django_tables2.export.views import ExportMixin
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from api.viewsets import is_regex from api.viewsets import is_regex
from note.models import Alias, NoteClub, NoteUser, Trust 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.backends import PermissionBackend
from permission.models import Role from permission.models import Role
from permission.views import ProtectQuerysetMixin, ProtectedCreateView from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from family.models import Family
from django import forms from django import forms
from .forms import UserForm, ProfileForm, ImageForm, ClubForm, MembershipForm, \ from .forms import UserForm, ProfileForm, ImageForm, ClubForm, MembershipForm, \
@@ -50,15 +48,6 @@ class CustomLoginView(LoginView):
self.request.session['permission_mask'] = form.cleaned_data['permission_mask'].rank self.request.session['permission_mask'] = form.cleaned_data['permission_mask'].rank
return super().form_valid(form) return super().form_valid(form)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user_agent = self.request.META.get('HTTP_USER_AGENT', '').lower()
context['display_appstore_badge'] = 'iphone' in user_agent or 'android' not in user_agent
context['display_playstore_badge'] = 'android' in user_agent or 'iphone' not in user_agent
return context
class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
""" """
@@ -217,10 +206,6 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
modified_note.is_active = True modified_note.is_active = True
context["can_unlock_note"] = not user.note.is_active and PermissionBackend\ context["can_unlock_note"] = not user.note.is_active and PermissionBackend\
.check_perm(self.request, "note.change_noteuser_is_active", modified_note) .check_perm(self.request, "note.change_noteuser_is_active", modified_note)
if 'family' in settings.INSTALLED_APPS:
context["family_app_installed"] = True
families = Family.objects.filter(memberships__user=user).distinct()
context["families"] = families
return context return context
@@ -417,6 +402,14 @@ class ManageAuthTokens(LoginRequiredMixin, TemplateView):
context['token'] = Token.objects.get_or_create(user=self.request.user)[0] context['token'] = Token.objects.get_or_create(user=self.request.user)[0]
return context return context
class QRCodeView(LoginRequiredMixin, DetailView):
"""
Affiche le QR Code
"""
model = User
context_object_name = "user_object"
template_name = "member/qr_code.html"
extra_context = {"title": _("QR Code")}
# ******************************* # # ******************************* #
# CLUB # # CLUB #
@@ -960,12 +953,11 @@ class ClubManageRolesView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
return reverse_lazy('member:user_detail', kwargs={'pk': self.object.user.id}) 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 model = Membership
table_class = MembershipTable table_class = MembershipTable
template_name = "member/club_members.html" template_name = "member/club_members.html"
extra_context = {"title": _("Members of the club")} extra_context = {"title": _("Members of the club")}
export_formats = ["csv"]
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
qs = super().get_queryset().filter(club_id=self.kwargs["pk"]) qs = super().get_queryset().filter(club_id=self.kwargs["pk"])
@@ -997,14 +989,6 @@ class ClubMembersListView(ExportMixin, ProtectQuerysetMixin, LoginRequiredMixin,
return qs.distinct() 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): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
club = Club.objects.filter( club = Club.objects.filter(

View File

@@ -228,7 +228,7 @@ function consume (source, source_alias, dest, quantity, amount, reason, type, ca
addMsg(interpolate(gettext('Warning, the transaction from the note %s succeed, ' + addMsg(interpolate(gettext('Warning, the transaction from the note %s succeed, ' +
'but the emitter note %s is negative.'), [source_alias, source_alias]), 'warning', 30000) 'but the emitter note %s is negative.'), [source_alias, source_alias]), 'warning', 30000)
} }
if (source.membership && source.membership.date_end <= new Date().toISOString()) { if (source.membership && source.membership.date_end < new Date().toISOString()) {
addMsg(interpolate(gettext('Warning, the emitter note %s is no more a BDE member.'), [source_alias]), addMsg(interpolate(gettext('Warning, the emitter note %s is no more a BDE member.'), [source_alias]),
'danger', 30000) 'danger', 30000)
} }

View File

@@ -67,8 +67,6 @@ $(document).ready(function () {
last.quantity = 1 last.quantity = 1
if (last.note.club) { if (last.note.club) {
$('#last_name').val(last.note.name) $('#last_name').val(last.note.name)
$('#first_name').val(last.note.name) $('#first_name').val(last.note.name)
@@ -113,7 +111,6 @@ $(document).ready(function () {
dest.removeClass('d-none') dest.removeClass('d-none')
$('#dest_note_list').removeClass('d-none') $('#dest_note_list').removeClass('d-none')
$('#debit_type').addClass('d-none') $('#debit_type').addClass('d-none')
$('#reason').val('')
$('#source_note_label').text(select_emitters_label) $('#source_note_label').text(select_emitters_label)
$('#dest_note_label').text(select_receveirs_label) $('#dest_note_label').text(select_receveirs_label)
@@ -137,7 +134,6 @@ $(document).ready(function () {
dest.val('') dest.val('')
dest.tooltip('hide') dest.tooltip('hide')
$('#debit_type').addClass('d-none') $('#debit_type').addClass('d-none')
$('#reason').val('Rechargement note')
$('#source_note_label').text(transfer_type_label) $('#source_note_label').text(transfer_type_label)
$('#dest_note_label').text(select_receveir_label) $('#dest_note_label').text(select_receveir_label)
@@ -166,7 +162,6 @@ $(document).ready(function () {
dest.addClass('d-none') dest.addClass('d-none')
dest.tooltip('hide') dest.tooltip('hide')
$('#debit_type').removeClass('d-none') $('#debit_type').removeClass('d-none')
$('#reason').val('')
$('#source_note_label').text(select_emitter_label) $('#source_note_label').text(select_emitter_label)
$('#dest_note_label').text(transfer_type_label) $('#dest_note_label').text(transfer_type_label)
@@ -310,10 +305,10 @@ $('#btn_transfer').click(function () {
destination: dest.note.id, destination: dest.note.id,
destination_alias: dest.name destination_alias: dest.name
}).done(function () { }).done(function () {
if (source.note.membership && source.note.membership.date_end <= new Date().toISOString()) { if (source.note.membership && source.note.membership.date_end < new Date().toISOString()) {
addMsg(interpolate(gettext('Warning, the emitter note %s is no more a BDE member.'), [source.name]), 'danger', 30000) addMsg(interpolate(gettext('Warning, the emitter note %s is no more a BDE member.'), [source.name]), 'danger', 30000)
} }
if (dest.note.membership && dest.note.membership.date_end <= new Date().toISOString()) { if (dest.note.membership && dest.note.membership.date_end < new Date().toISOString()) {
addMsg(interpolate(gettext('Warning, the destination note %s is no more a BDE member.'), [dest.name]), 'danger', 30000) addMsg(interpolate(gettext('Warning, the destination note %s is no more a BDE member.'), [dest.name]), 'danger', 30000)
} }
@@ -414,7 +409,7 @@ $('#btn_transfer').click(function () {
bank: $('#bank').val() bank: $('#bank').val()
}).done(function () { }).done(function () {
addMsg(gettext('Credit/debit succeed!'), 'success', 10000) addMsg(gettext('Credit/debit succeed!'), 'success', 10000)
if (user_note.membership && user_note.membership.date_end <= new Date().toISOString()) { addMsg(gettext('Warning, the emitter note %s is no more a BDE member.'), 'danger', 10000) } if (user_note.membership && user_note.membership.date_end < new Date().toISOString()) { addMsg(gettext('Warning, the emitter note %s is no more a BDE member.'), 'danger', 10000) }
reset() reset()
}).fail(function (err) { }).fail(function (err) {
const errObj = JSON.parse(err.responseText) const errObj = JSON.parse(err.responseText)

View File

@@ -26,42 +26,24 @@ class PermissionBackend(ModelBackend):
@staticmethod @staticmethod
@memoize @memoize
def get_raw_permissions(request, t): # noqa: C901 def get_raw_permissions(request, t):
""" """
Query permissions of a certain type for a user, then memoize it. Query permissions of a certain type for a user, then memoize it.
:param request: The current request :param request: The current request
:param t: The type of the permissions: view, change, add or delete :param t: The type of the permissions: view, change, add or delete
:return: The queryset of the permissions of the user (memoized) grouped by clubs :return: The queryset of the permissions of the user (memoized) grouped by clubs
""" """
# Permission for auth if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'):
if hasattr(request, 'oauth2') and request.oauth2 is not None and 'scope' in request.oauth2:
# OAuth2 Authentication # OAuth2 Authentication
user = request.oauth2['user']
def permission_filter(membership_obj):
query = Q(pk=-1)
for scope in request.oauth2['scope']:
if scope == "openid":
continue
permission_id, club_id = scope.split('_')
if int(club_id) == membership_obj.club_id:
query |= Q(pk=permission_id, mask__rank__lte=request.oauth2['mask'])
return query
# Restreint token permission to his scope
elif hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'):
user = request.auth.user user = request.auth.user
def permission_filter(membership_obj): def permission_filter(membership_obj):
query = Q(pk=-1) query = Q(pk=-1)
for scope in request.auth.scope.split(' '): for scope in request.auth.scope.split(' '):
if scope == "openid" or scope == "0_0":
continue
permission_id, club_id = scope.split('_') permission_id, club_id = scope.split('_')
if int(club_id) == membership_obj.club_id: if int(club_id) == membership_obj.club_id:
query |= Q(pk=permission_id) query |= Q(pk=permission_id)
return query return query
else: else:
user = request.user user = request.user
@@ -95,6 +77,7 @@ class PermissionBackend(ModelBackend):
:param type: The type of the permissions: view, change, add or delete :param type: The type of the permissions: view, change, add or delete
:return: A generator of the requested permissions :return: A generator of the requested permissions
""" """
if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'): if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'):
# OAuth2 Authentication # OAuth2 Authentication
user = request.auth.user user = request.auth.user

View File

@@ -927,7 +927,7 @@
"note", "note",
"transactiontemplate" "transactiontemplate"
], ],
"query": "[\"AND\", {\"destination\": [\"club\", \"note\"]}, {\"category__name\": \"Clubs\"}]", "query": "{\"destination\": [\"club\", \"note\"]}",
"type": "view", "type": "view",
"mask": 2, "mask": 2,
"field": "", "field": "",
@@ -943,7 +943,7 @@
"note", "note",
"transactiontemplate" "transactiontemplate"
], ],
"query": "[\"AND\", {\"destination\": [\"club\", \"note\"]}, {\"category__name\": \"Clubs\"}]", "query": "{\"destination\": [\"club\", \"note\"]}",
"type": "add", "type": "add",
"mask": 3, "mask": 3,
"field": "", "field": "",
@@ -959,7 +959,7 @@
"note", "note",
"transactiontemplate" "transactiontemplate"
], ],
"query": "[\"AND\", {\"destination\": [\"club\", \"note\"]}, {\"category__name\": \"Clubs\"}]", "query": "{\"destination\": [\"club\", \"note\"]}",
"type": "change", "type": "change",
"mask": 3, "mask": 3,
"field": "", "field": "",
@@ -1391,12 +1391,12 @@
"wei", "wei",
"weiregistration" "weiregistration"
], ],
"query": "[\"AND\", {\"wei\": [\"club\"], \"wei__membership_end__gte\": [\"today\"]}, {\"deposit_type\": \"note\"}]", "query": "{\"wei\": [\"club\"], \"wei__membership_end__gte\": [\"today\"]}",
"type": "change", "type": "change",
"mask": 2, "mask": 2,
"field": "deposit_given", "field": "caution_check",
"permanent": false, "permanent": false,
"description": "Autoriser une transaction de caution WEI" "description": "Dire si un chèque de caution est donné pour une inscription WEI"
} }
}, },
{ {
@@ -3486,22 +3486,6 @@
"description": "Voir la bouffe servie" "description": "Voir la bouffe servie"
} }
}, },
{
"model": "permission.permission",
"pk": 223,
"fields": {
"model": [
"note",
"templatecategory"
],
"query": "{\"name\": \"Clubs\"}",
"type": "view",
"mask": 2,
"field": "",
"permanent": false,
"description": "Voir la catégorie de bouton Clubs"
}
},
{ {
"model": "permission.permission", "model": "permission.permission",
"pk": 239, "pk": 239,
@@ -4363,570 +4347,7 @@
"mask": 3, "mask": 3,
"field": "", "field": "",
"permanent": false, "permanent": false,
"description": "Faire adhérer BDE ou Kfet" "description": "Ajouter un membre au BDE ou à la Kfet"
}
},
{
"model": "permission.permission",
"pk": 293,
"fields": {
"model": [
"wei",
"weimembership"
],
"query": "[\"AND\", {\"bus\": [\"membership\", \"weimembership\", \"bus\"]}, {\"club\": [\"club\"], \"club__weiclub__membership_end__gte\": [\"today\"]}]",
"type": "change",
"mask": 2,
"field": "team",
"permanent": false,
"description": "Modifier l'équipe d'une adhésion WEI à son bus"
}
},
{
"model": "permission.permission",
"pk": 294,
"fields": {
"model": [
"wei",
"weiregistration"
],
"query": "[\"AND\", {\"wei__year\": [\"today\", \"year\"], \"wei__membership_start__lte\": [\"today\"], \"wei__membership_end__gte\": [\"today\"]}, {\"deposit_type\": \"check\"}]",
"type": "change",
"mask": 2,
"field": "deposit_given",
"permanent": false,
"description": "Dire si un chèque de caution a été donné"
}
},
{
"model": "permission.permission",
"pk": 295,
"fields": {
"model": [
"wei",
"weiregistration"
],
"query": "{\"wei__year\": [\"today\", \"year\"]}",
"type": "view",
"mask": 2,
"field": "",
"permanent": false,
"description": "Voir toutes les inscriptions au WEI courant"
}
},
{
"model": "permission.permission",
"pk": 296,
"fields": {
"model": [
"wei",
"weimembership"
],
"query": "{\"club__weiclub__year\": [\"today\", \"year\"]}",
"type": "view",
"mask": 2,
"field": "",
"permanent": false,
"description": "Voir toutes les adhésions au WEI courant"
}
},
{
"model": "permission.permission",
"pk": 297,
"fields": {
"model": [
"wei",
"weiregistration"
],
"query": "[\"AND\", {\"user\": [\"user\"], \"wei__membership_start__lte\": [\"today\"], \"wei__membership_end__gte\": [\"today\"]}, [\"OR\", {\"wei\": [\"club\"]}, {\"wei__year\": [\"today\", \"year\"], \"membership\": null}]]",
"type": "change",
"mask": 1,
"field": "deposit_type",
"permanent": false,
"description": "Modifier le type de caution de mon inscription WEI tant qu'elle n'est pas validée"
}
},
{
"model": "permission.permission",
"pk": 298,
"fields": {
"model": [
"wei",
"bus"
],
"query": "{\"pk\": [\"membership\", \"weimembership\", \"bus\", \"pk\"], \"wei__date_end__gte\": [\"today\"]}",
"type": "change",
"mask": 2,
"field": "information_json",
"permanent": false,
"description": "Modifier les informations du bus"
}
},
{
"model": "permission.permission",
"pk": 311,
"fields": {
"model": [
"family",
"family"
],
"query": "{}",
"type": "view",
"mask": 1,
"field": "",
"permanent": false,
"description": "Voir toutes les familles"
}
},
{
"model": "permission.permission",
"pk": 312,
"fields": {
"model": [
"family",
"family"
],
"query": "{}",
"type": "add",
"mask": 2,
"field": "",
"permanent": false,
"description": "Créer une famille"
}
},
{
"model": "permission.permission",
"pk": 313,
"fields": {
"model": [
"family",
"family"
],
"query": "{}",
"type": "change",
"mask": 2,
"field": "",
"permanent": false,
"description": "Modifier n'importe quelle famille"
}
},
{
"model": "permission.permission",
"pk": 314,
"fields": {
"model": [
"family",
"family"
],
"query": "{\"pk\": [\"user\", \"family_memberships\", \"family\", \"pk\"]}",
"type": "change",
"mask": 2,
"field": "",
"permanent": false,
"description": "Modifier ma famille"
}
},
{
"model": "permission.permission",
"pk": 315,
"fields": {
"model": [
"family",
"familymembership"
],
"query": "{}",
"type": "view",
"mask": 2,
"field": "",
"permanent": false,
"description": "Voir les membres de n'importe quelle famille"
}
},
{
"model": "permission.permission",
"pk": 316,
"fields": {
"model": [
"family",
"familymembership"
],
"query": "{\"family\": [\"user\", \"family_memberships\", \"family\"]}",
"type": "view",
"mask": 1,
"field": "",
"permanent": false,
"description": "Voir les membres de ma famille"
}
},
{
"model": "permission.permission",
"pk": 317,
"fields": {
"model": [
"family",
"familymembership"
],
"query": "{}",
"type": "add",
"mask": 2,
"field": "",
"permanent": false,
"description": "Ajouter un membre à n'importe quelle famille"
}
},
{
"model": "permission.permission",
"pk": 318,
"fields": {
"model": [
"family",
"familymembership"
],
"query": "{\"family\": [\"user\", \"family_memberships\", \"family\"]}",
"type": "add",
"mask": 2,
"field": "",
"permanent": false,
"description": "Ajouter un membre à ma famille"
}
},
{
"model": "permission.permission",
"pk": 319,
"fields": {
"model": [
"family",
"challenge"
],
"query": "{}",
"type": "view",
"mask": 1,
"field": "",
"permanent": false,
"description": "Voir tous les défis"
}
},
{
"model": "permission.permission",
"pk": 320,
"fields": {
"model": [
"family",
"challenge"
],
"query": "{}",
"type": "add",
"mask": 2,
"field": "",
"permanent": false,
"description": "Créer un défi"
}
},
{
"model": "permission.permission",
"pk": 321,
"fields": {
"model": [
"family",
"challenge"
],
"query": "{}",
"type": "change",
"mask": 2,
"field": "",
"permanent": false,
"description": "Modifier un défi"
}
},
{
"model": "permission.permission",
"pk": 322,
"fields": {
"model": [
"family",
"challenge"
],
"query": "{}",
"type": "delete",
"mask": 2,
"field": "{}",
"permanent": false,
"description": "Supprimer un défi"
}
},
{
"model": "permission.permission",
"pk": 323,
"fields": {
"model": [
"family",
"achievement"
],
"query": "{}",
"type": "view",
"mask": 1,
"field": "",
"permanent": false,
"description": "Voir tous les succès"
}
},
{
"model": "permission.permission",
"pk": 324,
"fields": {
"model": [
"family",
"achievement"
],
"query": "{}",
"type": "add",
"mask": 2,
"field": "",
"permanent": false,
"description": "Créer un succès"
}
},
{
"model": "permission.permission",
"pk": 325,
"fields": {
"model": [
"family",
"achievement"
],
"query": "{}",
"type": "change",
"mask": 1,
"field": "valid",
"permanent": false,
"description": "Valider un succès"
}
},
{
"model": "permission.permission",
"pk": 326,
"fields": {
"model": [
"family",
"achievement"
],
"query": "{}",
"type": "delete",
"mask": 1,
"field": "",
"permanent": false,
"description": "Supprimer un succès"
}
},
{
"model": "permission.permission",
"pk": 330,
"fields": {
"model": [
"auth",
"user"
],
"query": "{\"memberships__club\": [\"club\"]}",
"type": "view",
"mask": 2,
"field": "email",
"permanent": false,
"description": "Voir l'adresse mail des membres de son club"
}
},
{
"model": "permission.permission",
"pk": 331,
"fields": {
"model": [
"food",
"dish"
],
"query": "{\"activity__organizer\": [\"club\"]}",
"type": "create",
"mask": 2,
"permanent": false,
"description": "Créer un plat vendu par son club"
}
},
{
"model": "permission.permission",
"pk": 332,
"fields": {
"model": [
"food",
"dish"
],
"query": "{\"activity__organizer\": [\"club\"]}",
"type": "change",
"mask": 2,
"permanent": false,
"description": "Modifier un plat vendu par son club"
}
},
{
"model": "permission.permission",
"pk": 333,
"fields": {
"model": [
"food",
"dish"
],
"query": "{\"activity__organizer\": [\"club\"]}",
"type": "view",
"mask": 2,
"permanent": false,
"description": "Voir les plats vendus par son club"
}
},
{
"model": "permission.permission",
"pk": 334,
"fields": {
"model": [
"food",
"dish"
],
"query": "[\"AND\", {\"activity__open\": true}, {\"available\": true}]",
"type": "view",
"mask": 1,
"permanent": false,
"description": "Voir les plats disponibles"
}
},
{
"model": "permission.permission",
"pk": 335,
"fields": {
"model": [
"food",
"supplement"
],
"query": "{\"dish__main__owner\": [\"club\"]}",
"type": "create",
"mask": 2,
"permanent": false,
"description": "Ajouter un supplément à un plat de son club"
}
},
{
"model": "permission.permission",
"pk": 336,
"fields": {
"model": [
"food",
"supplement"
],
"query": "{\"dish__main__owner\": [\"club\"]}",
"type": "change",
"mask": 2,
"permanent": false,
"description": "Modifier un supplément d'un plat de son club"
}
},
{
"model": "permission.permission",
"pk": 337,
"fields": {
"model": [
"food",
"supplement"
],
"query": "{\"dish__main__owner\": [\"club\"]}",
"type": "view",
"mask": 2,
"permanent": false,
"description": "Voir les suppléments des plats de son club"
}
},
{
"model": "permission.permission",
"pk": 337,
"fields": {
"model": [
"food",
"supplement"
],
"query": "[\"AND\", {\"dish__activity__open\": true}, {\"dish__available\": true}]",
"type": "view",
"mask": 1,
"permanent": false,
"description": "Voir les suppléments des plats disponibles"
}
},
{
"model": "permission.permission",
"pk": 338,
"fields": {
"model": [
"food",
"supplement"
],
"query": "{\"dish__main__owner\": [\"club\"]}",
"type": "delete",
"mask": 2,
"permanent": false,
"description": "Supprimer un supplément d'un plat de son club"
}
},
{
"model": "permission.permission",
"pk": 339,
"fields": {
"model": [
"food",
"order"
],
"query": "[\"AND\", {\"dish__activity__open\": true, \"dish__available\": true}, {\"user\": [\"user\"]}]",
"type": "create",
"mask": 1,
"permanent": false,
"description": "Commander un plat"
}
},
{
"model": "permission.permission",
"pk": 340,
"fields": {
"model": [
"food",
"order"
],
"query": "[\"AND\", {\"dish__activity__open\": true}, {\"user\": [\"user\"]}]",
"type": "view",
"mask": 1,
"permanent": false,
"description": "Voir ses commandes pour les activités ouvertes"
}
},
{
"model": "permission.permission",
"pk": 341,
"fields": {
"model": [
"food",
"order"
],
"query": "{\"activity__open\": true, \"activity__organizer\": [\"club\"]}",
"type": "view",
"mask": 2,
"permanent": false,
"description": "Voir toutes les commandes pour les activités ouvertes de son club"
}
},
{
"model": "permission.permission",
"pk": 342,
"fields": {
"model": [
"food",
"order"
],
"query": "{\"activity__open\": true, \"activity__organizer\": [\"club\"]}",
"type": "change",
"mask": 2,
"permanent": false,
"description": "Modifier un commande non servie d'une activité de son club"
} }
}, },
{ {
@@ -4983,11 +4404,7 @@
249, 249,
255, 255,
256, 256,
257, 257
311,
316,
319,
323
] ]
} }
}, },
@@ -5027,8 +4444,7 @@
159, 159,
160, 160,
212, 212,
222, 222
297
] ]
} }
}, },
@@ -5076,11 +4492,7 @@
221, 221,
247, 247,
258, 258,
259, 259
260,
263,
265,
330
] ]
} }
}, },
@@ -5092,6 +4504,7 @@
"name": "Pr\u00e9sident\u22c5e de club", "name": "Pr\u00e9sident\u22c5e de club",
"permissions": [ "permissions": [
62, 62,
135,
142 142
] ]
} }
@@ -5107,6 +4520,7 @@
19, 19,
20, 20,
21, 21,
27,
59, 59,
60, 60,
61, 61,
@@ -5117,7 +4531,6 @@
182, 182,
184, 184,
185, 185,
223,
239, 239,
240, 240,
241 241
@@ -5218,10 +4631,7 @@
176, 176,
177, 177,
178, 178,
183, 183
294,
295,
296
] ]
} }
}, },
@@ -5354,6 +4764,7 @@
"name": "Chef\u22c5fe de bus", "name": "Chef\u22c5fe de bus",
"permissions": [ "permissions": [
22, 22,
84,
115, 115,
117, 117,
118, 118,
@@ -5367,9 +4778,7 @@
287, 287,
289, 289,
290, 290,
291, 291
293,
298
] ]
} }
}, },
@@ -5381,6 +4790,7 @@
"name": "Chef\u22c5fe d'\u00e9quipe", "name": "Chef\u22c5fe d'\u00e9quipe",
"permissions": [ "permissions": [
22, 22,
84,
116, 116,
123, 123,
124, 124,
@@ -5395,7 +4805,20 @@
"for_club": null, "for_club": null,
"name": "\u00c9lectron libre", "name": "\u00c9lectron libre",
"permissions": [ "permissions": [
22 22,
84
]
}
},
{
"model": "permission.role",
"pk": 16,
"fields": {
"for_club": null,
"name": "\u00c9lectron libre (avec perm)",
"permissions": [
22,
84
] ]
} }
}, },
@@ -5429,7 +4852,6 @@
"permissions": [ "permissions": [
37, 37,
41, 41,
42,
53, 53,
54, 54,
55, 55,
@@ -5481,15 +4903,7 @@
168, 168,
176, 176,
177, 177,
197, 197
211,
212,
213,
214,
215,
216,
311,
319
] ]
} }
}, },
@@ -5555,6 +4969,7 @@
"name": "Référent⋅e Bus", "name": "Référent⋅e Bus",
"permissions": [ "permissions": [
22, 22,
84,
115, 115,
117, 117,
118, 118,
@@ -5568,9 +4983,7 @@
287, 287,
289, 289,
290, 290,
291, 291
293,
298
] ]
} }
}, },
@@ -5660,39 +5073,6 @@
] ]
} }
}, },
{
"model": "permission.role",
"pk": 34,
"fields": {
"for_club": 1,
"name": "Chef·fe de famille",
"permissions": [
314,
318,
324
]
}
},
{
"model": "permission.role",
"pk": 35,
"fields": {
"for_club": 1,
"name": "Respo familles",
"permissions": [
312,
313,
315,
317,
320,
321,
322,
324,
325,
326
]
}
},
{ {
"model": "wei.weirole", "model": "wei.weirole",
"pk": 12, "pk": 12,
@@ -5713,6 +5093,11 @@
"pk": 15, "pk": 15,
"fields": {} "fields": {}
}, },
{
"model": "wei.weirole",
"pk": 16,
"fields": {}
},
{ {
"model": "wei.weirole", "model": "wei.weirole",
"pk": 17, "pk": 17,

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