diff --git a/apps/activity/fixtures/initial.json b/apps/activity/fixtures/initial.json
index 7961c17f..d42e6f9d 100644
--- a/apps/activity/fixtures/initial.json
+++ b/apps/activity/fixtures/initial.json
@@ -48,5 +48,15 @@
"can_invite": true,
"guest_entry_fee": 0
}
+ },
+ {
+ "model": "activity.activitytype",
+ "pk": 8,
+ "fields": {
+ "name": "Perm bouffe",
+ "manage_entries": false,
+ "can_invite": false,
+ "guest_entry_fee": 0
+ }
}
-]
+]
\ No newline at end of file
diff --git a/apps/activity/templates/activity/includes/activity_info.html b/apps/activity/templates/activity/includes/activity_info.html
index 4565a086..5a8887e2 100644
--- a/apps/activity/templates/activity/includes/activity_info.html
+++ b/apps/activity/templates/activity/includes/activity_info.html
@@ -65,6 +65,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% if activity.open and activity.activity_type.manage_entries and ".change__open"|has_perm:activity %}
{% trans "Entry page" %}
{% endif %}
+ {% if false %}
+ {% if activity.activity_type.name == "Perm bouffe" %}
+ {% trans "Dish page" %}
+ {% endif %}
+ {% endif %}
{% if request.path_info == activity_detail_url %}
{% if activity.valid and ".change__open"|has_perm:activity %}
diff --git a/apps/food/api/serializers.py b/apps/food/api/serializers.py
index eb1621b6..1400a28a 100644
--- a/apps/food/api/serializers.py
+++ b/apps/food/api/serializers.py
@@ -3,7 +3,7 @@
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):
@@ -21,9 +21,13 @@ class FoodSerializer(serializers.ModelSerializer):
REST API Serializer for Food.
The djangorestframework plugin will analyse the model `Food` and parse all fields in the API.
"""
+ # This fields is used for autocompleting food in ManageIngredientsView
+ # TODO Find a better way to do it
+ owner_name = serializers.CharField(source='owner.name', read_only=True)
+
class Meta:
model = Food
- fields = '__all__'
+ fields = ['name', 'owner', 'allergens', 'expiry_date', 'end_of_life', 'is_ready', 'order', 'owner_name']
class BasicFoodSerializer(serializers.ModelSerializer):
@@ -54,3 +58,43 @@ class QRCodeSerializer(serializers.ModelSerializer):
class Meta:
model = QRCode
fields = '__all__'
+
+
+class DishSerializer(serializers.ModelSerializer):
+ """
+ REST API Serializer for Dish.
+ The djangorestframework plugin will analyse the model `Dish` and parse all fields in the API.
+ """
+ class Meta:
+ model = Dish
+ fields = '__all__'
+
+
+class SupplementSerializer(serializers.ModelSerializer):
+ """
+ REST API Serializer for Supplement.
+ The djangorestframework plugin will analyse the model `Supplement` and parse all fields in the API.
+ """
+ class Meta:
+ model = Supplement
+ fields = '__all__'
+
+
+class OrderSerializer(serializers.ModelSerializer):
+ """
+ REST API Serializer for Order.
+ The djangorestframework plugin will analyse the model `Order` and parse all fields in the API.
+ """
+ class Meta:
+ model = Order
+ fields = '__all__'
+
+
+class FoodTransactionSerializer(serializers.ModelSerializer):
+ """
+ REST API Serializer for FoodTransaction.
+ The djangorestframework plugin will analyse the model `FoodTransaction` and parse all fields in the API.
+ """
+ class Meta:
+ model = FoodTransaction
+ fields = '__all__'
diff --git a/apps/food/api/urls.py b/apps/food/api/urls.py
index 8fa6995d..bda0f52e 100644
--- a/apps/food/api/urls.py
+++ b/apps/food/api/urls.py
@@ -1,7 +1,8 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
-from .views import AllergenViewSet, FoodViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet
+from .views import AllergenViewSet, FoodViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet, \
+ DishViewSet, SupplementViewSet, OrderViewSet, FoodTransactionViewSet
def register_food_urls(router, path):
@@ -13,3 +14,7 @@ def register_food_urls(router, path):
router.register(path + '/basicfood', BasicFoodViewSet)
router.register(path + '/transformedfood', TransformedFoodViewSet)
router.register(path + '/qrcode', QRCodeViewSet)
+ router.register(path + '/dish', DishViewSet)
+ router.register(path + '/supplement', SupplementViewSet)
+ router.register(path + '/order', OrderViewSet)
+ router.register(path + '/foodtransaction', FoodTransactionViewSet)
diff --git a/apps/food/api/views.py b/apps/food/api/views.py
index 0aead0de..3b60a261 100644
--- a/apps/food/api/views.py
+++ b/apps/food/api/views.py
@@ -5,8 +5,9 @@ from api.viewsets import ReadProtectedModelViewSet
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter
-from .serializers import AllergenSerializer, FoodSerializer, BasicFoodSerializer, TransformedFoodSerializer, QRCodeSerializer
-from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode
+from .serializers import AllergenSerializer, FoodSerializer, BasicFoodSerializer, TransformedFoodSerializer, QRCodeSerializer, \
+ DishSerializer, SupplementSerializer, OrderSerializer, FoodTransactionSerializer
+from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order, FoodTransaction
class AllergenViewSet(ReadProtectedModelViewSet):
@@ -72,3 +73,55 @@ class QRCodeViewSet(ReadProtectedModelViewSet):
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['qr_code_number', ]
search_fields = ['$qr_code_number', ]
+
+
+class DishViewSet(ReadProtectedModelViewSet):
+ """
+ REST API View set.
+ The djangorestframework plugin will get all `Dish` objects, serialize it to JSON with the given serializer,
+ then render it on /api/food/dish/
+ """
+ queryset = Dish.objects.order_by('id')
+ serializer_class = DishSerializer
+ filter_backends = [DjangoFilterBackend, SearchFilter]
+ filterset_fields = ['main__name', 'activity', ]
+ search_fields = ['$main__name', '$activity', ]
+
+
+class SupplementViewSet(ReadProtectedModelViewSet):
+ """
+ REST API View set.
+ The djangorestframework plugin will get all `Supplement` objects, serialize it to JSON with the given serializer,
+ then render it on /api/food/supplement/
+ """
+ queryset = Supplement.objects.order_by('id')
+ serializer_class = SupplementSerializer
+ filter_backends = [DjangoFilterBackend, SearchFilter]
+ filterset_fields = ['food__name', 'dish__activity', ]
+ search_fields = ['$food__name', '$dish__activity', ]
+
+
+class OrderViewSet(ReadProtectedModelViewSet):
+ """
+ REST API View set.
+ The djangorestframework plugin will get all `Order` objects, serialize it to JSON with the given serializer,
+ then render it on /api/food/order/
+ """
+ queryset = Order.objects.order_by('id')
+ serializer_class = OrderSerializer
+ filter_backends = [DjangoFilterBackend, SearchFilter]
+ filterset_fields = ['user', 'activity', 'dish', 'supplements', 'number', ]
+ search_fields = ['$user', '$activity', '$dish', '$supplements', '$number', ]
+
+
+class FoodTransactionViewSet(ReadProtectedModelViewSet):
+ """
+ REST API View set.
+ The djangorestframework plugin will get all `FoodTransaction` objects, serialize it to JSON with the given serializer,
+ then render it on /api/food/foodtransaction/
+ """
+ queryset = FoodTransaction.objects.order_by('id')
+ serializer_class = FoodTransactionSerializer
+ filter_backends = [DjangoFilterBackend, SearchFilter]
+ filterset_fields = ['order', ]
+ search_fields = ['$order', ]
diff --git a/apps/food/forms.py b/apps/food/forms.py
index 13c5cba3..2b09699a 100644
--- a/apps/food/forms.py
+++ b/apps/food/forms.py
@@ -4,15 +4,17 @@
from random import shuffle
from bootstrap_datepicker_plus.widgets import DateTimePickerInput
+from crispy_forms.helper import FormHelper
from django import forms
-from django.forms.widgets import NumberInput
+from django.forms import CheckboxSelectMultiple
+from django.forms.widgets import NumberInput, TextInput
from django.utils.translation import gettext_lazy as _
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 permission.backends import PermissionBackend
-from .models import Food, BasicFood, TransformedFood, QRCode
+from .models import Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order, Recipe
class QRCodeForms(forms.ModelForm):
@@ -54,7 +56,7 @@ class BasicFoodForms(forms.ModelForm):
class Meta:
model = BasicFood
- fields = ('name', 'owner', 'date_type', 'expiry_date', 'allergens', 'order',)
+ fields = ('name', 'owner', 'date_type', 'expiry_date', 'allergens', 'traces', 'order',)
widgets = {
"owner": Autocomplete(
model=Club,
@@ -97,7 +99,7 @@ class BasicFoodUpdateForms(forms.ModelForm):
"""
class Meta:
model = BasicFood
- fields = ('name', 'owner', 'date_type', 'expiry_date', 'end_of_life', 'is_ready', 'order', 'allergens')
+ fields = ('name', 'owner', 'date_type', 'expiry_date', 'end_of_life', 'is_ready', 'order', 'allergens', 'traces')
widgets = {
"owner": Autocomplete(
model=Club,
@@ -133,7 +135,7 @@ class AddIngredientForms(forms.ModelForm):
Form for add an ingredient
"""
fully_used = forms.BooleanField()
- fully_used.initial = True
+ fully_used.initial = False
fully_used.required = False
fully_used.label = _("Fully used")
@@ -141,11 +143,14 @@ class AddIngredientForms(forms.ModelForm):
super().__init__(*args, **kwargs)
# TODO find a better way to get pk (be not url scheme dependant)
pk = get_current_request().path.split('/')[-1]
- self.fields['ingredients'].queryset = self.fields['ingredients'].queryset.filter(
+ qs = self.fields['ingredients'].queryset.filter(
polymorphic_ctype__model="transformedfood",
is_ready=False,
end_of_life='',
- ).filter(PermissionBackend.filter_queryset(get_current_request(), Food, "change")).exclude(pk=pk)
+ ).filter(PermissionBackend.filter_queryset(get_current_request(), Food, "change"))
+ if pk:
+ qs = qs.exclude(pk=pk)
+ self.fields['ingredients'].queryset = qs
class Meta:
model = TransformedFood
@@ -157,7 +162,7 @@ class ManageIngredientsForm(forms.Form):
Form to manage ingredient
"""
fully_used = forms.BooleanField()
- fully_used.initial = True
+ fully_used.initial = False
fully_used.required = True
fully_used.label = _('Fully used')
@@ -166,7 +171,7 @@ class ManageIngredientsForm(forms.Form):
model=Food,
resetable=True,
attrs={"api_url": "/api/food/food",
- "class": "autocomplete"},
+ "class": "autocomplete manageingredients-autocomplete"},
)
name.label = _('Name')
@@ -180,8 +185,116 @@ class ManageIngredientsForm(forms.Form):
)
qrcode.label = _('QR code number')
+ add_all_same_name = forms.BooleanField(
+ required=False,
+ label=_("Add all identical food")
+ )
+
ManageIngredientsFormSet = forms.formset_factory(
ManageIngredientsForm,
extra=1,
)
+
+
+class DishForm(forms.ModelForm):
+ """
+ Form to create a dish
+ """
+ class Meta:
+ model = Dish
+ fields = ('main', 'price', 'available')
+ widgets = {
+ "price": AmountInput(),
+ }
+
+
+class SupplementForm(forms.ModelForm):
+ """
+ Form to create a dish
+ """
+ class Meta:
+ model = Supplement
+ fields = '__all__'
+ widgets = {
+ "price": AmountInput(),
+ }
+
+
+# The 2 following classes are copied from treasury app
+# Add a subform per supplement in the dish form, and manage correctly the link between the dish and
+# its supplements. The FormSet will search automatically the ForeignKey in the Supplement model.
+SupplementFormSet = forms.inlineformset_factory(
+ Dish,
+ Supplement,
+ form=SupplementForm,
+ extra=1,
+)
+
+
+class SupplementFormSetHelper(FormHelper):
+ """
+ Specify some template information for the supplement form
+ """
+
+ def __init__(self, form=None):
+ super().__init__(form)
+ self.form_tag = False
+ self.form_method = 'POST'
+ self.form_class = 'form-inline'
+ self.template = 'bootstrap4/table_inline_formset.html'
+
+
+class OrderForm(forms.ModelForm):
+ """
+ Form to order food
+ """
+ class Meta:
+ model = Order
+ exclude = ("activity", "number", "ordered_at", "served", "served_at")
+
+
+class RecipeForm(forms.ModelForm):
+ """
+ Form to create a recipe
+ """
+ class Meta:
+ model = Recipe
+ fields = ('name', 'creater',)
+ widgets = {
+ "creater": Autocomplete(
+ model=Club,
+ attrs={"api_url": "/api/members/club/"},
+ ),
+ }
+
+
+class RecipeIngredientsForm(forms.Form):
+ """
+ Form to add ingredients to a recipe
+ """
+ name = forms.CharField()
+ name.widget = TextInput()
+ name.label = _("Name")
+
+
+RecipeIngredientsFormSet = forms.formset_factory(
+ RecipeIngredientsForm,
+ extra=1,
+)
+
+
+class UseRecipeForm(forms.Form):
+ """
+ Form to add ingredients to a TransformedFood using a Recipe
+ """
+ recipe = forms.ModelChoiceField(
+ queryset=Recipe.objects,
+ label=_('Recipe'),
+ )
+
+ ingredients = forms.ModelMultipleChoiceField(
+ queryset=Food.objects,
+ label=_("Ingredients"),
+ widget=CheckboxSelectMultiple(),
+ )
diff --git a/apps/food/migrations/0002_alter_food_end_of_life_alter_food_order.py b/apps/food/migrations/0002_alter_food_end_of_life_alter_food_order.py
new file mode 100644
index 00000000..8c6a119d
--- /dev/null
+++ b/apps/food/migrations/0002_alter_food_end_of_life_alter_food_order.py
@@ -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'),
+ ),
+ ]
diff --git a/apps/food/migrations/0003_dish_order_foodtransaction_supplement_and_more.py b/apps/food/migrations/0003_dish_order_foodtransaction_supplement_and_more.py
new file mode 100644
index 00000000..8a5364d6
--- /dev/null
+++ b/apps/food/migrations/0003_dish_order_foodtransaction_supplement_and_more.py
@@ -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')},
+ ),
+ ]
diff --git a/apps/food/migrations/0004_alter_foodtransaction_order.py b/apps/food/migrations/0004_alter_foodtransaction_order.py
new file mode 100644
index 00000000..bd48dd70
--- /dev/null
+++ b/apps/food/migrations/0004_alter_foodtransaction_order.py
@@ -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'),
+ ),
+ ]
diff --git a/apps/food/migrations/0005_food_traces.py b/apps/food/migrations/0005_food_traces.py
new file mode 100644
index 00000000..4848ce78
--- /dev/null
+++ b/apps/food/migrations/0005_food_traces.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.6 on 2025-11-02 17:38
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('food', '0004_alter_foodtransaction_order'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='food',
+ name='traces',
+ field=models.ManyToManyField(blank=True, related_name='food_with_traces', to='food.allergen', verbose_name='traces'),
+ ),
+ ]
diff --git a/apps/food/migrations/0006_recipe.py b/apps/food/migrations/0006_recipe.py
new file mode 100644
index 00000000..2a24a028
--- /dev/null
+++ b/apps/food/migrations/0006_recipe.py
@@ -0,0 +1,29 @@
+# Generated by Django 5.2.6 on 2025-11-06 17:02
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('food', '0005_food_traces'),
+ ('member', '0015_alter_profile_promotion'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Recipe',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=255, verbose_name='name')),
+ ('ingredients_json', models.TextField(blank=True, default='[]', help_text='Ingredients of the recipe, encoded in JSON', verbose_name='list of ingredients')),
+ ('creater', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='member.club', verbose_name='creater')),
+ ],
+ options={
+ 'verbose_name': 'Recipe',
+ 'verbose_name_plural': 'Recipes',
+ 'unique_together': {('name', 'creater')},
+ },
+ ),
+ ]
diff --git a/apps/food/models.py b/apps/food/models.py
index c0b25078..cd66c960 100644
--- a/apps/food/models.py
+++ b/apps/food/models.py
@@ -1,13 +1,18 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
+import json
from datetime import timedelta
from django.db import models, transaction
+from django.core.exceptions import ValidationError
from django.utils import timezone
+from django.contrib.auth.models import User
from django.utils.translation import gettext_lazy as _
from polymorphic.models import PolymorphicModel
from member.models import Club
+from activity.models import Activity
+from note.models import Transaction
class Allergen(models.Model):
@@ -49,6 +54,13 @@ class Food(PolymorphicModel):
verbose_name=_('allergens'),
)
+ traces = models.ManyToManyField(
+ Allergen,
+ blank=True,
+ verbose_name=_('traces'),
+ related_name='food_with_traces'
+ )
+
expiry_date = models.DateTimeField(
verbose_name=_('expiry date'),
null=False,
@@ -87,6 +99,19 @@ class Food(PolymorphicModel):
if old_allergens != list(parent.allergens.all()):
parent.save(old_allergens=old_allergens)
+ @transaction.atomic
+ def update_traces(self):
+ # update parents
+ for parent in self.transformed_ingredient_inv.iterator():
+ old_traces = list(parent.traces.all()).copy()
+ parent.traces.clear()
+ for child in parent.ingredients.iterator():
+ if child.pk != self.pk:
+ parent.traces.set(parent.traces.union(child.traces.all()))
+ parent.traces.set(parent.traces.union(self.traces.all()))
+ if old_traces != list(parent.traces.all()):
+ parent.save(old_traces=old_traces)
+
def update_expiry_date(self):
# update parents
for parent in self.transformed_ingredient_inv.iterator():
@@ -138,6 +163,10 @@ class BasicFood(Food):
and list(self.allergens.all()) != kwargs['old_allergens']):
self.update_allergens()
+ if ('old_traces' in kwargs
+ and list(self.traces.all()) != kwargs['old_traces']):
+ self.update_traces()
+
# Expiry date
if ((self.expiry_date != old_food.expiry_date
and self.date_type == 'DLC')
@@ -210,7 +239,7 @@ class TransformedFood(Food):
created = self.pk is None
if not created:
# Check if important fields are updated
- update = {'allergens': False, 'expiry_date': False}
+ update = {'allergens': False, 'traces': False, 'expiry_date': False}
old_food = Food.objects.select_for_update().get(pk=self.pk)
if not hasattr(self, "_force_save"):
# Allergens
@@ -220,6 +249,10 @@ class TransformedFood(Food):
and list(self.allergens.all()) != kwargs['old_allergens']):
update['allergens'] = True
+ if ('old_traces' in kwargs
+ and list(self.traces.all()) != kwargs['old_traces']):
+ update['traces'] = True
+
# Expiry date
update['expiry_date'] = (self.shelf_life != old_food.shelf_life
or self.creation_date != old_food.creation_date)
@@ -230,6 +263,7 @@ class TransformedFood(Food):
if ('old_ingredients' in kwargs
and list(self.ingredients.all()) != list(kwargs['old_ingredients'])):
update['allergens'] = True
+ update['traces'] = True
update['expiry_date'] = True
# it's preferable to keep a queryset but we allow list too
@@ -239,6 +273,8 @@ class TransformedFood(Food):
self.check_cycle(self.ingredients.all().difference(kwargs['old_ingredients']), self, [])
if update['allergens']:
self.update_allergens()
+ if update['traces']:
+ self.update_traces()
if update['expiry_date']:
self.update_expiry_date()
@@ -250,9 +286,10 @@ class TransformedFood(Food):
for child in self.ingredients.iterator():
self.allergens.set(self.allergens.union(child.allergens.all()))
+ self.traces.set(self.traces.union(child.traces.all()))
if not (child.polymorphic_ctype.model == 'basicfood' and child.date_type == 'DDM'):
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:
verbose_name = _('Transformed food')
@@ -284,3 +321,250 @@ class QRCode(models.Model):
def __str__(self):
return _('QR-code number') + ' ' + str(self.qr_code_number)
+
+
+class Dish(models.Model):
+ """
+ A dish is a food proposed during a meal
+ """
+ main = models.ForeignKey(
+ TransformedFood,
+ on_delete=models.PROTECT,
+ related_name='dishes_as_main',
+ verbose_name=_('main food'),
+ )
+
+ price = models.PositiveIntegerField(
+ verbose_name=_('price')
+ )
+
+ activity = models.ForeignKey(
+ Activity,
+ on_delete=models.CASCADE,
+ related_name='dishes',
+ verbose_name=_('activity'),
+ )
+
+ available = models.BooleanField(
+ default=True,
+ verbose_name=_('available'),
+ )
+
+ class Meta:
+ verbose_name = _('Dish')
+ verbose_name_plural = _('Dishes')
+ unique_together = ('main', 'activity')
+
+ def __str__(self):
+ return self.main.name + ' (' + str(self.activity) + ')'
+
+ def save(self, *args, **kwargs):
+ "Check the type of activity"
+ if self.activity.activity_type.name != 'Perm bouffe':
+ raise ValidationError(_('(You cannot select this type of activity.'))
+
+ return super().save(*args, **kwargs)
+
+
+class Supplement(models.Model):
+ """
+ A supplement is a food added to a dish
+ """
+ dish = models.ForeignKey(
+ Dish,
+ on_delete=models.CASCADE,
+ related_name='supplements',
+ verbose_name=_('dish'),
+ )
+
+ food = models.ForeignKey(
+ Food,
+ on_delete=models.PROTECT,
+ related_name='supplements',
+ verbose_name=_('food'),
+ )
+
+ price = models.PositiveIntegerField(
+ verbose_name=_('price')
+ )
+
+ class Meta:
+ verbose_name = _('Supplement')
+ verbose_name_plural = _('Supplements')
+
+ def __str__(self):
+ return _("Supplement {food} for {dish}").format(
+ food=str(self.food), dish=str(self.dish))
+
+ def save(self, *args, **kwargs):
+ # Check the owner of the food
+ if self.food.owner != self.dish.main.owner:
+ raise ValidationError(_('You cannot select food that belongs to the same club than the main food.'))
+ return super().save(*args, **kwargs)
+
+
+class Order(models.Model):
+ """
+ An order is a dish ordered by a member during an activity
+ """
+ user = models.ForeignKey(
+ User,
+ on_delete=models.CASCADE,
+ related_name='food_orders',
+ verbose_name=_('user'),
+ )
+
+ activity = models.ForeignKey(
+ Activity,
+ on_delete=models.CASCADE,
+ related_name='food_orders',
+ verbose_name=_('activity'),
+ )
+
+ dish = models.ForeignKey(
+ Dish,
+ on_delete=models.CASCADE,
+ related_name='orders',
+ verbose_name=_('dish'),
+ )
+
+ supplements = models.ManyToManyField(
+ Supplement,
+ related_name='orders',
+ verbose_name=_('supplements'),
+ blank=True,
+ )
+
+ request = models.TextField(
+ blank=True,
+ verbose_name=_('request'),
+ help_text=_('A specific request (to remove an ingredient for example)')
+ )
+
+ number = models.PositiveIntegerField(
+ verbose_name=_('number'),
+ default=1,
+ )
+
+ ordered_at = models.DateTimeField(
+ default=timezone.now,
+ verbose_name=_('order date'),
+ )
+
+ served = models.BooleanField(
+ default=False,
+ verbose_name=_('served'),
+ )
+
+ served_at = models.DateTimeField(
+ null=True,
+ blank=True,
+ verbose_name=_('served date'),
+ )
+
+ class Meta:
+ verbose_name = _('Order')
+ verbose_name_plural = _('Orders')
+ unique_together = ('activity', 'number', )
+
+ @property
+ def amount(self):
+ return self.dish.price + sum(s.price for s in self.supplements.all())
+
+ def __str__(self):
+ return _("Order of {dish} by {user}").format(
+ dish=str(self.dish),
+ user=str(self.user))
+
+ def save(self, *args, **kwargs):
+ if self.activity != self.dish.activity:
+ raise ValidationError(_('Activities must be the same.'))
+ created = self.pk is None
+ if created:
+ last_order = Order.objects.filter(activity=self.activity).last()
+ if last_order is None:
+ self.number = 1
+ else:
+ self.number = last_order.number + 1
+ super().save(*args, **kwargs)
+
+ transaction = FoodTransaction(
+ order=self,
+ source=self.user.note,
+ destination=self.activity.organizer.note,
+ amount=self.amount,
+ quantity=1,
+ reason=str(self.dish),
+ )
+ transaction.save()
+ else:
+ old_object = Order.objects.get(pk=self.pk)
+ if not old_object.served and self.served:
+ self.served_at = timezone.now()
+ self.transaction.save()
+ super().save(*args, **kwargs)
+
+
+class FoodTransaction(Transaction):
+ """
+ Special type of :model:`note.Transaction` associated to a :model:`food.Order`.
+ """
+ order = models.OneToOneField(
+ Order,
+ on_delete=models.PROTECT,
+ related_name='transaction',
+ verbose_name=_('order')
+ )
+
+ class Meta:
+ verbose_name = _("food transaction")
+ verbose_name_plural = _("food transactions")
+
+ def save(self, *args, **kwargs):
+ self.valid = self.order.served
+ super().save(*args, **kwargs)
+
+
+class Recipe(models.Model):
+ """
+ A recipe is a list of ingredients one can use to easily create a recurrent TransformedFood
+ """
+ name = models.CharField(
+ verbose_name=_("name"),
+ max_length=255,
+ )
+
+ ingredients_json = models.TextField(
+ blank=True,
+ default="[]",
+ verbose_name=_("list of ingredients"),
+ help_text=_("Ingredients of the recipe, encoded in JSON")
+ )
+
+ creater = models.ForeignKey(
+ Club,
+ on_delete=models.CASCADE,
+ verbose_name=_("creater"),
+ )
+
+ class Meta:
+ verbose_name = _("Recipe")
+ verbose_name_plural = _("Recipes")
+ unique_together = ('name', 'creater',)
+
+ def __str__(self):
+ return "{name} ({creater})".format(name=self.name, creater=str(self.creater))
+
+ @property
+ def ingredients(self):
+ """
+ Ingredients are stored in a JSON string
+ """
+ return json.loads(self.ingredients_json)
+
+ @ingredients.setter
+ def ingredients(self, ingredients):
+ """
+ Store ingredients as JSON string
+ """
+ self.ingredients_json = json.dumps(ingredients, indent=2)
diff --git a/apps/food/static/food/js/order.js b/apps/food/static/food/js/order.js
new file mode 100644
index 00000000..0e2043cc
--- /dev/null
+++ b/apps/food/static/food/js/order.js
@@ -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);
+ });
+}
\ No newline at end of file
diff --git a/apps/food/tables.py b/apps/food/tables.py
index 5b854e64..758ac0a3 100644
--- a/apps/food/tables.py
+++ b/apps/food/tables.py
@@ -3,8 +3,11 @@
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, Recipe
class FoodTable(tables.Table):
@@ -29,9 +32,104 @@ class FoodTable(tables.Table):
class Meta:
model = Food
template_name = 'django_tables2/bootstrap4.html'
- fields = ('name', 'owner', 'qr_code_numbers', 'allergens', 'date', 'expiry_date')
+ fields = ('name', 'owner', 'qr_code_numbers', 'allergens', 'traces', 'date', 'expiry_date')
row_attrs = {
'class': 'table-row',
'data-href': lambda record: 'detail/' + str(record.pk),
'style': 'cursor:pointer',
}
+
+
+class DishTable(tables.Table):
+ """
+ List dishes
+ """
+ supplements = tables.Column(empty_values=(), verbose_name=_('Available supplements'), orderable=False)
+
+ def render_supplements(self, record):
+ return ", ".join(str(q.food) for q in record.supplements.all())
+
+ def render_price(self, value):
+ return pretty_money(value)
+
+ class Meta:
+ model = Dish
+ template_name = 'django_tables2/bootstrap4.html'
+ fields = ('main', 'supplements', 'price', 'available')
+ row_attrs = {
+ 'class': 'table-row',
+ 'data-href': lambda record: str(record.pk),
+ 'style': 'cursor:pointer',
+ }
+
+
+DELETE_TEMPLATE = """
+
+"""
+
+
+SERVE_TEMPLATE = """
+
+"""
+
+
+class OrderTable(tables.Table):
+ """
+ Lis all orders.
+ """
+ delete = tables.TemplateColumn(
+ template_code=DELETE_TEMPLATE,
+ extra_context={"delete_trans": _('Delete')},
+ orderable=False,
+ attrs={'td': {'class': lambda record: 'col-sm-1' + (
+ ' d-none' if not PermissionBackend.check_perm(
+ get_current_request(), "food.delete_order",
+ record) else '')}}, verbose_name=_("Delete"), )
+
+ serve = tables.TemplateColumn(
+ template_code=SERVE_TEMPLATE,
+ extra_context={"serve_trans": _('Serve')},
+ orderable=False,
+ attrs={'td': {'class': lambda record: 'col-sm-1' + (
+ ' d-none' if not PermissionBackend.check_perm(
+ get_current_request(), "food.change_order_saved",
+ record) else '')}}, verbose_name=_("Serve"), )
+
+ class Meta:
+ model = Order
+ template_name = 'django_tables2/bootstrap4.html'
+ fields = ('number', 'ordered_at', 'user', 'dish', 'supplements', 'request', 'serve', 'delete')
+ order_by = ('ordered_at', )
+ row_attrs = {
+ 'class': 'table-row',
+ 'style': 'cursor:pointer',
+ }
+
+
+class RecipeTable(tables.Table):
+ """
+ List all recipes
+ """
+ def render_ingredients(self, record):
+ return ", ".join(str(q) for q in record.ingredients)
+
+ class Meta:
+ model = Recipe
+ template_name = 'django_tables2/bootstrap4.html'
+ fields = ('name', 'creater', 'ingredients',)
+ row_attrs = {
+ 'class': 'table-row',
+ 'data-href': lambda record: str(record.pk),
+ 'style': 'cursor:pointer',
+ }
diff --git a/apps/food/templates/food/dish_confirm_delete.html b/apps/food/templates/food/dish_confirm_delete.html
new file mode 100644
index 00000000..885721f0
--- /dev/null
+++ b/apps/food/templates/food/dish_confirm_delete.html
@@ -0,0 +1,25 @@
+{% extends "base.html" %}
+{% comment %}
+SPDX-License-Identifier: GPL-3.0-or-later
+{% endcomment %}
+{% load i18n crispy_forms_tags %}
+
+{% block content %}
+
+
+
+
+ {% blocktrans %}Are you sure you want to delete this dish? This action can't be undone.{% endblocktrans %}
+
+
+
+
+{% endblock %}
diff --git a/apps/food/templates/food/dish_detail.html b/apps/food/templates/food/dish_detail.html
new file mode 100644
index 00000000..136672cf
--- /dev/null
+++ b/apps/food/templates/food/dish_detail.html
@@ -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 %}
+
+{% endblock %}
diff --git a/apps/food/templates/food/dish_form.html b/apps/food/templates/food/dish_form.html
new file mode 100644
index 00000000..f7bc6c90
--- /dev/null
+++ b/apps/food/templates/food/dish_form.html
@@ -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 %}
+
+
+{# Hidden div that store an empty supplement form, to be copied into new forms #}
+
+
+{% endblock %}
+
+{% block extrajavascript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/apps/food/templates/food/dish_list.html b/apps/food/templates/food/dish_list.html
new file mode 100644
index 00000000..62acfb9b
--- /dev/null
+++ b/apps/food/templates/food/dish_list.html
@@ -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 %}
+
+
+ {% render_table table %}
+
+
+{% endblock %}
+
+{% block extrajavascript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/apps/food/templates/food/food_detail.html b/apps/food/templates/food/food_detail.html
index e82cc907..c0bc9555 100644
--- a/apps/food/templates/food/food_detail.html
+++ b/apps/food/templates/food/food_detail.html
@@ -47,6 +47,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% trans "Manage ingredients" %}
+ {% if false %}
+
+ {% trans "Use a recipe" %}
+
+ {% endif %}
{% endif %}
{% trans "Return to the food list" %}
diff --git a/apps/food/templates/food/food_list.html b/apps/food/templates/food/food_list.html
index 8e52a00a..d126f607 100644
--- a/apps/food/templates/food/food_list.html
+++ b/apps/food/templates/food/food_list.html
@@ -64,13 +64,31 @@ SPDX-License-Identifier: GPL-3.0-or-later
- {% if can_add_meal %}
- {% endif %}
+
{% if served.data %}
{% render_table served %}
{% else %}
diff --git a/apps/food/templates/food/kitchen.html b/apps/food/templates/food/kitchen.html
new file mode 100644
index 00000000..01674e58
--- /dev/null
+++ b/apps/food/templates/food/kitchen.html
@@ -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 %}
+
+
+
+ {% for food, quantity in orders.items %}
+
+
+
+
+ {{ quantity }}
+
+ {% endfor %}
+
+
+
+
+
+ {% if table.data %}
+ {% render_table table %}
+ {% else %}
+
+
+ {% trans "There are no special orders." %}
+
+
+ {% endif %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/apps/food/templates/food/manage_ingredients.html b/apps/food/templates/food/manage_ingredients.html
index 0dd7acb5..98888a07 100644
--- a/apps/food/templates/food/manage_ingredients.html
+++ b/apps/food/templates/food/manage_ingredients.html
@@ -22,6 +22,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{{ form.name.label }} |
{{ form.qrcode.label }} |
{{ form.fully_used.label }} |
+ {{ form.add_all_same_name.label }} |
@@ -34,6 +35,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{{ form.name }} |
{{ form.qrcode }} |
{{ form.fully_used }} |
+ {{ form.add_all_same_name }} |
{% endfor %}
@@ -88,7 +90,7 @@ function delete_form_data (form_id) {
document.getElementById(prefix + "name").value = "";
document.getElementById(prefix + "qrcode_pk").value = "";
document.getElementById(prefix + "qrcode").value = "";
- document.getElementById(prefix + "fully_used").checked = true;
+ document.getElementById(prefix + "fully_used").checked = false;
}
var form_count = {{ ingredients_count }} + 1;
diff --git a/apps/food/templates/food/order_confirm_delete.html b/apps/food/templates/food/order_confirm_delete.html
new file mode 100644
index 00000000..7cc7e408
--- /dev/null
+++ b/apps/food/templates/food/order_confirm_delete.html
@@ -0,0 +1,25 @@
+{% extends "base.html" %}
+{% comment %}
+SPDX-License-Identifier: GPL-3.0-or-later
+{% endcomment %}
+{% load i18n crispy_forms_tags %}
+
+{% block content %}
+
+
+
+
+ {% blocktrans %}Are you sure you want to delete this order? This action can't be undone.{% endblocktrans %}
+
+
+
+
+{% endblock %}
diff --git a/apps/food/templates/food/order_form.html b/apps/food/templates/food/order_form.html
new file mode 100644
index 00000000..7b37be84
--- /dev/null
+++ b/apps/food/templates/food/order_form.html
@@ -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 %}
+
+{% endblock %}
diff --git a/apps/food/templates/food/order_list.html b/apps/food/templates/food/order_list.html
new file mode 100644
index 00000000..352f78c9
--- /dev/null
+++ b/apps/food/templates/food/order_list.html
@@ -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 %}
+
+{% endblock %}
+
+{% block extrajavascript %}
+
+{% endblock%}
\ No newline at end of file
diff --git a/apps/food/templates/food/recipe_detail.html b/apps/food/templates/food/recipe_detail.html
new file mode 100644
index 00000000..990d73a3
--- /dev/null
+++ b/apps/food/templates/food/recipe_detail.html
@@ -0,0 +1,31 @@
+{% 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 %}
+
+{% endblock %}
diff --git a/apps/food/templates/food/recipe_form.html b/apps/food/templates/food/recipe_form.html
new file mode 100644
index 00000000..ab8e1c73
--- /dev/null
+++ b/apps/food/templates/food/recipe_form.html
@@ -0,0 +1,122 @@
+{% 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 %}
+
+
+{# Hidden div that store an empty supplement form, to be copied into new forms #}
+
+
+{% endblock %}
+
+{% block extrajavascript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/apps/food/templates/food/recipe_list.html b/apps/food/templates/food/recipe_list.html
new file mode 100644
index 00000000..0a72470e
--- /dev/null
+++ b/apps/food/templates/food/recipe_list.html
@@ -0,0 +1,32 @@
+{% 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 %}
+
+
+ {% render_table table %}
+
+
+{% endblock %}
+
+{% block extrajavascript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/apps/food/templates/food/served_order_list.html b/apps/food/templates/food/served_order_list.html
new file mode 100644
index 00000000..8e50eb5e
--- /dev/null
+++ b/apps/food/templates/food/served_order_list.html
@@ -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 %}
+
+{% endblock %}
+
+{% block extrajavascript %}
+
+{% endblock%}
\ No newline at end of file
diff --git a/apps/food/templates/food/supplement_detail.html b/apps/food/templates/food/supplement_detail.html
new file mode 100644
index 00000000..1786d85a
--- /dev/null
+++ b/apps/food/templates/food/supplement_detail.html
@@ -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 %}
+
+{% endblock %}
diff --git a/apps/food/templates/food/use_recipe_form.html b/apps/food/templates/food/use_recipe_form.html
new file mode 100644
index 00000000..136b955a
--- /dev/null
+++ b/apps/food/templates/food/use_recipe_form.html
@@ -0,0 +1,80 @@
+{% 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 %}
+
+{% endblock %}
+
+{% block extrajavascript %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/apps/food/tests/__init__.py b/apps/food/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/apps/food/tests/test_food.py b/apps/food/tests/test_food.py
index 9c314bf7..38629a43 100644
--- a/apps/food/tests/test_food.py
+++ b/apps/food/tests/test_food.py
@@ -6,9 +6,12 @@ from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
+from activity.models import Activity, ActivityType
+from member.models import Club
-from ..api.views import AllergenViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet
-from ..models import Allergen, BasicFood, TransformedFood, QRCode
+from ..api.views import AllergenViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet, \
+ DishViewSet, SupplementViewSet, OrderViewSet, FoodTransactionViewSet
+from ..models import Allergen, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order # TODO FoodTransaction
class TestFood(TestCase):
@@ -53,73 +56,293 @@ class TestFood(TestCase):
food_container=self.basicfood,
)
- def test_food_list(self):
- """
- Display food list
- """
- response = self.client.get(reverse('food:food_list'))
- self.assertEqual(response.status_code, 200)
+ def test_food_list(self):
+ """
+ Display food list
+ """
+ response = self.client.get(reverse('food:food_list'))
+ self.assertEqual(response.status_code, 200)
- def test_qrcode_create(self):
- """
- Display QRCode creation
- """
- response = self.client.get(reverse('food:qrcode_create'))
- self.assertEqual(response.status_code, 200)
+ def test_qrcode_create(self):
+ """
+ Display QRCode creation
+ """
+ response = self.client.get(reverse('food:qrcode_create', kwargs={"slug": 2}))
+ self.assertEqual(response.status_code, 200)
- def test_basicfood_create(self):
- """
- Display BasicFood creation
- """
- response = self.client.get(reverse('food:basicfood_create'))
- self.assertEqual(response.status_code, 200)
+ def test_basicfood_create(self):
+ """
+ Display BasicFood creation
+ """
+ response = self.client.get(reverse('food:basicfood_create', kwargs={"slug": 2}))
+ self.assertEqual(response.status_code, 200)
- def test_transformedfood_create(self):
- """
- Display TransformedFood creation
- """
- response = self.client.get(reverse('food:transformedfood_create'))
- self.assertEqual(response.status_code, 200)
+ def test_transformedfood_create(self):
+ """
+ Display TransformedFood creation
+ """
+ response = self.client.get(reverse('food:transformedfood_create'))
+ self.assertEqual(response.status_code, 200)
- def test_food_create(self):
- """
- Display Food update
- """
- response = self.client.get(reverse('food:food_update'))
- self.assertEqual(response.status_code, 200)
+ def test_food_update(self):
+ """
+ Display Food update
+ """
+ response = self.client.get(reverse('food:food_update', args=(self.basicfood.pk,)))
+ self.assertEqual(response.status_code, 200)
- def test_food_view(self):
- """
- Display Food detail
- """
- response = self.client.get(reverse('food:food_view'))
- self.assertEqual(response.status_code, 302)
+ def test_food_view(self):
+ """
+ Display Food detail
+ """
+ response = self.client.get(reverse('food:food_view', args=(self.basicfood.pk,)))
+ self.assertEqual(response.status_code, 302)
- def test_basicfood_view(self):
- """
- Display BasicFood detail
- """
- response = self.client.get(reverse('food:basicfood_view'))
- self.assertEqual(response.status_code, 200)
+ def test_basicfood_view(self):
+ """
+ Display BasicFood detail
+ """
+ response = self.client.get(reverse('food:basicfood_view', args=(self.basicfood.pk,)))
+ self.assertEqual(response.status_code, 200)
- def test_transformedfood_view(self):
- """
- Display TransformedFood detail
- """
- response = self.client.get(reverse('food:transformedfood_view'))
- self.assertEqual(response.status_code, 200)
+ def test_transformedfood_view(self):
+ """
+ Display TransformedFood detail
+ """
+ response = self.client.get(reverse('food:transformedfood_view', args=(self.transformedfood.pk,)))
+ self.assertEqual(response.status_code, 200)
- def test_add_ingredient(self):
- """
- Display add ingredient view
- """
- response = self.client.get(reverse('food:add_ingredient'))
- self.assertEqual(response.status_code, 200)
+ def test_add_ingredient(self):
+ """
+ Display add ingredient view
+ """
+ response = self.client.get(reverse('food:add_ingredient', args=(self.transformedfood.pk,)))
+ self.assertEqual(response.status_code, 200)
+
+
+'''class TestFoodOrder(TestCase):
+ """
+ Test Food Order
+ """
+ fixtures = ('initial',)
+
+ def setUp(self):
+ self.user = User.objects.create_superuser(
+ username='admintoto',
+ password='toto1234',
+ email='toto@example.com'
+ )
+ self.client.force_login(self.user)
+
+ sess = self.client.session
+ sess['permission_mask'] = 42
+ sess.save()
+
+ self.basicfood = BasicFood.objects.create(
+ id=1,
+ name='basicfood',
+ owner=Club.objects.get(name="BDE"),
+ expiry_date=timezone.now(),
+ is_ready=True,
+ date_type='DLC',
+ )
+
+ self.transformedfood = TransformedFood.objects.create(
+ id=2,
+ name='transformedfood',
+ owner=Club.objects.get(name="BDE"),
+ expiry_date=timezone.now(),
+ is_ready=True,
+ )
+
+ self.second_transformedfood = TransformedFood.objects.create(
+ id=3,
+ name='second transformedfood',
+ owner=Club.objects.get(name="BDE"),
+ expiry_date=timezone.now(),
+ is_ready=True,
+ )
+
+ self.third_transformedfood = TransformedFood.objects.create(
+ id=4,
+ name='third transformedfood',
+ owner=Club.objects.get(name="BDE"),
+ expiry_date=timezone.now(),
+ is_ready=True,
+ )
+
+ self.activity = Activity.objects.create(
+ activity_type=ActivityType.objects.get(name="Perm bouffe"),
+ organizer=Club.objects.get(name="BDE"),
+ creater=self.user,
+ attendees_club_id=1,
+ date_start=timezone.now(),
+ date_end=timezone.now(),
+ name="Test activity",
+ open=True,
+ valid=True,
+ )
+
+ self.dish = Dish.objects.create(
+ main=self.transformedfood,
+ price=500,
+ activity=self.activity,
+ available=True,
+ )
+
+ self.second_dish = Dish.objects.create(
+ main=self.second_transformedfood,
+ price=1000,
+ activity=self.activity,
+ available=True,
+ )
+
+ self.supplement = Supplement.objects.create(
+ dish=self.dish,
+ food=self.basicfood,
+ price=100,
+ )
+
+ self.order = Order.objects.create(
+ user=self.user,
+ activity=self.activity,
+ dish=self.dish,
+ )
+ self.order.supplements.add(self.supplement)
+ self.order.save()
+
+ def test_dish_list(self):
+ """
+ Try to display dish list
+ """
+ response = self.client.get(reverse("food:dish_list", kwargs={"activity_pk": self.activity.pk}))
+ self.assertEqual(response.status_code, 200)
+
+ def test_dish_create(self):
+ """
+ Try to create a dish
+ """
+ response = self.client.get(reverse("food:dish_create", kwargs={"activity_pk": self.activity.pk}))
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.post(reverse("food:dish_create", kwargs={"activity_pk": self.activity.pk}), data={
+ "main": self.third_transformedfood.pk,
+ "price": 4,
+ "activity": self.activity.pk,
+ "supplements-0-food": self.basicfood.pk,
+ "supplements-0-price": 0.5,
+ "supplements-TOTAL_FORMS": 1,
+ "supplements-INITIAL_FORMS": 0,
+ "supplements-MIN_NUM_FORMS": 0,
+ "supplements-MAX_NUM_FORMS": 1000,
+ })
+ self.assertRedirects(response, reverse("food:dish_list", kwargs={"activity_pk": self.activity.pk}), 302, 200)
+ self.assertTrue(Dish.objects.filter(main=self.third_transformedfood).exists())
+ self.assertTrue(Supplement.objects.filter(food=self.basicfood, price=50).exists())
+
+ def test_dish_update(self):
+ """
+ Try to update a dish
+ """
+ response = self.client.get(reverse("food:dish_update", kwargs={"activity_pk": self.activity.pk, "pk": self.dish.pk}))
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.post(reverse("food:dish_update", kwargs={"activity_pk": self.activity.pk, "pk": self.dish.pk}), data={
+ "price": 6,
+ "supplements-0-food": self.basicfood.pk,
+ "supplements-0-price": 1,
+ "supplements-1-food": self.basicfood.pk,
+ "supplements-1-price": 0.25,
+ "supplements-TOTAL_FORMS": 2,
+ "supplements-INITIAL_FORMS": 0,
+ "supplements-MIN_NUM_FORMS": 0,
+ "supplements-MAX_NUM_FORMS": 1000,
+ })
+ self.assertRedirects(response, reverse("food:dish_detail", kwargs={"activity_pk": self.activity.pk, "pk": self.dish.pk}), 302, 200)
+ self.dish.refresh_from_db()
+ self.assertTrue(Dish.objects.filter(main=self.transformedfood, price=600).exists())
+ self.assertTrue(Supplement.objects.filter(dish=self.dish, food=self.basicfood, price=25).exists())
+
+ def test_dish_detail(self):
+ """
+ Try to display dish details
+ """
+ response = self.client.get(reverse("food:dish_detail", kwargs={"activity_pk": self.activity.pk, "pk": self.dish.pk}))
+ self.assertEqual(response.status_code, 200)
+
+ def test_dish_delete(self):
+ """
+ Try to delete a dish
+ """
+ response = self.client.get(reverse("food:dish_delete", kwargs={"activity_pk": self.activity.pk, "pk": self.dish.pk}))
+ self.assertEqual(response.status_code, 200)
+
+ # Cannot delete already ordered Dish
+ response = self.client.delete(reverse("food:dish_delete", kwargs={"activity_pk": self.activity.pk, "pk": self.dish.pk}))
+ self.assertEqual(response.status_code, 403)
+ self.assertTrue(Dish.objects.filter(pk=self.dish.pk).exists())
+
+ # Can delete a Dish with no order
+ response = self.client.delete(reverse("food:dish_delete", kwargs={"activity_pk": self.activity.pk, "pk": self.second_dish.pk}))
+ self.assertRedirects(response, reverse("food:dish_list", kwargs={"activity_pk": self.activity.pk}))
+ self.assertFalse(Dish.objects.filter(pk=self.second_dish.pk).exists())
+
+ def test_order_food(self):
+ """
+ Try to make an order
+ """
+ response = self.client.get(reverse("food:order_create", kwargs={"activity_pk": self.activity.pk}))
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.post(reverse("food:order_create", kwargs={"activity_pk": self.activity.pk}), data=dict(
+ user=self.user.pk,
+ activity=self.activity.pk,
+ dish=self.second_dish.pk,
+ supplements=self.supplement.pk
+ ))
+ self.assertRedirects(response, reverse("food:food_list"))
+ self.assertTrue(Order.objects.filter(user=self.user, dish=self.second_dish, activity=self.activity).exists())
+
+ def test_order_list(self):
+ """
+ Try to display order list
+ """
+ response = self.client.get(reverse("food:order_list", kwargs={"activity_pk": self.activity.pk}))
+ self.assertEqual(response.status_code, 200)
+
+ def test_served_order_list(self):
+ """
+ Try to display served order list
+ """
+ response = self.client.get(reverse("food:served_order_list", kwargs={"activity_pk": self.activity.pk}))
+ self.assertEqual(response.status_code, 200)
+
+ def test_serve_order(self):
+ """
+ Try to serve an order, then to unserve it
+ """
+ response = self.client.patch("/api/food/order/" + str(self.order.pk) + "/", data=dict(
+ served=True
+ ), content_type="application/json")
+ self.assertEqual(response.status_code, 200)
+ self.order.refresh_from_db()
+ self.assertTrue(Order.objects.filter(dish=self.dish, user=self.user, served=True).exists())
+ self.assertIsNotNone(self.order.served_at)
+
+ self.assertTrue(FoodTransaction.objects.filter(order=self.order, valid=True).exists())
+
+ response = self.client.patch("/api/food/order/" + str(self.order.pk) + "/", data=dict(
+ served=False
+ ), content_type="application/json")
+ self.assertEqual(response.status_code, 200)
+ self.assertTrue(Order.objects.filter(dish=self.dish, user=self.user, served=False).exists())
+
+ self.assertTrue(FoodTransaction.objects.filter(order=self.order, valid=False).exists())'''
class TestFoodAPI(TestAPI):
def setUp(self) -> None:
- super().setUP()
+ super().setUp()
self.allergen = Allergen.objects.create(
name='name',
@@ -145,26 +368,84 @@ class TestFoodAPI(TestAPI):
food_container=self.basicfood,
)
- def test_allergen_api(self):
- """
- Load Allergen API page and test all filters and permissions
- """
- self.check_viewset(AllergenViewSet, '/api/food/allergen/')
+ self.activity = Activity.objects.create(
+ activity_type=ActivityType.objects.get(name="Perm bouffe"),
+ organizer=Club.objects.get(name="BDE"),
+ creater=self.user,
+ attendees_club_id=1,
+ date_start=timezone.now(),
+ date_end=timezone.now(),
+ name="Test activity",
+ open=True,
+ valid=True,
+ )
- def test_basicfood_api(self):
- """
- Load BasicFood API page and test all filters and permissions
- """
- self.check_viewset(BasicFoodViewSet, '/api/food/basicfood/')
+ self.dish = Dish.objects.create(
+ main=self.transformedfood,
+ price=500,
+ activity=self.activity,
+ available=True,
+ )
+ self.supplement = Supplement.objects.create(
+ dish=self.dish,
+ food=self.basicfood,
+ price=100,
+ )
+
+ self.order = Order.objects.create(
+ user=self.user,
+ activity=self.activity,
+ dish=self.dish,
+ )
+ self.order.supplements.add(self.supplement)
+ self.order.save()
+
+ def test_allergen_api(self):
+ """
+ Load Allergen API page and test all filters and permissions
+ """
+ self.check_viewset(AllergenViewSet, '/api/food/allergen/')
+
+ def test_basicfood_api(self):
+ """
+ Load BasicFood API page and test all filters and permissions
+ """
+ self.check_viewset(BasicFoodViewSet, '/api/food/basicfood/')
+
+ # TODO Repair and detabulate this test
def test_transformedfood_api(self):
"""
Load TransformedFood API page and test all filters and permissions
"""
self.check_viewset(TransformedFoodViewSet, '/api/food/transformedfood/')
- def test_qrcode_api(self):
- """
- Load QRCode API page and test all filters and permissions
- """
- self.check_viewset(QRCodeViewSet, '/api/food/qrcode/')
+ def test_qrcode_api(self):
+ """
+ Load QRCode API page and test all filters and permissions
+ """
+ self.check_viewset(QRCodeViewSet, '/api/food/qrcode/')
+
+ def test_dish_api(self):
+ """
+ Load Dish API page and test all filters and permissions
+ """
+ self.check_viewset(DishViewSet, '/api/food/dish/')
+
+ def test_supplement_api(self):
+ """
+ Load Supplement API page and test all filters and permissions
+ """
+ self.check_viewset(SupplementViewSet, '/api/food/supplement/')
+
+ def test_order_api(self):
+ """
+ Load Order API page and test all filters and permissions
+ """
+ self.check_viewset(OrderViewSet, '/api/food/order/')
+
+ def test_foodtransaction_api(self):
+ """
+ Load FoodTransaction API page and test all filters and permissions
+ """
+ self.check_viewset(FoodTransactionViewSet, '/api/food/foodtransaction/')
diff --git a/apps/food/urls.py b/apps/food/urls.py
index 82a7f22e..299548f6 100644
--- a/apps/food/urls.py
+++ b/apps/food/urls.py
@@ -9,14 +9,30 @@ app_name = 'food'
urlpatterns = [
path('', views.FoodListView.as_view(), name='food_list'),
- path('', views.QRCodeCreateView.as_view(), name='qrcode_create'),
- path('/add/basic', views.BasicFoodCreateView.as_view(), name='basicfood_create'),
- path('add/transformed', views.TransformedFoodCreateView.as_view(), name='transformedfood_create'),
- path('update/', views.FoodUpdateView.as_view(), name='food_update'),
- path('update/ingredients/', views.ManageIngredientsView.as_view(), name='manage_ingredients'),
- path('detail/', views.FoodDetailView.as_view(), name='food_view'),
- path('detail/basic/', views.BasicFoodDetailView.as_view(), name='basicfood_view'),
- path('detail/transformed/', views.TransformedFoodDetailView.as_view(), name='transformedfood_view'),
- path('add/ingredient/', views.AddIngredientView.as_view(), name='add_ingredient'),
+ path('/', views.QRCodeCreateView.as_view(), name='qrcode_create'),
+ path('/add/basic/', views.BasicFoodCreateView.as_view(), name='basicfood_create'),
+ path('add/transformed/', views.TransformedFoodCreateView.as_view(), name='transformedfood_create'),
+ path('update//', views.FoodUpdateView.as_view(), name='food_update'),
+ path('update/ingredients//', views.ManageIngredientsView.as_view(), name='manage_ingredients'),
+ path('detail//', views.FoodDetailView.as_view(), name='food_view'),
+ path('detail/basic//', views.BasicFoodDetailView.as_view(), name='basicfood_view'),
+ path('detail/transformed//', views.TransformedFoodDetailView.as_view(), name='transformedfood_view'),
+ path('add/ingredient//', views.AddIngredientView.as_view(), name='add_ingredient'),
path('redirect/', views.QRCodeRedirectView.as_view(), name='redirect_view'),
+ # TODO not always store activity_pk in url
+ # path('activity//dishes/add/', views.DishCreateView.as_view(), name='dish_create'),
+ # path('activity//dishes/', views.DishListView.as_view(), name='dish_list'),
+ # path('activity//dishes//', views.DishDetailView.as_view(), name='dish_detail'),
+ # path('activity//dishes//update/', views.DishUpdateView.as_view(), name='dish_update'),
+ # path('activity//dishes//delete/', views.DishDeleteView.as_view(), name='dish_delete'),
+ # path('activity//order/', views.OrderCreateView.as_view(), name='order_create'),
+ # path('activity//orders/', views.OrderListView.as_view(), name='order_list'),
+ # path('activity//orders/served', views.ServedOrderListView.as_view(), name='served_order_list'),
+ # path('activity//kitchen/', views.KitchenView.as_view(), name='kitchen'),
+ # path('recipe/add/', views.RecipeCreateView.as_view(), name='recipe_create'),
+ # path('recipe/', views.RecipeListView.as_view(), name='recipe_list'),
+ # path('recipe//', views.RecipeDetailView.as_view(), name='recipe_detail'),
+ # path('recipe//update/', views.RecipeUpdateView.as_view(), name='recipe_update'),
+ # path('update/ingredients//recipe/', views.UseRecipeView.as_view(), name='recipe_use'),
+ # path('ajax/get_ingredients/', views.get_ingredients_for_recipe, name='get_ingredients'),
]
diff --git a/apps/food/views.py b/apps/food/views.py
index 2ee8c998..c2dfa17c 100644
--- a/apps/food/views.py
+++ b/apps/food/views.py
@@ -4,25 +4,32 @@
from datetime import timedelta
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.models import Q
-from django.http import HttpResponseRedirect, Http404
+from django.db.models import Q, Count
+from django.http import HttpResponseRedirect, Http404, JsonResponse
+from django.views.decorators.http import require_GET
from django.views.generic import DetailView, UpdateView, CreateView
from django.views.generic.list import ListView
from django.views.generic.base import RedirectView
+from django.views.generic.edit import DeleteView
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from member.models import Club, Membership
+from activity.models import Activity
from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin, ProtectedCreateView, LoginRequiredMixin
-from .models import Food, BasicFood, TransformedFood, QRCode
+from .models import Food, BasicFood, TransformedFood, QRCode, Order, Dish, Supplement, Recipe
from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \
ManageIngredientsForm, ManageIngredientsFormSet, AddIngredientForms, \
- BasicFoodUpdateForms, TransformedFoodUpdateForms
-from .tables import FoodTable
+ BasicFoodUpdateForms, TransformedFoodUpdateForms, \
+ DishForm, SupplementFormSet, SupplementFormSetHelper, OrderForm, RecipeForm, \
+ RecipeIngredientsForm, RecipeIngredientsFormSet, UseRecipeForm
+from .tables import FoodTable, DishTable, OrderTable, RecipeTable
from .utils import pretty_duration
@@ -116,6 +123,13 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li
context['club_tables'] = tables[3:]
context['can_add_meal'] = PermissionBackend.check_perm(self.request, 'food.transformedfood_add')
+
+ context['can_add_recipe'] = PermissionBackend.check_perm(self.request, 'food.recipe_add')
+
+ context['can_view_recipes'] = PermissionBackend.check_perm(self.request, 'food.recipe_view')
+
+ context["open_activities"] = Activity.objects.filter(activity_type__name="Perm bouffe", open=True)
+
return context
@@ -231,6 +245,8 @@ class BasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
for field in context['form'].fields:
if field == 'allergens':
context['form'].fields[field].initial = getattr(food, field).all()
+ elif field == 'traces':
+ context['form'].fields[field].initial = getattr(food, field).all()
else:
context['form'].fields[field].initial = getattr(food, field)
@@ -290,34 +306,42 @@ class ManageIngredientsView(LoginRequiredMixin, UpdateView):
def form_valid(self, form):
old_ingredients = list(self.object.ingredients.all()).copy()
old_allergens = list(self.object.allergens.all()).copy()
+ old_traces = list(self.object.traces.all()).copy()
self.object.ingredients.clear()
for i in range(self.object.ingredients.all().count() + 1 + MAX_FORMS):
prefix = 'form-' + str(i) + '-'
- if form.data[prefix + 'qrcode'] not in ['0', '']:
+
+ ingredient = None
+ if form.data[prefix + 'qrcode'] not in ['0', '', 'NaN']:
ingredient = QRCode.objects.get(pk=form.data[prefix + 'qrcode']).food_container
+
+ elif form.data[prefix + 'name'] != '':
+ ingredient = Food.objects.get(pk=form.data[prefix + 'name'])
+
+ if form.data.get(prefix + 'add_all_same_name') == 'on':
+ ingredients = Food.objects.filter(name=ingredient.name, owner=ingredient.owner, end_of_life='')
+ else:
+ ingredients = [ingredient]
+
+ for ingredient in ingredients:
self.object.ingredients.add(ingredient)
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()
- elif form.data[prefix + 'name'] != '':
- ingredient = Food.objects.get(pk=form.data[prefix + 'name'])
- self.object.ingredients.add(ingredient)
- if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on':
- ingredient.end_of_life = _('Fully used in {meal}'.format(
- meal=self.object.name))
- ingredient.save()
# We recalculate new expiry date and allergens
self.object.expiry_date = self.object.creation_date + self.object.shelf_life
self.object.allergens.clear()
+ self.object.traces.clear()
for ingredient in self.object.ingredients.iterator():
if not (ingredient.polymorphic_ctype.model == 'basicfood' and ingredient.date_type == 'DDM'):
self.object.expiry_date = min(self.object.expiry_date, ingredient.expiry_date)
self.object.allergens.set(self.object.allergens.union(ingredient.allergens.all()))
+ self.object.traces.set(self.object.traces.union(ingredient.traces.all()))
- self.object.save(old_ingredients=old_ingredients, old_allergens=old_allergens)
+ self.object.save(old_ingredients=old_ingredients, old_allergens=old_allergens, old_traces=old_traces)
return HttpResponseRedirect(self.get_success_url())
def get_context_data(self, *args, **kwargs):
@@ -341,6 +365,7 @@ class ManageIngredientsView(LoginRequiredMixin, UpdateView):
'qr_number': '' if qr.count() == 0 else qr[0].qr_code_number,
'fully_used': 'true' if ingredient.end_of_life else '',
})
+
return context
def get_success_url(self, **kwargs):
@@ -369,13 +394,15 @@ class AddIngredientView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
for meal in meals:
old_ingredients = list(meal.ingredients.all()).copy()
old_allergens = list(meal.allergens.all()).copy()
+ old_traces = list(meal.traces.all()).copy()
meal.ingredients.add(self.object.pk)
# update allergen and expiry date if necessary
if not (self.object.polymorphic_ctype.model == 'basicfood'
and self.object.date_type == 'DDM'):
meal.expiry_date = min(meal.expiry_date, self.object.expiry_date)
meal.allergens.set(meal.allergens.union(self.object.allergens.all()))
- meal.save(old_ingredients=old_ingredients, old_allergens=old_allergens)
+ meal.traces.set(meal.traces.union(self.object.traces.all()))
+ meal.save(old_ingredients=old_ingredients, old_allergens=old_allergens, old_traces=old_traces)
if 'fully_used' in form.data:
if not self.object.end_of_life:
self.object.end_of_life = _(f'Food fully used in : {meal.name}')
@@ -405,6 +432,7 @@ class FoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
form.instance.creater = self.request.user
food = Food.objects.get(pk=self.kwargs['pk'])
old_allergens = list(food.allergens.all()).copy()
+ old_traces = list(food.traces.all()).copy()
if food.polymorphic_ctype.model == 'transformedfood':
old_ingredients = food.ingredients.all()
@@ -418,7 +446,7 @@ class FoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
if food.polymorphic_ctype.model == 'transformedfood':
form.instance.save(old_ingredients=old_ingredients)
else:
- form.instance.save(old_allergens=old_allergens)
+ form.instance.save(old_allergens=old_allergens, old_traces=old_traces)
return ans
def get_form_class(self, **kwargs):
@@ -451,7 +479,7 @@ class FoodDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
- fields = ["name", "owner", "expiry_date", "allergens", "is_ready", "end_of_life", "order"]
+ fields = ["name", "owner", "expiry_date", "allergens", "traces", "is_ready", "end_of_life", "order"]
fields = dict([(field, getattr(self.object, field)) for field in fields])
if fields["is_ready"]:
@@ -460,6 +488,8 @@ class FoodDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
fields["is_ready"] = _("No")
fields["allergens"] = ", ".join(
allergen.name for allergen in fields["allergens"].all())
+ fields["traces"] = ", ".join(
+ trace.name for trace in fields["traces"].all())
context["fields"] = [(
Food._meta.get_field(field).verbose_name.capitalize(),
@@ -530,3 +560,520 @@ class QRCodeRedirectView(RedirectView):
if slug:
return reverse_lazy('food:qrcode_create', kwargs={'slug': slug})
return reverse_lazy('food:list')
+
+
+class DishCreateView(ProtectQuerysetMixin, ProtectedCreateView):
+ """
+ Create a dish
+ """
+ model = Dish
+ form_class = DishForm
+ extra_context = {"title": _('Create dish')}
+
+ def get_sample_object(self):
+ activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
+ sample_food = TransformedFood(
+ name="Sample food",
+ owner=activity.organizer,
+ expiry_date=timezone.now() + timedelta(days=7),
+ is_ready=True,
+ )
+ sample_dish = Dish(
+ main=sample_food,
+ price=100,
+ activity=activity,
+ )
+ return sample_dish
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+
+ form = context['form']
+ form.helper = FormHelper()
+ # Remove form tag on the generation of the form in the template (already present on the template)
+ form.helper.form_tag = False
+ # The formset handles the set of the supplements
+ form_set = SupplementFormSet(instance=form.instance)
+ context['formset'] = form_set
+ context['helper'] = SupplementFormSetHelper()
+
+ return context
+
+ def get_form(self, form_class=None):
+ form = super().get_form(form_class)
+ if "available" in form.fields:
+ del form.fields["available"]
+ return form
+
+ @transaction.atomic
+ def form_valid(self, form):
+ activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
+
+ form.instance.activity = activity
+
+ ret = super().form_valid(form)
+
+ # For each supplement, we save it
+ formset = SupplementFormSet(self.request.POST, instance=form.instance)
+ if formset.is_valid():
+ for f in formset:
+ # We don't save the product if the price is not entered, ie. if the line is empty
+ if f.is_valid() and f.instance.price:
+ f.save()
+ f.instance.save()
+ else:
+ f.instance = None
+
+ return ret
+
+ def get_success_url(self):
+ return reverse_lazy('food:dish_list', kwargs={"activity_pk": self.kwargs["activity_pk"]})
+
+
+class DishListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
+ """
+ List dishes for this activity
+ """
+ model = Dish
+ table_class = DishTable
+ extra_context = {"title": _('Dishes served during')}
+ template_name = 'food/dish_list.html'
+
+ def get_queryset(self):
+ return super().get_queryset().filter(activity__pk=self.kwargs["activity_pk"])
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+
+ activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
+ context["activity"] = activity
+
+ context["can_add_dish"] = PermissionBackend.check_perm(self.request, 'food.dish_add')
+
+ return context
+
+
+class DishDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
+ """
+ View a dish for this activity
+ """
+ model = Dish
+ extra_context = {"title": _('Details of:')}
+ context_oject_name = "dish"
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+
+ context["food"] = self.object.main
+
+ context["supplements"] = self.object.supplements.all()
+
+ context["update"] = PermissionBackend.check_perm(self.request, "food.change_dish")
+
+ context["delete"] = not Order.objects.filter(dish=self.get_object()).exists() and PermissionBackend.check_perm(self.request, "food.delete_dish")
+
+ return context
+
+
+class DishUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
+ """
+ A view to update a dish
+ """
+ model = Dish
+ form_class = DishForm
+ extra_context = {"title": _("Update a dish")}
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+
+ form = context['form']
+ form.helper = FormHelper()
+ # Remove form tag on the generation of the form in the template (already present on the template)
+ form.helper.form_tag = False
+ # The formset handles the set of the supplements
+ form_set = SupplementFormSet(instance=form.instance)
+ context['formset'] = form_set
+ context['helper'] = SupplementFormSetHelper()
+
+ return context
+
+ def get_form(self, form_class=None):
+ form = super().get_form(form_class)
+ if 'main' in form.fields:
+ del form.fields["main"]
+ return form
+
+ @transaction.atomic
+ def form_valid(self, form):
+ activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
+
+ form.instance.activity = activity
+
+ ret = super().form_valid(form)
+
+ # For each supplement, we save it
+ formset = SupplementFormSet(self.request.POST, instance=form.instance)
+ saved = []
+ if formset.is_valid():
+ for f in formset:
+ # We don't save the product if the price is not entered, ie. if the line is empty
+ if f.is_valid() and f.instance.price:
+ f.save()
+ f.instance.save()
+ saved.append(f.instance.pk)
+ else:
+ f.instance = None
+ # Remove old supplements that weren't given in the form
+ Supplement.objects.filter(~Q(pk__in=saved), dish=form.instance).delete()
+
+ return ret
+
+ def get_success_url(self):
+ return reverse_lazy('food:dish_detail', kwargs={"activity_pk": self.kwargs["activity_pk"], "pk": self.kwargs["pk"]})
+
+
+class DishDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView):
+ """
+ Delete a dish with no order yet
+ """
+ model = Dish
+ extra_context = {"title": _('Delete dish')}
+
+ def delete(self, request, *args, **kwargs):
+ if Order.objects.filter(dish=self.get_object()).exists():
+ raise PermissionDenied(_("This dish cannot be deleted because it has already been ordered"))
+ return super().delete(request, *args, **kwargs)
+
+ def get_success_url(self):
+ return reverse_lazy('food:dish_list', kwargs={"activity_pk": self.kwargs["activity_pk"]})
+
+
+class OrderCreateView(ProtectQuerysetMixin, ProtectedCreateView):
+ """
+ Order a meal
+ """
+ model = Order
+ form_class = OrderForm
+ extra_context = {"title": _('Order food')}
+
+ def get_sample_object(self):
+ activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
+ sample_order = Order(
+ user=self.request.user,
+ activity=activity,
+ dish=Dish.objects.filter(activity=activity).last(),
+ )
+ return sample_order
+
+ def get_form(self):
+ form = super().get_form()
+
+ form.fields["user"].initial = self.request.user
+ form.fields["user"].disabled = True
+
+ return form
+
+ def form_valid(self, form):
+ activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
+
+ form.instance.activity = activity
+
+ return super().form_valid(form)
+
+ def get_success_url(self):
+ return reverse_lazy('food:food_list')
+
+
+class OrderListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView):
+ """
+ List existing Families
+ """
+ model = Order
+ table_class = OrderTable
+ extra_context = {"title": _('Order list')}
+ paginate_by = 10
+
+ def get_queryset(self, **kwargs):
+ activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
+ return Order.objects.filter(activity=activity).order_by('number')
+
+ def get_tables(self):
+ activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
+ dishes = Dish.objects.filter(activity=activity)
+
+ tables = [OrderTable] * dishes.count()
+ self.tables = tables
+ tables = super().get_tables()
+ for i in range(dishes.count()):
+ tables[i].prefix = dishes[i].main.name
+ return tables
+
+ def get_tables_data(self):
+ activity = Activity.objects.get(pk=self.kwargs["activity_pk"])
+ dishes = Dish.objects.filter(activity=activity)
+
+ tables = []
+
+ for dish in dishes:
+ tables.append(self.get_queryset().order_by('ordered_at').filter(
+ dish=dish, served=False).filter(
+ PermissionBackend.filter_queryset(self.request, Order, 'view')
+ ))
+
+ return tables
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+
+ context["activity"] = Activity.objects.get(pk=self.kwargs["activity_pk"])
+
+ return context
+
+
+class ServedOrderListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
+ """
+ View served orders
+ """
+ model = Order
+ template_name = 'food/served_order_list.html'
+ table_class = OrderTable
+
+ def get_queryset(self):
+ return super().get_queryset().filter(activity__pk=self.kwargs["activity_pk"], served=True).order_by('-served_at')
+
+ def get_table(self, **kwargs):
+ table = super().get_table(**kwargs)
+
+ table.columns.hide("delete")
+
+ return table
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+
+ context["activity"] = Activity.objects.get(pk=self.kwargs["activity_pk"])
+
+ return context
+
+
+class KitchenView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
+ """
+ The view to display useful information for the kitchen
+ """
+ model = Order
+ table_class = OrderTable
+ template_name = 'food/kitchen.html'
+ extra_context = {'title': _('Kitchen')}
+
+ def get_queryset(self):
+ return super().get_queryset().filter(~Q(supplements__isnull=True, request=''), activity__pk=self.kwargs["activity_pk"], served=False)
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+
+ orders_count = Order.objects.filter(activity__pk=self.kwargs["activity_pk"], served=False).values('dish__main__name').annotate(quantity=Count('id'))
+
+ context["orders"] = {o['dish__main__name']: o['quantity'] for o in orders_count}
+
+ return context
+
+ def get_table(self, **kwargs):
+ table = super().get_table(**kwargs)
+
+ hide = ["ordered_at", "serve", "delete"]
+ for field in hide:
+ table.columns.hide(field)
+
+ return table
+
+
+class RecipeCreateView(ProtectQuerysetMixin, ProtectedCreateView):
+ """
+ Create a recipe
+ """
+ model = Recipe
+ form_class = RecipeForm
+ extra_context = {"title": _("Create a recipe")}
+
+ def get_sample_object(self):
+ return Recipe(name='Sample recipe')
+
+ @transaction.atomic
+ def form_valid(self, form):
+ formset = RecipeIngredientsFormSet(self.request.POST)
+ if formset.is_valid():
+ ingredients = [f.cleaned_data['name'] for f in formset if f.cleaned_data.get('name')]
+ self.object = form.save(commit=False)
+ self.object.ingredients = ingredients
+ self.object.save()
+ return super().form_valid(form)
+ else:
+ return self.form_invalid(form)
+
+ def get_context_data(self, *args, **kwargs):
+ context = super().get_context_data(*args, **kwargs)
+ context['form'] = RecipeIngredientsForm()
+ context['recipe_form'] = self.get_form()
+ if self.request.POST:
+ context['formset'] = RecipeIngredientsFormSet(self.request.POST,)
+ else:
+ context['formset'] = RecipeIngredientsFormSet()
+ return context
+
+ def get_success_url(self):
+ return reverse_lazy('food:recipe_list')
+
+
+class RecipeListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
+ """
+ List all recipes
+ """
+ model = Recipe
+ table_class = RecipeTable
+ extra_context = {"title": _('All recipes')}
+
+ def get_context_data(self, *args, **kwargs):
+ context = super().get_context_data(*args, **kwargs)
+
+ context['can_add_recipe'] = PermissionBackend.check_perm(self.request, 'food.recipe_add')
+
+ return context
+
+
+class RecipeDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
+ """
+ List all recipes
+ """
+ model = Recipe
+ extra_context = {"title": _('Details of:')}
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["ingredients"] = self.object.ingredients
+ context["update"] = PermissionBackend.check_perm(self.request, "food.change_recipe")
+
+ return context
+
+
+class RecipeUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
+ """
+ Create a recipe
+ """
+ model = Recipe
+ form_class = RecipeForm
+ extra_context = {"title": _("Create a recipe")}
+
+ def get_sample_object(self):
+ return Recipe(name='Sample recipe')
+
+ @transaction.atomic
+ def form_valid(self, form):
+ formset = RecipeIngredientsFormSet(self.request.POST)
+ if formset.is_valid():
+ ingredients = [f.cleaned_data['name'] for f in formset if f.cleaned_data.get('name')]
+ self.object = form.save(commit=False)
+ self.object.ingredients = ingredients
+ self.object.save()
+ return super().form_valid(form)
+ else:
+ return self.form_invalid(form)
+
+ def get_context_data(self, *args, **kwargs):
+ context = super().get_context_data(*args, **kwargs)
+ context['form'] = RecipeIngredientsForm()
+ context['recipe_form'] = self.get_form()
+ if self.request.POST:
+ formset = RecipeIngredientsFormSet(self.request.POST,)
+ else:
+ formset = RecipeIngredientsFormSet()
+ ingredients = self.object.ingredients
+ context["ingredients_count"] = len(ingredients)
+ formset.extra += len(ingredients)
+ context["formset"] = formset
+ context["ingredients"] = []
+ for ingredient in ingredients:
+ context["ingredients"].append({"name": ingredient})
+ return context
+
+ def get_success_url(self):
+ return reverse_lazy('food:recipe_detail', kwargs={"pk": self.object.pk})
+
+
+class UseRecipeView(LoginRequiredMixin, UpdateView):
+ """
+ Add ingredients to a TransformedFood using a Recipe
+ """
+ model = TransformedFood
+ fields = ('ingredients',)
+ template_name = 'food/use_recipe_form.html'
+ extra_context = {"title": _("Use a recipe for:")}
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+
+ context["form"] = UseRecipeForm()
+ return context
+
+ def form_valid(self, form):
+ old_ingredients = list(self.object.ingredients.all()).copy()
+ old_allergens = list(self.object.allergens.all()).copy()
+ old_traces = list(self.object.traces.all()).copy()
+ if "ingredients" in form.data:
+ ingredients_pk = form.data.getlist("ingredients")
+ ingredients = Food.objects.all().filter(pk__in=ingredients_pk)
+ for ingredient in ingredients:
+ self.object.ingredients.add(ingredient)
+
+ # We recalculate new expiry date and allergens
+ self.object.expiry_date = self.object.creation_date + self.object.shelf_life
+ self.object.allergens.clear()
+ self.object.traces.clear()
+
+ for ingredient in self.object.ingredients.iterator():
+ if not (ingredient.polymorphic_ctype.model == 'basicfood' and ingredient.date_type == 'DDM'):
+ self.object.expiry_date = min(self.object.expiry_date, ingredient.expiry_date)
+ self.object.allergens.set(self.object.allergens.union(ingredient.allergens.all()))
+ self.object.traces.set(self.object.traces.union(ingredient.traces.all()))
+
+ self.object.save(old_ingredients=old_ingredients, old_allergens=old_allergens, old_traces=old_traces)
+ return HttpResponseRedirect(self.get_success_url())
+
+ def get_success_url(self):
+ return reverse_lazy('food:transformedfood_view', kwargs={"pk": self.object.pk})
+
+
+@require_GET
+def get_ingredients_for_recipe(request):
+ recipe_id = request.GET.get('recipe_id')
+ if not recipe_id:
+ return JsonResponse({'error': 'Missing recipe_id'}, status=400)
+
+ try:
+ recipe = Recipe.objects.get(pk=recipe_id)
+ except Recipe.DoesNotExist:
+ return JsonResponse({'error': 'Recipe not found'}, status=404)
+
+ # 🔧 Supporte les deux cas : ManyToMany ou simple liste
+ ingredients_field = recipe.ingredients
+
+ if hasattr(ingredients_field, "values_list"):
+ # Cas ManyToManyField
+ ingredient_names = list(ingredients_field.values_list('name', flat=True))
+ elif isinstance(ingredients_field, (list, tuple)):
+ # Cas liste directe
+ ingredient_names = ingredients_field
+ else:
+ return JsonResponse({'error': 'Unsupported ingredients type'}, status=500)
+
+ # Union des Foods dont le nom commence par un nom d’ingrédient
+ query = Q()
+ for name in ingredient_names:
+ valid_regex = is_regex(name)
+ suffix = '__iregex' if valid_regex else '__istartswith'
+ prefix = '.*' if valid_regex else ''
+ query |= Q(**{f'name{suffix}': prefix + name}, end_of_life='')
+ qs = Food.objects.filter(query).distinct()
+ qs = qs.filter(PermissionBackend.filter_queryset(request, Food, 'view'))
+
+ data = [{'id': f.id, 'name': f.name, 'qr_code_numbers': ", ".join(str(q.qr_code_number) for q in f.QR_code.all())} for f in qs]
+ return JsonResponse({'ingredients': data})
diff --git a/apps/permission/fixtures/initial.json b/apps/permission/fixtures/initial.json
index f6d90b31..03bcfb9d 100644
--- a/apps/permission/fixtures/initial.json
+++ b/apps/permission/fixtures/initial.json
@@ -4734,6 +4734,201 @@
"description": "Voir l'adresse mail des membres de son club"
}
},
+ {
+ "model": "permission.permission",
+ "pk": 331,
+ "fields": {
+ "model": [
+ "food",
+ "dish"
+ ],
+ "query": "{\"activity__organizer\": [\"club\"]}",
+ "type": "create",
+ "mask": 2,
+ "permanent": false,
+ "description": "Créer un plat vendu par son club"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 332,
+ "fields": {
+ "model": [
+ "food",
+ "dish"
+ ],
+ "query": "{\"activity__organizer\": [\"club\"]}",
+ "type": "change",
+ "mask": 2,
+ "permanent": false,
+ "description": "Modifier un plat vendu par son club"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 333,
+ "fields": {
+ "model": [
+ "food",
+ "dish"
+ ],
+ "query": "{\"activity__organizer\": [\"club\"]}",
+ "type": "view",
+ "mask": 2,
+ "permanent": false,
+ "description": "Voir les plats vendus par son club"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 334,
+ "fields": {
+ "model": [
+ "food",
+ "dish"
+ ],
+ "query": "[\"AND\", {\"activity__open\": true}, {\"available\": true}]",
+ "type": "view",
+ "mask": 1,
+ "permanent": false,
+ "description": "Voir les plats disponibles"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 335,
+ "fields": {
+ "model": [
+ "food",
+ "supplement"
+ ],
+ "query": "{\"dish__main__owner\": [\"club\"]}",
+ "type": "create",
+ "mask": 2,
+ "permanent": false,
+ "description": "Ajouter un supplément à un plat de son club"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 336,
+ "fields": {
+ "model": [
+ "food",
+ "supplement"
+ ],
+ "query": "{\"dish__main__owner\": [\"club\"]}",
+ "type": "change",
+ "mask": 2,
+ "permanent": false,
+ "description": "Modifier un supplément d'un plat de son club"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 337,
+ "fields": {
+ "model": [
+ "food",
+ "supplement"
+ ],
+ "query": "{\"dish__main__owner\": [\"club\"]}",
+ "type": "view",
+ "mask": 2,
+ "permanent": false,
+ "description": "Voir les suppléments des plats de son club"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 337,
+ "fields": {
+ "model": [
+ "food",
+ "supplement"
+ ],
+ "query": "[\"AND\", {\"dish__activity__open\": true}, {\"dish__available\": true}]",
+ "type": "view",
+ "mask": 1,
+ "permanent": false,
+ "description": "Voir les suppléments des plats disponibles"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 338,
+ "fields": {
+ "model": [
+ "food",
+ "supplement"
+ ],
+ "query": "{\"dish__main__owner\": [\"club\"]}",
+ "type": "delete",
+ "mask": 2,
+ "permanent": false,
+ "description": "Supprimer un supplément d'un plat de son club"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 339,
+ "fields": {
+ "model": [
+ "food",
+ "order"
+ ],
+ "query": "[\"AND\", {\"dish__activity__open\": true, \"dish__available\": true}, {\"user\": [\"user\"]}]",
+ "type": "create",
+ "mask": 1,
+ "permanent": false,
+ "description": "Commander un plat"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 340,
+ "fields": {
+ "model": [
+ "food",
+ "order"
+ ],
+ "query": "[\"AND\", {\"dish__activity__open\": true}, {\"user\": [\"user\"]}]",
+ "type": "view",
+ "mask": 1,
+ "permanent": false,
+ "description": "Voir ses commandes pour les activités ouvertes"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 341,
+ "fields": {
+ "model": [
+ "food",
+ "order"
+ ],
+ "query": "{\"activity__open\": true, \"activity__organizer\": [\"club\"]}",
+ "type": "view",
+ "mask": 2,
+ "permanent": false,
+ "description": "Voir toutes les commandes pour les activités ouvertes de son club"
+ }
+ },
+ {
+ "model": "permission.permission",
+ "pk": 342,
+ "fields": {
+ "model": [
+ "food",
+ "order"
+ ],
+ "query": "{\"activity__open\": true, \"activity__organizer\": [\"club\"]}",
+ "type": "change",
+ "mask": 2,
+ "permanent": false,
+ "description": "Modifier un commande non servie d'une activité de son club"
+ }
+ },
{
"model": "permission.role",
"pk": 1,
diff --git a/note_kfet/static/js/autocomplete_model.js b/note_kfet/static/js/autocomplete_model.js
index a8b2461c..1b129941 100644
--- a/note_kfet/static/js/autocomplete_model.js
+++ b/note_kfet/static/js/autocomplete_model.js
@@ -13,11 +13,14 @@ $(document).ready(function () {
target.addClass('is-invalid')
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) {
let html = ''
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 += '
'