1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-11-14 10:41:27 +01:00

Compare commits

...

3 Commits

Author SHA1 Message Date
Ehouarn
0db794c70e Add model Recipe 2025-11-07 00:06:39 +01:00
Ehouarn
48b1ef9ec8 Add field 'traces' for model Food 2025-11-02 18:43:33 +01:00
Ehouarn
4f016fed38 'Add all identical food' also for QRcode input 2025-11-02 18:42:14 +01:00
14 changed files with 694 additions and 46 deletions

View File

@@ -6,14 +6,15 @@ from random import shuffle
from bootstrap_datepicker_plus.widgets import DateTimePickerInput from bootstrap_datepicker_plus.widgets import DateTimePickerInput
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from django import forms 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 django.utils.translation import gettext_lazy as _
from member.models import Club from member.models import Club
from note_kfet.inputs import Autocomplete, AmountInput from note_kfet.inputs import Autocomplete, AmountInput
from note_kfet.middlewares import get_current_request from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from .models import Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order from .models import Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order, Recipe
class QRCodeForms(forms.ModelForm): class QRCodeForms(forms.ModelForm):
@@ -55,7 +56,7 @@ class BasicFoodForms(forms.ModelForm):
class Meta: class Meta:
model = BasicFood model = BasicFood
fields = ('name', 'owner', 'date_type', 'expiry_date', 'allergens', 'order',) fields = ('name', 'owner', 'date_type', 'expiry_date', 'allergens', 'traces', 'order',)
widgets = { widgets = {
"owner": Autocomplete( "owner": Autocomplete(
model=Club, model=Club,
@@ -98,7 +99,7 @@ class BasicFoodUpdateForms(forms.ModelForm):
""" """
class Meta: class Meta:
model = BasicFood 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 = { widgets = {
"owner": Autocomplete( "owner": Autocomplete(
model=Club, model=Club,
@@ -134,7 +135,7 @@ class AddIngredientForms(forms.ModelForm):
Form for add an ingredient Form for add an ingredient
""" """
fully_used = forms.BooleanField() fully_used = forms.BooleanField()
fully_used.initial = True fully_used.initial = False
fully_used.required = False fully_used.required = False
fully_used.label = _("Fully used") fully_used.label = _("Fully used")
@@ -142,11 +143,14 @@ class AddIngredientForms(forms.ModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# TODO find a better way to get pk (be not url scheme dependant) # TODO find a better way to get pk (be not url scheme dependant)
pk = get_current_request().path.split('/')[-1] 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", polymorphic_ctype__model="transformedfood",
is_ready=False, is_ready=False,
end_of_life='', 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: class Meta:
model = TransformedFood model = TransformedFood
@@ -158,7 +162,7 @@ class ManageIngredientsForm(forms.Form):
Form to manage ingredient Form to manage ingredient
""" """
fully_used = forms.BooleanField() fully_used = forms.BooleanField()
fully_used.initial = True fully_used.initial = False
fully_used.required = True fully_used.required = True
fully_used.label = _('Fully used') fully_used.label = _('Fully used')
@@ -248,3 +252,43 @@ class OrderForm(forms.ModelForm):
class Meta: class Meta:
model = Order model = Order
exclude = ("activity", "number", "ordered_at", "served", "served_at") 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,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'),
),
]

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 # Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import json
from datetime import timedelta from datetime import timedelta
from django.db import models, transaction from django.db import models, transaction
@@ -53,6 +54,13 @@ class Food(PolymorphicModel):
verbose_name=_('allergens'), verbose_name=_('allergens'),
) )
traces = models.ManyToManyField(
Allergen,
blank=True,
verbose_name=_('traces'),
related_name='food_with_traces'
)
expiry_date = models.DateTimeField( expiry_date = models.DateTimeField(
verbose_name=_('expiry date'), verbose_name=_('expiry date'),
null=False, null=False,
@@ -91,6 +99,19 @@ class Food(PolymorphicModel):
if old_allergens != list(parent.allergens.all()): if old_allergens != list(parent.allergens.all()):
parent.save(old_allergens=old_allergens) 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): def update_expiry_date(self):
# update parents # update parents
for parent in self.transformed_ingredient_inv.iterator(): for parent in self.transformed_ingredient_inv.iterator():
@@ -142,6 +163,10 @@ class BasicFood(Food):
and list(self.allergens.all()) != kwargs['old_allergens']): and list(self.allergens.all()) != kwargs['old_allergens']):
self.update_allergens() self.update_allergens()
if ('old_traces' in kwargs
and list(self.traces.all()) != kwargs['old_traces']):
self.update_traces()
# Expiry date # Expiry date
if ((self.expiry_date != old_food.expiry_date if ((self.expiry_date != old_food.expiry_date
and self.date_type == 'DLC') and self.date_type == 'DLC')
@@ -214,7 +239,7 @@ class TransformedFood(Food):
created = self.pk is None created = self.pk is None
if not created: if not created:
# Check if important fields are updated # 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) old_food = Food.objects.select_for_update().get(pk=self.pk)
if not hasattr(self, "_force_save"): if not hasattr(self, "_force_save"):
# Allergens # Allergens
@@ -224,6 +249,10 @@ class TransformedFood(Food):
and list(self.allergens.all()) != kwargs['old_allergens']): and list(self.allergens.all()) != kwargs['old_allergens']):
update['allergens'] = True update['allergens'] = True
if ('old_traces' in kwargs
and list(self.traces.all()) != kwargs['old_traces']):
update['traces'] = True
# Expiry date # Expiry date
update['expiry_date'] = (self.shelf_life != old_food.shelf_life update['expiry_date'] = (self.shelf_life != old_food.shelf_life
or self.creation_date != old_food.creation_date) or self.creation_date != old_food.creation_date)
@@ -234,6 +263,7 @@ class TransformedFood(Food):
if ('old_ingredients' in kwargs if ('old_ingredients' in kwargs
and list(self.ingredients.all()) != list(kwargs['old_ingredients'])): and list(self.ingredients.all()) != list(kwargs['old_ingredients'])):
update['allergens'] = True update['allergens'] = True
update['traces'] = True
update['expiry_date'] = True update['expiry_date'] = True
# it's preferable to keep a queryset but we allow list too # it's preferable to keep a queryset but we allow list too
@@ -243,6 +273,8 @@ class TransformedFood(Food):
self.check_cycle(self.ingredients.all().difference(kwargs['old_ingredients']), self, []) self.check_cycle(self.ingredients.all().difference(kwargs['old_ingredients']), self, [])
if update['allergens']: if update['allergens']:
self.update_allergens() self.update_allergens()
if update['traces']:
self.update_traces()
if update['expiry_date']: if update['expiry_date']:
self.update_expiry_date() self.update_expiry_date()
@@ -254,6 +286,7 @@ class TransformedFood(Food):
for child in self.ingredients.iterator(): for child in self.ingredients.iterator():
self.allergens.set(self.allergens.union(child.allergens.all())) 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'): if not (child.polymorphic_ctype.model == 'basicfood' and child.date_type == 'DDM'):
self.expiry_date = min(self.expiry_date, child.expiry_date) self.expiry_date = min(self.expiry_date, child.expiry_date)
return super().save(force_insert=False, force_update=force_update, using=using, update_fields=update_fields) return super().save(force_insert=False, force_update=force_update, using=using, update_fields=update_fields)
@@ -481,3 +514,48 @@ class FoodTransaction(Transaction):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.valid = self.order.served self.valid = self.order.served
super().save(*args, **kwargs) 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 note.templatetags.pretty_money import pretty_money
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from .models import Food, Dish, Order from .models import Food, Dish, Order, Recipe
class FoodTable(tables.Table): class FoodTable(tables.Table):
@@ -32,7 +32,7 @@ class FoodTable(tables.Table):
class Meta: class Meta:
model = Food model = Food
template_name = 'django_tables2/bootstrap4.html' 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 = { row_attrs = {
'class': 'table-row', 'class': 'table-row',
'data-href': lambda record: 'detail/' + str(record.pk), 'data-href': lambda record: 'detail/' + str(record.pk),
@@ -115,3 +115,21 @@ class OrderTable(tables.Table):
'class': 'table-row', 'class': 'table-row',
'style': 'cursor:pointer', '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 %}"> <a class="btn btn-sm btn-secondary" href="{% url "food:manage_ingredients" pk=food.pk %}">
{% trans "Manage ingredients" %} {% trans "Manage ingredients" %}
</a> </a>
<a class="btn btn-sm btn-secondary" href="{% url "food:recipe_use" pk=food.pk %}">
{% trans "Use a recipe" %}
</a>
{% endif %} {% endif %}
<a class="btn btn-sm btn-primary" href="{% url "food:food_list" %}"> <a class="btn btn-sm btn-primary" href="{% url "food:food_list" %}">
{% trans "Return to the 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" %} {% trans "New meal" %}
</a> </a>
{% endif %} {% 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 %} {% for activity in open_activities %}
<a class="btn btn-sm btn-secondary" href="{% url 'food:dish_list' activity_pk=activity.pk %}"> <a class="btn btn-sm btn-secondary" href="{% url 'food:dish_list' activity_pk=activity.pk %}">
{% trans "View" %} {{ activity.name }} {% trans "View" %} {{ activity.name }}

View File

@@ -90,7 +90,7 @@ function delete_form_data (form_id) {
document.getElementById(prefix + "name").value = ""; document.getElementById(prefix + "name").value = "";
document.getElementById(prefix + "qrcode_pk").value = ""; document.getElementById(prefix + "qrcode_pk").value = "";
document.getElementById(prefix + "qrcode").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; 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 = [ urlpatterns = [
path('', views.FoodListView.as_view(), name='food_list'), path('', views.FoodListView.as_view(), name='food_list'),
path('<int:slug>', views.QRCodeCreateView.as_view(), name='qrcode_create'), path('<int:slug>/', views.QRCodeCreateView.as_view(), name='qrcode_create'),
path('<int:slug>/add/basic', views.BasicFoodCreateView.as_view(), name='basicfood_create'), path('<int:slug>/add/basic/', views.BasicFoodCreateView.as_view(), name='basicfood_create'),
path('add/transformed', views.TransformedFoodCreateView.as_view(), name='transformedfood_create'), path('add/transformed/', views.TransformedFoodCreateView.as_view(), name='transformedfood_create'),
path('update/<int:pk>', views.FoodUpdateView.as_view(), name='food_update'), path('update/<int:pk>/', views.FoodUpdateView.as_view(), name='food_update'),
path('update/ingredients/<int:pk>', views.ManageIngredientsView.as_view(), name='manage_ingredients'), 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/<int:pk>/', views.FoodDetailView.as_view(), name='food_view'),
path('detail/basic/<int:pk>', views.BasicFoodDetailView.as_view(), name='basicfood_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('detail/transformed/<int:pk>/', views.TransformedFoodDetailView.as_view(), name='transformedfood_view'),
path('add/ingredient/<int:pk>', views.AddIngredientView.as_view(), name='add_ingredient'), path('add/ingredient/<int:pk>/', views.AddIngredientView.as_view(), name='add_ingredient'),
path('redirect/', views.QRCodeRedirectView.as_view(), name='redirect_view'), path('redirect/', views.QRCodeRedirectView.as_view(), name='redirect_view'),
# TODO not always store activity_pk in url # TODO not always store activity_pk in url
path('activity/<int:activity_pk>/dishes/add/', views.DishCreateView.as_view(), name='dish_create'), path('activity/<int:activity_pk>/dishes/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/', 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>/orders/served', views.ServedOrderListView.as_view(), name='served_order_list'),
path('activity/<int:activity_pk>/kitchen/', views.KitchenView.as_view(), name='kitchen'), 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.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
from django.db.models import Q, Count 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 import DetailView, UpdateView, CreateView
from django.views.generic.list import ListView from django.views.generic.list import ListView
from django.views.generic.base import RedirectView from django.views.generic.base import RedirectView
@@ -22,12 +23,13 @@ from activity.models import Activity
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin, ProtectedCreateView, LoginRequiredMixin from permission.views import ProtectQuerysetMixin, ProtectedCreateView, LoginRequiredMixin
from .models import Food, BasicFood, TransformedFood, QRCode, Order, Dish, Supplement from .models import Food, BasicFood, TransformedFood, QRCode, Order, Dish, Supplement, Recipe
from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \ from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \
ManageIngredientsForm, ManageIngredientsFormSet, AddIngredientForms, \ ManageIngredientsForm, ManageIngredientsFormSet, AddIngredientForms, \
BasicFoodUpdateForms, TransformedFoodUpdateForms, \ BasicFoodUpdateForms, TransformedFoodUpdateForms, \
DishForm, SupplementFormSet, SupplementFormSetHelper, OrderForm DishForm, SupplementFormSet, SupplementFormSetHelper, OrderForm, RecipeForm, \
from .tables import FoodTable, DishTable, OrderTable RecipeIngredientsForm, RecipeIngredientsFormSet, UseRecipeForm
from .tables import FoodTable, DishTable, OrderTable, RecipeTable
from .utils import pretty_duration 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_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) context["open_activities"] = Activity.objects.filter(activity_type__name="Perm bouffe", open=True)
return context return context
@@ -235,6 +241,8 @@ class BasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
for field in context['form'].fields: for field in context['form'].fields:
if field == 'allergens': if field == 'allergens':
context['form'].fields[field].initial = getattr(food, field).all() context['form'].fields[field].initial = getattr(food, field).all()
elif field == 'traces':
context['form'].fields[field].initial = getattr(food, field).all()
else: else:
context['form'].fields[field].initial = getattr(food, field) context['form'].fields[field].initial = getattr(food, field)
@@ -294,42 +302,42 @@ class ManageIngredientsView(LoginRequiredMixin, UpdateView):
def form_valid(self, form): def form_valid(self, form):
old_ingredients = list(self.object.ingredients.all()).copy() old_ingredients = list(self.object.ingredients.all()).copy()
old_allergens = list(self.object.allergens.all()).copy() old_allergens = list(self.object.allergens.all()).copy()
old_traces = list(self.object.traces.all()).copy()
self.object.ingredients.clear() self.object.ingredients.clear()
for i in range(self.object.ingredients.all().count() + 1 + MAX_FORMS): for i in range(self.object.ingredients.all().count() + 1 + MAX_FORMS):
prefix = 'form-' + str(i) + '-' prefix = 'form-' + str(i) + '-'
ingredient = None
if form.data[prefix + 'qrcode'] not in ['0', '']: if form.data[prefix + 'qrcode'] not in ['0', '']:
ingredient = QRCode.objects.get(pk=form.data[prefix + 'qrcode']).food_container 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) self.object.ingredients.add(ingredient)
if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on': if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on':
ingredient.end_of_life = _('Fully used in {meal}'.format( ingredient.end_of_life = _('Fully used in {meal}'.format(
meal=self.object.name)) meal=self.object.name))
ingredient.save() ingredient.save()
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='')
for ingredient in ingredients:
self.object.ingredients.add(ingredient)
if form.data.get(prefix + 'fully_used') == 'on':
ingredient.end_of_life = _('Fully used in {meal}'.format(meal=self.object.name))
ingredient.save()
else:
self.object.ingredients.add(ingredient)
if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on':
ingredient.end_of_life = _('Fully used in {meal}'.format(
meal=self.object.name))
ingredient.save()
# We recalculate new expiry date and allergens # We recalculate new expiry date and allergens
self.object.expiry_date = self.object.creation_date + self.object.shelf_life self.object.expiry_date = self.object.creation_date + self.object.shelf_life
self.object.allergens.clear() self.object.allergens.clear()
self.object.traces.clear()
for ingredient in self.object.ingredients.iterator(): for ingredient in self.object.ingredients.iterator():
if not (ingredient.polymorphic_ctype.model == 'basicfood' and ingredient.date_type == 'DDM'): 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.expiry_date = min(self.object.expiry_date, ingredient.expiry_date)
self.object.allergens.set(self.object.allergens.union(ingredient.allergens.all())) 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()) return HttpResponseRedirect(self.get_success_url())
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
@@ -353,6 +361,7 @@ class ManageIngredientsView(LoginRequiredMixin, UpdateView):
'qr_number': '' if qr.count() == 0 else qr[0].qr_code_number, 'qr_number': '' if qr.count() == 0 else qr[0].qr_code_number,
'fully_used': 'true' if ingredient.end_of_life else '', 'fully_used': 'true' if ingredient.end_of_life else '',
}) })
return context return context
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
@@ -381,13 +390,15 @@ class AddIngredientView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
for meal in meals: for meal in meals:
old_ingredients = list(meal.ingredients.all()).copy() old_ingredients = list(meal.ingredients.all()).copy()
old_allergens = list(meal.allergens.all()).copy() old_allergens = list(meal.allergens.all()).copy()
old_traces = list(meal.traces.all()).copy()
meal.ingredients.add(self.object.pk) meal.ingredients.add(self.object.pk)
# update allergen and expiry date if necessary # update allergen and expiry date if necessary
if not (self.object.polymorphic_ctype.model == 'basicfood' if not (self.object.polymorphic_ctype.model == 'basicfood'
and self.object.date_type == 'DDM'): and self.object.date_type == 'DDM'):
meal.expiry_date = min(meal.expiry_date, self.object.expiry_date) meal.expiry_date = min(meal.expiry_date, self.object.expiry_date)
meal.allergens.set(meal.allergens.union(self.object.allergens.all())) 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 'fully_used' in form.data:
if not self.object.end_of_life: if not self.object.end_of_life:
self.object.end_of_life = _(f'Food fully used in : {meal.name}') self.object.end_of_life = _(f'Food fully used in : {meal.name}')
@@ -417,6 +428,7 @@ class FoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
form.instance.creater = self.request.user form.instance.creater = self.request.user
food = Food.objects.get(pk=self.kwargs['pk']) food = Food.objects.get(pk=self.kwargs['pk'])
old_allergens = list(food.allergens.all()).copy() old_allergens = list(food.allergens.all()).copy()
old_traces = list(food.traces.all()).copy()
if food.polymorphic_ctype.model == 'transformedfood': if food.polymorphic_ctype.model == 'transformedfood':
old_ingredients = food.ingredients.all() old_ingredients = food.ingredients.all()
@@ -430,7 +442,7 @@ class FoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
if food.polymorphic_ctype.model == 'transformedfood': if food.polymorphic_ctype.model == 'transformedfood':
form.instance.save(old_ingredients=old_ingredients) form.instance.save(old_ingredients=old_ingredients)
else: else:
form.instance.save(old_allergens=old_allergens) form.instance.save(old_allergens=old_allergens, old_traces=old_traces)
return ans return ans
def get_form_class(self, **kwargs): def get_form_class(self, **kwargs):
@@ -463,7 +475,7 @@ class FoodDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**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]) fields = dict([(field, getattr(self.object, field)) for field in fields])
if fields["is_ready"]: if fields["is_ready"]:
@@ -472,6 +484,8 @@ class FoodDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
fields["is_ready"] = _("No") fields["is_ready"] = _("No")
fields["allergens"] = ", ".join( fields["allergens"] = ", ".join(
allergen.name for allergen in fields["allergens"].all()) allergen.name for allergen in fields["allergens"].all())
fields["traces"] = ", ".join(
trace.name for trace in fields["traces"].all())
context["fields"] = [( context["fields"] = [(
Food._meta.get_field(field).verbose_name.capitalize(), Food._meta.get_field(field).verbose_name.capitalize(),
@@ -848,12 +862,12 @@ class KitchenView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
extra_context = {'title': _('Kitchen')} extra_context = {'title': _('Kitchen')}
def get_queryset(self): def get_queryset(self):
return super().get_queryset().filter(~Q(supplements__isnull=True, request=''), activity__pk=self.kwargs["activity_pk"]) return super().get_queryset().filter(~Q(supplements__isnull=True, request=''), activity__pk=self.kwargs["activity_pk"], served=False)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
orders_count = Order.objects.values('dish__main__name').annotate(quantity=Count('id')) 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} context["orders"] = {o['dish__main__name']: o['quantity'] for o in orders_count}
@@ -867,3 +881,166 @@ class KitchenView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
table.columns.hide(field) table.columns.hide(field)
return table 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})