From 0db794c70eba6668fb2ceb2f2dca0d12271cd6c4 Mon Sep 17 00:00:00 2001 From: Ehouarn Date: Thu, 6 Nov 2025 23:49:16 +0100 Subject: [PATCH] Add model Recipe --- apps/food/forms.py | 56 +++++- apps/food/migrations/0006_recipe.py | 29 +++ apps/food/models.py | 46 +++++ apps/food/tables.py | 20 +- apps/food/templates/food/food_detail.html | 3 + apps/food/templates/food/food_list.html | 10 + .../templates/food/manage_ingredients.html | 2 +- apps/food/templates/food/recipe_detail.html | 31 +++ apps/food/templates/food/recipe_form.html | 122 ++++++++++++ apps/food/templates/food/recipe_list.html | 32 ++++ apps/food/templates/food/use_recipe_form.html | 80 ++++++++ apps/food/urls.py | 24 ++- apps/food/views.py | 180 +++++++++++++++++- 13 files changed, 613 insertions(+), 22 deletions(-) create mode 100644 apps/food/migrations/0006_recipe.py create mode 100644 apps/food/templates/food/recipe_detail.html create mode 100644 apps/food/templates/food/recipe_form.html create mode 100644 apps/food/templates/food/recipe_list.html create mode 100644 apps/food/templates/food/use_recipe_form.html diff --git a/apps/food/forms.py b/apps/food/forms.py index 0a7eac9e..2fb60291 100644 --- a/apps/food/forms.py +++ b/apps/food/forms.py @@ -6,14 +6,15 @@ from random import shuffle from bootstrap_datepicker_plus.widgets import DateTimePickerInput from crispy_forms.helper import FormHelper from django import forms -from django.forms.widgets import NumberInput +from django.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, AmountInput from note_kfet.middlewares import get_current_request from permission.backends import PermissionBackend -from .models import Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order +from .models import Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order, Recipe class QRCodeForms(forms.ModelForm): @@ -134,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") @@ -142,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 @@ -158,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') @@ -248,3 +252,43 @@ class OrderForm(forms.ModelForm): 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',) + + +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/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 84a52ff7..6a044a89 100644 --- a/apps/food/models.py +++ b/apps/food/models.py @@ -1,6 +1,7 @@ # 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 @@ -513,3 +514,48 @@ class FoodTransaction(Transaction): 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/tables.py b/apps/food/tables.py index c2270dfe..758ac0a3 100644 --- a/apps/food/tables.py +++ b/apps/food/tables.py @@ -7,7 +7,7 @@ from note_kfet.middlewares import get_current_request from note.templatetags.pretty_money import pretty_money from permission.backends import PermissionBackend -from .models import Food, Dish, Order +from .models import Food, Dish, Order, Recipe class FoodTable(tables.Table): @@ -115,3 +115,21 @@ class OrderTable(tables.Table): '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/food_detail.html b/apps/food/templates/food/food_detail.html index e82cc907..024dee1e 100644 --- a/apps/food/templates/food/food_detail.html +++ b/apps/food/templates/food/food_detail.html @@ -47,6 +47,9 @@ SPDX-License-Identifier: GPL-3.0-or-later {% trans "Manage ingredients" %} + + {% trans "Use a recipe" %} + {% 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 b77474bc..54c8fb1c 100644 --- a/apps/food/templates/food/food_list.html +++ b/apps/food/templates/food/food_list.html @@ -70,6 +70,16 @@ SPDX-License-Identifier: GPL-3.0-or-later {% trans "New meal" %} {% endif %} + {% if can_view_recipes %} + + {% trans "View recipes" %} + + {% endif %} + {% if can_add_recipe %} + + {% trans "New recipe" %} + + {% endif %} {% for activity in open_activities %} {% trans "View" %} {{ activity.name }} diff --git a/apps/food/templates/food/manage_ingredients.html b/apps/food/templates/food/manage_ingredients.html index 54b03dd1..98888a07 100644 --- a/apps/food/templates/food/manage_ingredients.html +++ b/apps/food/templates/food/manage_ingredients.html @@ -90,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/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 }} +

+
+
+{% 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/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/urls.py b/apps/food/urls.py index 92a21e9f..00bdc3e3 100644 --- a/apps/food/urls.py +++ b/apps/food/urls.py @@ -9,15 +9,15 @@ 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'), @@ -29,4 +29,10 @@ urlpatterns = [ 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 b56e0858..b2596294 100644 --- a/apps/food/views.py +++ b/apps/food/views.py @@ -9,7 +9,8 @@ from django_tables2.views import SingleTableView, MultiTableMixin from django.core.exceptions import PermissionDenied from django.db import transaction from django.db.models import Q, Count -from django.http import HttpResponseRedirect, Http404 +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 @@ -22,12 +23,13 @@ from activity.models import Activity from permission.backends import PermissionBackend from permission.views import ProtectQuerysetMixin, ProtectedCreateView, LoginRequiredMixin -from .models import Food, BasicFood, TransformedFood, QRCode, Order, Dish, Supplement +from .models import Food, BasicFood, TransformedFood, QRCode, Order, Dish, Supplement, Recipe from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \ ManageIngredientsForm, ManageIngredientsFormSet, AddIngredientForms, \ BasicFoodUpdateForms, TransformedFoodUpdateForms, \ - DishForm, SupplementFormSet, SupplementFormSetHelper, OrderForm -from .tables import FoodTable, DishTable, OrderTable + DishForm, SupplementFormSet, SupplementFormSetHelper, OrderForm, RecipeForm, \ + RecipeIngredientsForm, RecipeIngredientsFormSet, UseRecipeForm +from .tables import FoodTable, DishTable, OrderTable, RecipeTable from .utils import pretty_duration @@ -118,6 +120,10 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li 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 @@ -329,7 +335,7 @@ class ManageIngredientsView(LoginRequiredMixin, UpdateView): 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.tracese.set(self.object.traces.union(ingredient.traces.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()) @@ -355,6 +361,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): @@ -874,3 +881,166 @@ class KitchenView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): 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_create') + + +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 = '__all__' + template_name = 'food/use_recipe_form.html' + extra_context = {"title": _("Use a recipe:")} + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context["form"] = UseRecipeForm() + + return context + + +@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: + query |= Q(name__istartswith=name) + + qs = Food.objects.filter(query).distinct() + + 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})