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 %} +
+
+

{% trans "Delete dish" %}

+
+
+
+ {% 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 %} +
+

+ {{ title }} {{ food.name }} +

+
+ + {% if update %} + + {% trans "Update" %} + + {% endif %} + + {% trans "Return to dish list" %} + + {% if delete %} + + {% trans "Delete" %} + + {% endif %} +
+
+{% 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 %} +
+

+ {{ title }} +

+
+ {% csrf_token %} +
+ {% crispy form %} +
+

+ {% trans "Add supplements (optional)" %} +

+ {{ formset.management_form }} + + {% for form in formset %} + {% if forloop.first %} + + + + + + + + {% endif %} + + + + {# These fields are hidden but handled by the formset to link the id and the invoice id #} + {{ form.dish }} + {{ form.id }} + + {% endfor %} + +
{{ form.food.label }}*{{ form.price.label }}*
{{ form.food }}{{ form.price }}
+ + {# Display buttons to add and remove supplements #} +
+
+ + +
+ +
+
+
+ +{# 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 %} +
+

+ {{ title }} {{activity.name}} +

+ {% 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

{% trans "Meal served" %}

- {% 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 %} +
+ +

+ {{ food }}
+

+

+ {{ quantity }}

+
+ {% endfor %} +
+ + +
+

+ {% trans "Special orders" %} +

+ {% 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 %} +
+
+

{% trans "Delete order" %}

+
+
+
+ {% 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 %} +
+

+ {{ title }} +

+
+
+ {% csrf_token %} + {{ form | crispy }} + +
+
+
+{% 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 %} +
+

+ {{ title }} +

+ {% trans "View served orders" %} + {% for table in tables %} +
+

+ {% trans "Orders of " %} {{ table.prefix }} +

+ {% if table.data %} + {% render_table table %} + {% endif %} +
+ {% endfor %} +
+{% 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 %} +
+

+ {{ title }} {{ recipe.name }} +

+
+ + {% if update %} + + {% trans "Update" %} + + {% endif %} + + {% trans "Return to recipe list" %} + +
+
+{% 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 %} +
+

+ {{ title }} +

+
+ {% csrf_token %} +
+ {% crispy recipe_form %} + {# Keep all form elements in the same card-body for proper structure #} + {{ formset.management_form }} +

{% trans "Add ingredients" %}

+ + {% for form in formset %} + {% if forloop.first %} + + + + + + + {% endif %} + + + + {% endfor %} + +
{{ form.name.label }}
+ {# Force prefix on the form fields #} + {{ form.name.as_widget }} +
+
+ {# Display buttons to add and remove ingredients #} +
+
+ + +
+ +
+
+
+ +{# 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 %} +
+

+ {{ title }} +

+ {% 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 %} +
+

+ {{ title }} {{activity.name}} +

+ {% trans "View unserved orders" %} + {% render_table table %} +
+{% 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 %} +
+

+ {{ title }} {{ supplement.name }} +

+
+
+
+{% 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 %} +
+

+ {{ title }} {{ object.name }} +

+
+
+ {% csrf_token %} + {{ form | crispy }} + +
+
+
+{% 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 += '
'