mirror of
https://gitlab.crans.org/bde/nk20
synced 2025-10-31 23:54:30 +01:00
Compare commits
25 Commits
family
...
note_sheet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cffe94bae | ||
|
|
78372807f8 | ||
|
|
b9bf01f2e3 | ||
|
|
624f94823c | ||
|
|
30a598c0b7 | ||
|
|
6bf21b103f | ||
|
|
d4cb464169 | ||
|
|
27a1f36183 | ||
|
|
83c8b9a3d0 | ||
|
|
cb3b34f874 | ||
|
|
0962a3735e | ||
|
|
9907cfbd86 | ||
|
|
ad90887691 | ||
|
|
47d2476b51 | ||
|
|
5d8720cf46 | ||
|
|
8700144dea | ||
|
|
d17ab26f2f | ||
|
|
297f289d7e | ||
|
|
034ad9a4ce | ||
|
|
897d37f74d | ||
|
|
42fb0aa2d6 | ||
|
|
4bc43ec3cb | ||
|
|
00737da69f | ||
|
|
0934b8fa34 | ||
|
|
7633c9ab4b |
@@ -48,5 +48,15 @@
|
|||||||
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -37,6 +37,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
<div id="guests_table">
|
<div id="guests_table">
|
||||||
{% render_table guests %}
|
{% render_table guests %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-footer text-center">
|
||||||
|
<button class="btn btn-block btn-primary mb-3" onclick="window.location.href='?_export=1&table=guests'">
|
||||||
|
{% trans "Export to CSV" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base_search.html" %}
|
||||||
{% comment %}
|
{% comment %}
|
||||||
SPDX-License-Identifier: GPL-3.0-or-later
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
@@ -44,6 +44,8 @@ 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 table %}
|
{% render_table all %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{ block.super }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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 %}
|
{% load i18n perms pretty_money dict_get %}
|
||||||
{% 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,6 +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">
|
||||||
@@ -60,6 +66,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
<a class="btn btn-warning btn-sm my-1" href="{% url 'activity:activity_entry' pk=activity.pk %}"> {% trans "Entry page" %}</a>
|
<a class="btn btn-warning btn-sm my-1" href="{% url 'activity:activity_entry' pk=activity.pk %}"> {% trans "Entry page" %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if activity.activity_type.name == "Perm bouffe" %}
|
||||||
|
<a class="btn btn-warning btn-sm my-1" href="{% url 'food:dish_list' activity_pk=activity.pk %}"> {% trans "Dish page" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if request.path_info == activity_detail_url %}
|
{% if request.path_info == activity_detail_url %}
|
||||||
{% if activity.valid and ".change__open"|has_perm:activity %}
|
{% if activity.valid and ".change__open"|has_perm:activity %}
|
||||||
<a class="btn btn-warning btn-sm my-1" id="open_activity"> {% if activity.open %}{% trans "close"|capfirst %}{% else %}{% trans "open"|capfirst %}{% endif %}</a>
|
<a class="btn btn-warning btn-sm my-1" id="open_activity"> {% if activity.open %}{% trans "close"|capfirst %}{% else %}{% trans "open"|capfirst %}{% endif %}</a>
|
||||||
|
|||||||
0
apps/activity/templatetags/__init__.py
Normal file
0
apps/activity/templatetags/__init__.py
Normal file
12
apps/activity/templatetags/dict_get.py
Normal file
12
apps/activity/templatetags/dict_get.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# 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)
|
||||||
@@ -67,32 +67,65 @@ 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
|
# first table = all activities, second table = upcoming, third table = search
|
||||||
|
|
||||||
|
# table search
|
||||||
|
qs = self.get_queryset().order_by('-date_start')
|
||||||
|
if "search" in self.request.GET and self.request.GET['search']:
|
||||||
|
pattern = self.request.GET['search']
|
||||||
|
|
||||||
|
# check regex
|
||||||
|
valid_regex = is_regex(pattern)
|
||||||
|
suffix = '__iregex' if valid_regex else '__istartswith'
|
||||||
|
prefix = '^' if valid_regex else ''
|
||||||
|
qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern})
|
||||||
|
| Q(**{f'organizer__name{suffix}': prefix + pattern})
|
||||||
|
| Q(**{f'organizer__note__alias__name{suffix}': prefix + pattern}))
|
||||||
|
else:
|
||||||
|
qs = qs.none()
|
||||||
|
search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Activity, 'view'))
|
||||||
|
|
||||||
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(["table", "upcoming"], tables):
|
for name, table in zip(["all", "upcoming", "table"], 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
|
||||||
|
|
||||||
|
|
||||||
@@ -103,12 +136,19 @@ 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 = [
|
||||||
lambda data: GuestTable(data, prefix="guests-"),
|
GuestTable,
|
||||||
lambda data: OpenerTable(data, prefix="opener-"),
|
OpenerTable,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -117,6 +157,51 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMix
|
|||||||
.filter(PermissionBackend.filter_queryset(self.request, Opener, "view")),
|
.filter(PermissionBackend.filter_queryset(self.request, Opener, "view")),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def render_to_response(self, context, **response_kwargs):
|
||||||
|
"""
|
||||||
|
Gère l'export CSV manuel pour MultiTableMixin.
|
||||||
|
"""
|
||||||
|
if "_export" in self.request.GET:
|
||||||
|
import tablib
|
||||||
|
table_name = self.request.GET.get("table")
|
||||||
|
if table_name:
|
||||||
|
tables = self.get_tables()
|
||||||
|
data_list = self.get_tables_data()
|
||||||
|
|
||||||
|
for t, d in zip(tables, data_list):
|
||||||
|
if t.prefix == table_name:
|
||||||
|
# Préparer le CSV
|
||||||
|
dataset = tablib.Dataset()
|
||||||
|
columns = list(t.base_columns) # noms des colonnes
|
||||||
|
dataset.headers = columns
|
||||||
|
|
||||||
|
for row in d:
|
||||||
|
values = []
|
||||||
|
for col in columns:
|
||||||
|
try:
|
||||||
|
val = getattr(row, col, "")
|
||||||
|
# Gestion spéciale pour la colonne 'entry'
|
||||||
|
if col == "entry":
|
||||||
|
if getattr(row, "has_entry", False):
|
||||||
|
val = timezone.localtime(row.entry.time).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
else:
|
||||||
|
val = ""
|
||||||
|
values.append(str(val) if val is not None else "")
|
||||||
|
except Exception: # RelatedObjectDoesNotExist ou autre
|
||||||
|
values.append("")
|
||||||
|
dataset.append(values)
|
||||||
|
|
||||||
|
csv_bytes = dataset.export("csv")
|
||||||
|
if isinstance(csv_bytes, str):
|
||||||
|
csv_bytes = csv_bytes.encode("utf-8")
|
||||||
|
|
||||||
|
response = HttpResponse(csv_bytes, content_type="text/csv")
|
||||||
|
response["Content-Disposition"] = f'attachment; filename="{table_name}.csv"'
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Sinon rendu normal
|
||||||
|
return super().render_to_response(context, **response_kwargs)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data()
|
context = super().get_context_data()
|
||||||
|
|
||||||
@@ -137,6 +222,14 @@ 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}
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode
|
from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order, FoodTransaction
|
||||||
|
|
||||||
|
|
||||||
class AllergenSerializer(serializers.ModelSerializer):
|
class AllergenSerializer(serializers.ModelSerializer):
|
||||||
@@ -21,9 +21,13 @@ 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 = '__all__'
|
fields = ['name', 'owner', 'allergens', 'expiry_date', 'end_of_life', 'is_ready', 'order', 'owner_name']
|
||||||
|
|
||||||
|
|
||||||
class BasicFoodSerializer(serializers.ModelSerializer):
|
class BasicFoodSerializer(serializers.ModelSerializer):
|
||||||
@@ -54,3 +58,43 @@ 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__'
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# 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):
|
||||||
@@ -13,3 +14,7 @@ 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)
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ 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, \
|
||||||
from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode
|
DishSerializer, SupplementSerializer, OrderSerializer, FoodTransactionSerializer
|
||||||
|
from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order, FoodTransaction
|
||||||
|
|
||||||
|
|
||||||
class AllergenViewSet(ReadProtectedModelViewSet):
|
class AllergenViewSet(ReadProtectedModelViewSet):
|
||||||
@@ -72,3 +73,55 @@ 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', ]
|
||||||
|
|||||||
@@ -4,15 +4,16 @@
|
|||||||
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.widgets import NumberInput
|
from django.forms.widgets import NumberInput
|
||||||
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
|
from note_kfet.inputs import Autocomplete, AmountInput
|
||||||
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
|
from .models import Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order
|
||||||
|
|
||||||
|
|
||||||
class QRCodeForms(forms.ModelForm):
|
class QRCodeForms(forms.ModelForm):
|
||||||
@@ -166,7 +167,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"},
|
"class": "autocomplete manageingredients-autocomplete"},
|
||||||
)
|
)
|
||||||
name.label = _('Name')
|
name.label = _('Name')
|
||||||
|
|
||||||
@@ -180,8 +181,70 @@ 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")
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# 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')},
|
||||||
|
),
|
||||||
|
]
|
||||||
19
apps/food/migrations/0004_alter_foodtransaction_order.py
Normal file
19
apps/food/migrations/0004_alter_foodtransaction_order.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -4,10 +4,14 @@
|
|||||||
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):
|
||||||
@@ -252,7 +256,7 @@ class TransformedFood(Food):
|
|||||||
self.allergens.set(self.allergens.union(child.allergens.all()))
|
self.allergens.set(self.allergens.union(child.allergens.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, force_update, using, update_fields)
|
return super().save(force_insert=False, force_update=force_update, using=using, update_fields=update_fields)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _('Transformed food')
|
verbose_name = _('Transformed food')
|
||||||
@@ -284,3 +288,196 @@ 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))
|
||||||
|
|
||||||
|
|
||||||
|
class Order(models.Model):
|
||||||
|
"""
|
||||||
|
An order is a dish ordered by a member during an activity
|
||||||
|
"""
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='food_orders',
|
||||||
|
verbose_name=_('user'),
|
||||||
|
)
|
||||||
|
|
||||||
|
activity = models.ForeignKey(
|
||||||
|
Activity,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='food_orders',
|
||||||
|
verbose_name=_('activity'),
|
||||||
|
)
|
||||||
|
|
||||||
|
dish = models.ForeignKey(
|
||||||
|
Dish,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='orders',
|
||||||
|
verbose_name=_('dish'),
|
||||||
|
)
|
||||||
|
|
||||||
|
supplements = models.ManyToManyField(
|
||||||
|
Supplement,
|
||||||
|
related_name='orders',
|
||||||
|
verbose_name=_('supplements'),
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
request = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_('request'),
|
||||||
|
help_text=_('A specific request (to remove an ingredient for example)')
|
||||||
|
)
|
||||||
|
|
||||||
|
number = models.PositiveIntegerField(
|
||||||
|
verbose_name=_('number'),
|
||||||
|
default=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
ordered_at = models.DateTimeField(
|
||||||
|
default=timezone.now,
|
||||||
|
verbose_name=_('order date'),
|
||||||
|
)
|
||||||
|
|
||||||
|
served = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_('served'),
|
||||||
|
)
|
||||||
|
|
||||||
|
served_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_('served date'),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('Order')
|
||||||
|
verbose_name_plural = _('Orders')
|
||||||
|
unique_together = ('activity', 'number', )
|
||||||
|
|
||||||
|
@property
|
||||||
|
def amount(self):
|
||||||
|
return self.dish.price + sum(s.price for s in self.supplements.all())
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return _("Order of {dish} by {user}").format(
|
||||||
|
dish=str(self.dish),
|
||||||
|
user=str(self.user))
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
created = self.pk is None
|
||||||
|
if created:
|
||||||
|
last_order = Order.objects.filter(activity=self.activity).last()
|
||||||
|
if last_order is None:
|
||||||
|
self.number = 1
|
||||||
|
else:
|
||||||
|
self.number = last_order.number + 1
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
transaction = FoodTransaction(
|
||||||
|
order=self,
|
||||||
|
source=self.user.note,
|
||||||
|
destination=self.activity.organizer.note,
|
||||||
|
amount=self.amount,
|
||||||
|
quantity=1,
|
||||||
|
)
|
||||||
|
transaction.save()
|
||||||
|
else:
|
||||||
|
old_object = Order.objects.get(pk=self.pk)
|
||||||
|
if not old_object.served and self.served:
|
||||||
|
self.served_at = timezone.now()
|
||||||
|
self.transaction.save()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class FoodTransaction(Transaction):
|
||||||
|
"""
|
||||||
|
Special type of :model:`note.Transaction` associated to a :model:`food.Order`.
|
||||||
|
"""
|
||||||
|
order = models.OneToOneField(
|
||||||
|
Order,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='transaction',
|
||||||
|
verbose_name=_('order')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("food transaction")
|
||||||
|
verbose_name_plural = _("food transactions")
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
self.valid = self.order.served
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|||||||
45
apps/food/static/food/js/order.js
Normal file
45
apps/food/static/food/js/order.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,20 +2,116 @@
|
|||||||
# 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
|
from .models import Food, Dish, Order
|
||||||
|
|
||||||
|
|
||||||
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', 'allergens', 'expiry_date')
|
fields = ('name', 'owner', 'qr_code_numbers', 'allergens', 'date', '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',
|
||||||
|
}
|
||||||
|
|||||||
25
apps/food/templates/food/dish_confirm_delete.html
Normal file
25
apps/food/templates/food/dish_confirm_delete.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{% 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 %}
|
||||||
44
apps/food/templates/food/dish_detail.html
Normal file
44
apps/food/templates/food/dish_detail.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{% 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 %}
|
||||||
94
apps/food/templates/food/dish_form.html
Normal file
94
apps/food/templates/food/dish_form.html
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
{% 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 %}
|
||||||
33
apps/food/templates/food/dish_list.html
Normal file
33
apps/food/templates/food/dish_list.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -34,6 +34,12 @@ 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>
|
||||||
@@ -58,13 +64,19 @@ 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>
|
||||||
{% if can_add_meal %}
|
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
|
{% if can_add_meal %}
|
||||||
<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 %}
|
||||||
|
{% for activity in open_activities %}
|
||||||
|
<a class="btn btn-sm btn-secondary" href="{% url 'food:dish_list' activity_pk=activity.pk %}">
|
||||||
|
{% trans "View" %} {{ activity.name }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
{% if served.data %}
|
{% if served.data %}
|
||||||
{% render_table served %}
|
{% render_table served %}
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -114,7 +126,26 @@ 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) {
|
||||||
|
|||||||
41
apps/food/templates/food/kitchen.html
Normal file
41
apps/food/templates/food/kitchen.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -22,6 +22,7 @@ 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">
|
||||||
@@ -34,6 +35,7 @@ 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>
|
||||||
|
|||||||
25
apps/food/templates/food/order_confirm_delete.html
Normal file
25
apps/food/templates/food/order_confirm_delete.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{% 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 %}
|
||||||
21
apps/food/templates/food/order_form.html
Normal file
21
apps/food/templates/food/order_form.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{% 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 %}
|
||||||
30
apps/food/templates/food/order_list.html
Normal file
30
apps/food/templates/food/order_list.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load render_table from django_tables2 %}
|
||||||
|
{% load 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%}
|
||||||
21
apps/food/templates/food/served_order_list.html
Normal file
21
apps/food/templates/food/served_order_list.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{% 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%}
|
||||||
17
apps/food/templates/food/supplement_detail.html
Normal file
17
apps/food/templates/food/supplement_detail.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{% 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 %}
|
||||||
0
apps/food/tests/__init__.py
Normal file
0
apps/food/tests/__init__.py
Normal file
@@ -6,9 +6,12 @@ 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, \
|
||||||
from ..models import Allergen, BasicFood, TransformedFood, QRCode
|
DishViewSet, SupplementViewSet, OrderViewSet, FoodTransactionViewSet
|
||||||
|
from ..models import Allergen, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order, FoodTransaction
|
||||||
|
|
||||||
|
|
||||||
class TestFood(TestCase):
|
class TestFood(TestCase):
|
||||||
@@ -53,73 +56,293 @@ class TestFood(TestCase):
|
|||||||
food_container=self.basicfood,
|
food_container=self.basicfood,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_food_list(self):
|
def test_food_list(self):
|
||||||
"""
|
"""
|
||||||
Display food list
|
Display food list
|
||||||
"""
|
"""
|
||||||
response = self.client.get(reverse('food:food_list'))
|
response = self.client.get(reverse('food:food_list'))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_qrcode_create(self):
|
def test_qrcode_create(self):
|
||||||
"""
|
"""
|
||||||
Display QRCode creation
|
Display QRCode creation
|
||||||
"""
|
"""
|
||||||
response = self.client.get(reverse('food:qrcode_create'))
|
response = self.client.get(reverse('food:qrcode_create', kwargs={"slug": 2}))
|
||||||
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'))
|
response = self.client.get(reverse('food:basicfood_create', kwargs={"slug": 2}))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_transformedfood_create(self):
|
def test_transformedfood_create(self):
|
||||||
"""
|
"""
|
||||||
Display TransformedFood creation
|
Display TransformedFood creation
|
||||||
"""
|
"""
|
||||||
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_create(self):
|
def test_food_update(self):
|
||||||
"""
|
"""
|
||||||
Display Food update
|
Display Food update
|
||||||
"""
|
"""
|
||||||
response = self.client.get(reverse('food:food_update'))
|
response = self.client.get(reverse('food:food_update', args=(self.basicfood.pk,)))
|
||||||
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'))
|
response = self.client.get(reverse('food:food_view', args=(self.basicfood.pk,)))
|
||||||
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'))
|
response = self.client.get(reverse('food:basicfood_view', args=(self.basicfood.pk,)))
|
||||||
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'))
|
response = self.client.get(reverse('food:transformedfood_view', args=(self.transformedfood.pk,)))
|
||||||
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'))
|
response = self.client.get(reverse('food:add_ingredient', args=(self.transformedfood.pk,)))
|
||||||
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',
|
||||||
@@ -145,26 +368,84 @@ class TestFoodAPI(TestAPI):
|
|||||||
food_container=self.basicfood,
|
food_container=self.basicfood,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_allergen_api(self):
|
self.activity = Activity.objects.create(
|
||||||
"""
|
activity_type=ActivityType.objects.get(name="Perm bouffe"),
|
||||||
Load Allergen API page and test all filters and permissions
|
organizer=Club.objects.get(name="BDE"),
|
||||||
"""
|
creater=self.user,
|
||||||
self.check_viewset(AllergenViewSet, '/api/food/allergen/')
|
attendees_club_id=1,
|
||||||
|
date_start=timezone.now(),
|
||||||
|
date_end=timezone.now(),
|
||||||
|
name="Test activity",
|
||||||
|
open=True,
|
||||||
|
valid=True,
|
||||||
|
)
|
||||||
|
|
||||||
def test_basicfood_api(self):
|
self.dish = Dish.objects.create(
|
||||||
"""
|
main=self.transformedfood,
|
||||||
Load BasicFood API page and test all filters and permissions
|
price=500,
|
||||||
"""
|
activity=self.activity,
|
||||||
self.check_viewset(BasicFoodViewSet, '/api/food/basicfood/')
|
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):
|
||||||
|
"""
|
||||||
|
Load Allergen API page and test all filters and permissions
|
||||||
|
"""
|
||||||
|
self.check_viewset(AllergenViewSet, '/api/food/allergen/')
|
||||||
|
|
||||||
|
def test_basicfood_api(self):
|
||||||
|
"""
|
||||||
|
Load BasicFood API page and test all filters and permissions
|
||||||
|
"""
|
||||||
|
self.check_viewset(BasicFoodViewSet, '/api/food/basicfood/')
|
||||||
|
|
||||||
|
# TODO Repair and detabulate this test
|
||||||
def test_transformedfood_api(self):
|
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
|
||||||
"""
|
"""
|
||||||
self.check_viewset(TransformedFoodViewSet, '/api/food/transformedfood/')
|
self.check_viewset(TransformedFoodViewSet, '/api/food/transformedfood/')
|
||||||
|
|
||||||
def test_qrcode_api(self):
|
def test_qrcode_api(self):
|
||||||
"""
|
"""
|
||||||
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/')
|
||||||
|
|||||||
@@ -19,4 +19,14 @@ urlpatterns = [
|
|||||||
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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,25 +4,30 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from api.viewsets import is_regex
|
from api.viewsets import is_regex
|
||||||
from django_tables2.views import MultiTableMixin
|
from crispy_forms.helper import FormHelper
|
||||||
|
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
|
from django.db.models import Q, Count
|
||||||
from django.http import HttpResponseRedirect, Http404
|
from django.http import HttpResponseRedirect, Http404
|
||||||
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
|
from .models import Food, BasicFood, TransformedFood, QRCode, Order, Dish, Supplement
|
||||||
from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \
|
from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \
|
||||||
ManageIngredientsForm, ManageIngredientsFormSet, AddIngredientForms, \
|
ManageIngredientsForm, ManageIngredientsFormSet, AddIngredientForms, \
|
||||||
BasicFoodUpdateForms, TransformedFoodUpdateForms
|
BasicFoodUpdateForms, TransformedFoodUpdateForms, \
|
||||||
from .tables import FoodTable
|
DishForm, SupplementFormSet, SupplementFormSetHelper, OrderForm
|
||||||
|
from .tables import FoodTable, DishTable, OrderTable
|
||||||
from .utils import pretty_duration
|
from .utils import pretty_duration
|
||||||
|
|
||||||
|
|
||||||
@@ -65,9 +70,13 @@ 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().order_by('expiry_date').filter(
|
open_table = self.get_queryset().order_by('expiry_date').filter(
|
||||||
@@ -95,6 +104,7 @@ 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):
|
||||||
@@ -107,6 +117,9 @@ 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["open_activities"] = Activity.objects.filter(activity_type__name="Perm bouffe", open=True)
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
@@ -218,7 +231,7 @@ 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()
|
||||||
@@ -294,11 +307,19 @@ class ManageIngredientsView(LoginRequiredMixin, UpdateView):
|
|||||||
|
|
||||||
elif form.data[prefix + 'name'] != '':
|
elif form.data[prefix + 'name'] != '':
|
||||||
ingredient = Food.objects.get(pk=form.data[prefix + 'name'])
|
ingredient = Food.objects.get(pk=form.data[prefix + 'name'])
|
||||||
self.object.ingredients.add(ingredient)
|
if form.data.get(prefix + 'add_all_same_name') == 'on':
|
||||||
if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on':
|
ingredients = Food.objects.filter(name=ingredient.name, owner=ingredient.owner, end_of_life='')
|
||||||
ingredient.end_of_life = _('Fully used in {meal}'.format(
|
for ingredient in ingredients:
|
||||||
meal=self.object.name))
|
self.object.ingredients.add(ingredient)
|
||||||
ingredient.save()
|
if form.data.get(prefix + 'fully_used') == 'on':
|
||||||
|
ingredient.end_of_life = _('Fully used in {meal}'.format(meal=self.object.name))
|
||||||
|
ingredient.save()
|
||||||
|
else:
|
||||||
|
self.object.ingredients.add(ingredient)
|
||||||
|
if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on':
|
||||||
|
ingredient.end_of_life = _('Fully used in {meal}'.format(
|
||||||
|
meal=self.object.name))
|
||||||
|
ingredient.save()
|
||||||
# We recalculate new expiry date and allergens
|
# 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()
|
||||||
@@ -521,3 +542,328 @@ 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"])
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
orders_count = Order.objects.values('dish__main__name').annotate(quantity=Count('id'))
|
||||||
|
|
||||||
|
context["orders"] = {o['dish__main__name']: o['quantity'] for o in orders_count}
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
def get_table(self, **kwargs):
|
||||||
|
table = super().get_table(**kwargs)
|
||||||
|
|
||||||
|
hide = ["ordered_at", "serve", "delete"]
|
||||||
|
for field in hide:
|
||||||
|
table.columns.hide(field)
|
||||||
|
|
||||||
|
return table
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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
|
||||||
@@ -45,6 +46,11 @@ 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"))
|
||||||
@@ -72,7 +78,12 @@ 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
|
||||||
return super().save(commit)
|
instance = super().save(commit=False)
|
||||||
|
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
|
||||||
|
|||||||
@@ -92,6 +92,20 @@ 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
|
||||||
@@ -149,6 +163,16 @@ 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',
|
||||||
|
|||||||
@@ -36,7 +36,13 @@ 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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
<form method="post" id="profile-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form | crispy }}
|
{{ form | crispy }}
|
||||||
{{ profile_form | crispy }}
|
{{ profile_form | crispy }}
|
||||||
@@ -21,3 +21,45 @@ 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 %}
|
||||||
@@ -17,6 +17,7 @@ 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
|
||||||
@@ -950,11 +951,12 @@ 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(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
class ClubMembersListView(ExportMixin, 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"])
|
||||||
@@ -986,6 +988,14 @@ class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV
|
|||||||
|
|
||||||
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(
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ $(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)
|
||||||
@@ -111,6 +113,7 @@ $(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)
|
||||||
@@ -134,6 +137,7 @@ $(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)
|
||||||
@@ -162,6 +166,7 @@ $(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)
|
||||||
|
|||||||
@@ -4430,6 +4430,22 @@
|
|||||||
"description": "Modifier le type de caution de mon inscription WEI tant qu'elle n'est pas validée"
|
"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",
|
"model": "permission.permission",
|
||||||
"pk": 311,
|
"pk": 311,
|
||||||
@@ -4686,6 +4702,22 @@
|
|||||||
"description": "Supprimer un succès"
|
"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.role",
|
"model": "permission.role",
|
||||||
"pk": 1,
|
"pk": 1,
|
||||||
@@ -4833,7 +4865,11 @@
|
|||||||
221,
|
221,
|
||||||
247,
|
247,
|
||||||
258,
|
258,
|
||||||
259
|
259,
|
||||||
|
260,
|
||||||
|
263,
|
||||||
|
265,
|
||||||
|
330
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4845,7 +4881,6 @@
|
|||||||
"name": "Pr\u00e9sident\u22c5e de club",
|
"name": "Pr\u00e9sident\u22c5e de club",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
62,
|
62,
|
||||||
135,
|
|
||||||
142
|
142
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -5122,7 +5157,8 @@
|
|||||||
289,
|
289,
|
||||||
290,
|
290,
|
||||||
291,
|
291,
|
||||||
293
|
293,
|
||||||
|
298
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -5182,6 +5218,7 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
37,
|
37,
|
||||||
41,
|
41,
|
||||||
|
42,
|
||||||
53,
|
53,
|
||||||
54,
|
54,
|
||||||
55,
|
55,
|
||||||
@@ -5233,7 +5270,9 @@
|
|||||||
168,
|
168,
|
||||||
176,
|
176,
|
||||||
177,
|
177,
|
||||||
197
|
197,
|
||||||
|
311,
|
||||||
|
319
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -5313,7 +5352,8 @@
|
|||||||
289,
|
289,
|
||||||
290,
|
290,
|
||||||
291,
|
291,
|
||||||
293
|
293,
|
||||||
|
298
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from ...models import WEIMembership, Bus
|
|||||||
|
|
||||||
WORDS = {
|
WORDS = {
|
||||||
'list': [
|
'list': [
|
||||||
'Fiesta', 'Graillance', 'Move it move it', 'Calme', 'Nert et geek', 'Jeux de rôles et danse rock',
|
'Fiesta', 'Graillance', 'Move it move it', 'Calme', 'Nerd et geek', 'Jeux de rôles et danse rock',
|
||||||
'Strass et paillettes', 'Spectaculaire', 'Splendide', 'Flow inégalable', 'Rap', 'Battles légendaires',
|
'Strass et paillettes', 'Spectaculaire', 'Splendide', 'Flow inégalable', 'Rap', 'Battles légendaires',
|
||||||
'Techno', 'Alcool', 'Kiffeur·euse', 'Rugby', 'Médiéval', 'Festif',
|
'Techno', 'Alcool', 'Kiffeur·euse', 'Rugby', 'Médiéval', 'Festif',
|
||||||
'Stylé', 'Chipie', 'Rétro', 'Vache', 'Farfadet', 'Fanfare',
|
'Stylé', 'Chipie', 'Rétro', 'Vache', 'Farfadet', 'Fanfare',
|
||||||
@@ -57,7 +57,7 @@ WORDS = {
|
|||||||
42: "Un burgouzz de valouzz",
|
42: "Un burgouzz de valouzz",
|
||||||
47: "Un ocarina (pour me téléporter hors de ce bourbier)",
|
47: "Un ocarina (pour me téléporter hors de ce bourbier)",
|
||||||
48: "Des paillettes, un micro de karaoké et une enceinte bluetooth",
|
48: "Des paillettes, un micro de karaoké et une enceinte bluetooth",
|
||||||
45: "",
|
45: "Un kebab",
|
||||||
44: "Une 86 et un caisson pour taper du pied",
|
44: "Une 86 et un caisson pour taper du pied",
|
||||||
46: "Une épée, un ballon et une tireuse",
|
46: "Une épée, un ballon et une tireuse",
|
||||||
43: "Des lunettes de soleil",
|
43: "Des lunettes de soleil",
|
||||||
@@ -176,7 +176,33 @@ WORDS = {
|
|||||||
49: "Soirée raclette !"
|
49: "Soirée raclette !"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
'stats': [
|
||||||
|
{
|
||||||
|
"question": """Le WEI est structuré par bus, et au sein de chaque bus, par équipes.
|
||||||
|
Pour toi, être dans une équipe où tout le monde reste sobre (primo-entrants comme encadrants) c'est :""",
|
||||||
|
"answers": [
|
||||||
|
(1, "Inenvisageable"),
|
||||||
|
(2, "À contre cœur"),
|
||||||
|
(3, "Pourquoi pas"),
|
||||||
|
(4, "Souhaitable"),
|
||||||
|
(5, "Nécessaire"),
|
||||||
|
],
|
||||||
|
"help_text": "(De toute façon aucun alcool n'est consommé pendant les trajets du bus, ni aller, ni retour.)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "Faire partie d'un bus qui n'apporte pas de boisson alcoolisée pour ses membres, pour toi c'est :",
|
||||||
|
"answers": [
|
||||||
|
(1, "Inenvisageable"),
|
||||||
|
(2, "À contre cœur"),
|
||||||
|
(3, "Pourquoi pas"),
|
||||||
|
(4, "Souhaitable"),
|
||||||
|
(5, "Nécessaire"),
|
||||||
|
],
|
||||||
|
"help_text": """(Tout les bus apportent de l'alcool cette année, cette question sert à l'organisation pour l'année prochaine.
|
||||||
|
De plus il y aura de toute façon de l'alcool commun au WEI et aucun alcool n'est consommé pendant les trajets en bus.)""",
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
IMAGES = {
|
IMAGES = {
|
||||||
@@ -235,7 +261,7 @@ class WEISurveyForm2025(forms.Form):
|
|||||||
all_preferred_words = WORDS['list']
|
all_preferred_words = WORDS['list']
|
||||||
rng.shuffle(all_preferred_words)
|
rng.shuffle(all_preferred_words)
|
||||||
self.fields["words"].choices = [(w, w) for w in all_preferred_words]
|
self.fields["words"].choices = [(w, w) for w in all_preferred_words]
|
||||||
else:
|
elif information.step <= len(WORDS['questions']):
|
||||||
questions = list(WORDS['questions'].items())
|
questions = list(WORDS['questions'].items())
|
||||||
idx = information.step - 1
|
idx = information.step - 1
|
||||||
if idx < len(questions):
|
if idx < len(questions):
|
||||||
@@ -251,6 +277,15 @@ class WEISurveyForm2025(forms.Form):
|
|||||||
widget=OptionalImageRadioSelect(images=IMAGES.get(q, {})),
|
widget=OptionalImageRadioSelect(images=IMAGES.get(q, {})),
|
||||||
required=True,
|
required=True,
|
||||||
)
|
)
|
||||||
|
elif information.step == len(WORDS['questions']) + 1:
|
||||||
|
for i, v in enumerate(WORDS['stats']):
|
||||||
|
self.fields[f'stat_{i}'] = forms.ChoiceField(
|
||||||
|
label=v['question'],
|
||||||
|
choices=v['answers'],
|
||||||
|
widget=forms.RadioSelect(),
|
||||||
|
required=False,
|
||||||
|
help_text=_(v.get('help_text', ''))
|
||||||
|
)
|
||||||
|
|
||||||
def clean_words(self):
|
def clean_words(self):
|
||||||
data = self.cleaned_data['words']
|
data = self.cleaned_data['words']
|
||||||
@@ -377,7 +412,7 @@ class WEISurvey2025(WEISurvey):
|
|||||||
setattr(self.information, "word" + str(i), word)
|
setattr(self.information, "word" + str(i), word)
|
||||||
self.information.step += 1
|
self.information.step += 1
|
||||||
self.save()
|
self.save()
|
||||||
else:
|
elif 1 <= self.information.step <= len(WORDS['questions']):
|
||||||
questions = list(WORDS['questions'].keys())
|
questions = list(WORDS['questions'].keys())
|
||||||
idx = self.information.step - 1
|
idx = self.information.step - 1
|
||||||
if idx < len(questions):
|
if idx < len(questions):
|
||||||
@@ -385,6 +420,13 @@ class WEISurvey2025(WEISurvey):
|
|||||||
setattr(self.information, q, form.cleaned_data[q])
|
setattr(self.information, q, form.cleaned_data[q])
|
||||||
self.information.step += 1
|
self.information.step += 1
|
||||||
self.save()
|
self.save()
|
||||||
|
else:
|
||||||
|
for i, __ in enumerate(WORDS['stats']):
|
||||||
|
ans = form.cleaned_data.get(f'stat_{i}')
|
||||||
|
if ans is not None:
|
||||||
|
setattr(self.information, f'stat_{i}', ans)
|
||||||
|
self.information.step += 1
|
||||||
|
self.save()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_algorithm_class(cls):
|
def get_algorithm_class(cls):
|
||||||
@@ -394,7 +436,7 @@ class WEISurvey2025(WEISurvey):
|
|||||||
"""
|
"""
|
||||||
The survey is complete once the bus is chosen.
|
The survey is complete once the bus is chosen.
|
||||||
"""
|
"""
|
||||||
return self.information.step > len(WORDS['questions'])
|
return self.information.step > len(WORDS['questions']) + 1
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@lru_cache()
|
@lru_cache()
|
||||||
|
|||||||
BIN
apps/wei/static/wei/img/logo_auvergne_rhone_alpes.jpg
Normal file
BIN
apps/wei/static/wei/img/logo_auvergne_rhone_alpes.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -11,7 +11,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
{{ title }}
|
{{ title }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post">
|
<form id="registration-form" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form|crispy }}
|
{{ form|crispy }}
|
||||||
{{ membership_form|crispy }}
|
{{ membership_form|crispy }}
|
||||||
@@ -22,6 +22,46 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extrajavascript %}
|
{% block extrajavascript %}
|
||||||
|
<!-- intl-tel-input CSS/JS -->
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const input = document.querySelector("input[name='emergency_contact_phone']");
|
||||||
|
const form = document.querySelector("#registration-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>
|
||||||
|
|
||||||
{% if not object.membership %}
|
{% if not object.membership %}
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
|
|||||||
@@ -53,9 +53,11 @@ class TestWEIAlgorithm(TestCase):
|
|||||||
birth_date='2000-01-01',
|
birth_date='2000-01-01',
|
||||||
)
|
)
|
||||||
information = WEISurveyInformation2025(registration)
|
information = WEISurveyInformation2025(registration)
|
||||||
for j in range(1, 21):
|
for j in range(1, 1 + NB_WORDS):
|
||||||
setattr(information, f'word{j}', random.choice(WORDS['list']))
|
setattr(information, f'word{j}', random.choice(WORDS['list']))
|
||||||
information.step = 20
|
for q in WORDS['questions']:
|
||||||
|
setattr(information, q, random.choice(list(WORDS['questions'][q][1].keys())))
|
||||||
|
information.step = len(WORDS['questions']) + 2
|
||||||
information.save(registration)
|
information.save(registration)
|
||||||
registration.save()
|
registration.save()
|
||||||
|
|
||||||
@@ -87,7 +89,7 @@ class TestWEIAlgorithm(TestCase):
|
|||||||
setattr(information, f'word{j}', random.choice(WORDS['list']))
|
setattr(information, f'word{j}', random.choice(WORDS['list']))
|
||||||
for q in WORDS['questions']:
|
for q in WORDS['questions']:
|
||||||
setattr(information, q, random.choice(list(WORDS['questions'][q][1].keys())))
|
setattr(information, q, random.choice(list(WORDS['questions'][q][1].keys())))
|
||||||
information.step = len(WORDS['questions']) + 1
|
information.step = len(WORDS['questions']) + 2
|
||||||
information.save(registration)
|
information.save(registration)
|
||||||
registration.save()
|
registration.save()
|
||||||
survey = WEISurvey2025(registration)
|
survey = WEISurvey2025(registration)
|
||||||
|
|||||||
@@ -770,7 +770,7 @@ msgstr "Créer une famille ou un défi"
|
|||||||
|
|
||||||
#: apps/family/templates/family/manage.html:96
|
#: apps/family/templates/family/manage.html:96
|
||||||
msgid "Add a family"
|
msgid "Add a family"
|
||||||
msgstr "Ajouter une famille"
|
msgstr "Fonder une famille"
|
||||||
|
|
||||||
#: apps/family/templates/family/manage.html:101
|
#: apps/family/templates/family/manage.html:101
|
||||||
msgid "Add a challenge"
|
msgid "Add a challenge"
|
||||||
|
|||||||
@@ -306,8 +306,8 @@ PIC_WIDTH = 200
|
|||||||
PIC_RATIO = 1
|
PIC_RATIO = 1
|
||||||
|
|
||||||
# Custom phone number format
|
# Custom phone number format
|
||||||
PHONENUMBER_DB_FORMAT = 'NATIONAL'
|
PHONENUMBER_DB_FORMAT = 'E164'
|
||||||
PHONENUMBER_DEFAULT_REGION = 'FR'
|
PHONENUMBER_DEFAULT_REGION = None
|
||||||
|
|
||||||
# We add custom information to CAS, in order to give a normalized name to other services
|
# We add custom information to CAS, in order to give a normalized name to other services
|
||||||
CAS_AUTH_CLASS = 'member.auth.CustomAuthUser'
|
CAS_AUTH_CLASS = 'member.auth.CustomAuthUser'
|
||||||
|
|||||||
@@ -13,11 +13,14 @@ $(document).ready(function () {
|
|||||||
target.addClass('is-invalid')
|
target.addClass('is-invalid')
|
||||||
target.removeClass('is-valid')
|
target.removeClass('is-valid')
|
||||||
|
|
||||||
|
const isManageIngredients = target.hasClass('manageingredients-autocomplete')
|
||||||
|
|
||||||
$.getJSON(api_url + (api_url.includes('?') ? '&' : '?') + 'format=json&search=^' + input + api_url_suffix, function (objects) {
|
$.getJSON(api_url + (api_url.includes('?') ? '&' : '?') + 'format=json&search=^' + input + api_url_suffix, function (objects) {
|
||||||
let html = '<ul class="list-group list-group-flush" id="' + prefix + '_list">'
|
let html = '<ul class="list-group list-group-flush" id="' + prefix + '_list">'
|
||||||
|
|
||||||
objects.results.forEach(function (obj) {
|
objects.results.forEach(function (obj) {
|
||||||
html += li(prefix + '_' + obj.id, obj[name_field])
|
const extra = isManageIngredients ? ` (${obj.owner_name})` : ''
|
||||||
|
html += li(`${prefix}_${obj.id}`, `${obj[name_field]}${extra}`)
|
||||||
})
|
})
|
||||||
html += '</ul>'
|
html += '</ul>'
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
<link rel="stylesheet" href="{% static "font-awesome/css/font-awesome.min.css" %}">
|
<link rel="stylesheet" href="{% static "font-awesome/css/font-awesome.min.css" %}">
|
||||||
<link rel="stylesheet" href="{% static "css/custom.css" %}">
|
<link rel="stylesheet" href="{% static "css/custom.css" %}">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/intl-tel-input@25.5.2/build/css/intlTelInput.css">
|
||||||
|
|
||||||
{# JQuery, Bootstrap and Turbolinks JavaScript #}
|
{# JQuery, Bootstrap and Turbolinks JavaScript #}
|
||||||
<script src="{% static "jquery/jquery.min.js" %}"></script>
|
<script src="{% static "jquery/jquery.min.js" %}"></script>
|
||||||
<script src="{% static "popper.js/umd/popper.min.js" %}"></script>
|
<script src="{% static "popper.js/umd/popper.min.js" %}"></script>
|
||||||
@@ -41,6 +43,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
{# Translation in javascript files #}
|
{# Translation in javascript files #}
|
||||||
<script src="{% static "js/jsi18n/"|add:LANGUAGE_CODE|add:".js" %}"></script>
|
<script src="{% static "js/jsi18n/"|add:LANGUAGE_CODE|add:".js" %}"></script>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/intl-tel-input@25.5.2/build/js/intlTelInput.min.js"></script>
|
||||||
|
|
||||||
{# If extra ressources are needed for a form, load here #}
|
{# If extra ressources are needed for a form, load here #}
|
||||||
{% if form.media %}
|
{% if form.media %}
|
||||||
{{ form.media }}
|
{{ form.media }}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post">
|
<form method="post" id="profile_form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form|crispy }}
|
{{ form|crispy }}
|
||||||
{{ profile_form|crispy }}
|
{{ profile_form|crispy }}
|
||||||
@@ -31,3 +31,45 @@ 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 %}
|
||||||
@@ -18,4 +18,5 @@ django-rest-polymorphic~=0.1.10
|
|||||||
django-tables2~=2.7.5
|
django-tables2~=2.7.5
|
||||||
python-memcached~=1.62
|
python-memcached~=1.62
|
||||||
phonenumbers~=9.0.8
|
phonenumbers~=9.0.8
|
||||||
|
tablib~=3.8.0
|
||||||
Pillow>=11.3.0
|
Pillow>=11.3.0
|
||||||
|
|||||||
Reference in New Issue
Block a user