1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-11-07 07:29:45 +01:00

Add model Recipe

This commit is contained in:
Ehouarn
2025-11-06 23:49:16 +01:00
parent 48b1ef9ec8
commit 0db794c70e
13 changed files with 613 additions and 22 deletions

View File

@@ -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(),
)

View File

@@ -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')},
},
),
]

View File

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

View File

@@ -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',
}

View File

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

View File

@@ -70,6 +70,16 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% trans "New meal" %}
</a>
{% endif %}
{% if can_view_recipes %}
<a class="btn btn-sm btn-secondary" href="{% url 'food:recipe_list' %}">
{% trans "View recipes" %}
</a>
{% endif %}
{% if can_add_recipe %}
<a class="btn btn-sm btn-primary" href="{% url 'food:recipe_create' %}">
{% trans "New recipe" %}
</a>
{% endif %}
{% for activity in open_activities %}
<a class="btn btn-sm btn-secondary" href="{% url 'food:dish_list' activity_pk=activity.pk %}">
{% trans "View" %} {{ activity.name }}

View File

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

View File

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

View File

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

View File

@@ -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 %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{{ title }}
</h3>
{% render_table table %}
<div class="card-footer">
{% if can_add_recipe %}
<a class="btn btn-sm btn-success" href="{% url 'food:recipe_create' %}">{% trans "New recipe" %}</a>
{% endif %}
<a class="btn btn-sm btn-primary" href="{% url "food:food_list" %}">
{% trans "Return to the food list" %}
</a>
</div>
</div>
{% endblock %}
{% block extrajavascript %}
<script type="text/javascript">
$(".table-row").click(function () {
window.document.location = $(this).data("href");
});
</script>
{% endblock %}

View File

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

View File

@@ -9,15 +9,15 @@ app_name = 'food'
urlpatterns = [
path('', views.FoodListView.as_view(), name='food_list'),
path('<int:slug>', views.QRCodeCreateView.as_view(), name='qrcode_create'),
path('<int:slug>/add/basic', views.BasicFoodCreateView.as_view(), name='basicfood_create'),
path('add/transformed', views.TransformedFoodCreateView.as_view(), name='transformedfood_create'),
path('update/<int:pk>', views.FoodUpdateView.as_view(), name='food_update'),
path('update/ingredients/<int:pk>', views.ManageIngredientsView.as_view(), name='manage_ingredients'),
path('detail/<int:pk>', views.FoodDetailView.as_view(), name='food_view'),
path('detail/basic/<int:pk>', views.BasicFoodDetailView.as_view(), name='basicfood_view'),
path('detail/transformed/<int:pk>', views.TransformedFoodDetailView.as_view(), name='transformedfood_view'),
path('add/ingredient/<int:pk>', views.AddIngredientView.as_view(), name='add_ingredient'),
path('<int:slug>/', views.QRCodeCreateView.as_view(), name='qrcode_create'),
path('<int:slug>/add/basic/', views.BasicFoodCreateView.as_view(), name='basicfood_create'),
path('add/transformed/', views.TransformedFoodCreateView.as_view(), name='transformedfood_create'),
path('update/<int:pk>/', views.FoodUpdateView.as_view(), name='food_update'),
path('update/ingredients/<int:pk>/', views.ManageIngredientsView.as_view(), name='manage_ingredients'),
path('detail/<int:pk>/', views.FoodDetailView.as_view(), name='food_view'),
path('detail/basic/<int:pk>/', views.BasicFoodDetailView.as_view(), name='basicfood_view'),
path('detail/transformed/<int:pk>/', views.TransformedFoodDetailView.as_view(), name='transformedfood_view'),
path('add/ingredient/<int:pk>/', views.AddIngredientView.as_view(), name='add_ingredient'),
path('redirect/', views.QRCodeRedirectView.as_view(), name='redirect_view'),
# TODO not always store activity_pk in url
path('activity/<int:activity_pk>/dishes/add/', views.DishCreateView.as_view(), name='dish_create'),
@@ -29,4 +29,10 @@ urlpatterns = [
path('activity/<int:activity_pk>/orders/', views.OrderListView.as_view(), name='order_list'),
path('activity/<int:activity_pk>/orders/served', views.ServedOrderListView.as_view(), name='served_order_list'),
path('activity/<int:activity_pk>/kitchen/', views.KitchenView.as_view(), name='kitchen'),
path('recipe/add/', views.RecipeCreateView.as_view(), name='recipe_create'),
path('recipe/', views.RecipeListView.as_view(), name='recipe_list'),
path('recipe/<int:pk>/', views.RecipeDetailView.as_view(), name='recipe_detail'),
path('recipe/<int:pk>/update/', views.RecipeUpdateView.as_view(), name='recipe_update'),
path('update/ingredients/<int:pk>/recipe/', views.UseRecipeView.as_view(), name='recipe_use'),
path('ajax/get_ingredients/', views.get_ingredients_for_recipe, name='get_ingredients'),
]

View File

@@ -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 dingré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})