1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-05-01 02:51:41 +00:00

Add manage ingredient feature, fix some bug

This commit is contained in:
quark 2025-04-30 11:59:08 +02:00
parent b4f3a158a6
commit ad0a219ed3
10 changed files with 407 additions and 33 deletions

View File

@ -3,7 +3,7 @@
from rest_framework import serializers
from ..models import Allergen, BasicFood, TransformedFood, QRCode
from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode
class AllergenSerializer(serializers.ModelSerializer):
@ -16,6 +16,16 @@ class AllergenSerializer(serializers.ModelSerializer):
fields = '__all__'
class FoodSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Food.
The djangorestframework plugin will analyse the model `Food` and parse all fields in the API.
"""
class Meta:
model = Food
fields = '__all__'
class BasicFoodSerializer(serializers.ModelSerializer):
"""
REST API Serializer for BasicFood.

View File

@ -1,7 +1,7 @@
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from .views import AllergenViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet
from .views import AllergenViewSet, FoodViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet
def register_food_urls(router, path):
@ -9,6 +9,7 @@ def register_food_urls(router, path):
Configure router for Food REST API.
"""
router.register(path + '/allergen', AllergenViewSet)
router.register(path + '/food', FoodViewSet)
router.register(path + '/basicfood', BasicFoodViewSet)
router.register(path + '/transformedfood', TransformedFoodViewSet)
router.register(path + '/qrcode', QRCodeViewSet)

View File

@ -5,8 +5,8 @@ from api.viewsets import ReadProtectedModelViewSet
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter
from .serializers import AllergenSerializer, BasicFoodSerializer, TransformedFoodSerializer, QRCodeSerializer
from ..models import Allergen, BasicFood, TransformedFood, QRCode
from .serializers import AllergenSerializer, FoodSerializer, BasicFoodSerializer, TransformedFoodSerializer, QRCodeSerializer
from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode
class AllergenViewSet(ReadProtectedModelViewSet):
@ -22,6 +22,19 @@ class AllergenViewSet(ReadProtectedModelViewSet):
search_fields = ['$name', ]
class FoodViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Food` objects, serialize it to JSON with the given serializer,
then render it on /api/food/food/
"""
queryset = Food.objects.order_by('id')
serializer_class = FoodSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['name', ]
search_fields = ['$name', ]
class BasicFoodViewSet(ReadProtectedModelViewSet):
"""
REST API View set.

View File

@ -12,7 +12,7 @@ from note_kfet.inputs import Autocomplete
from note_kfet.middlewares import get_current_request
from permission.backends import PermissionBackend
from .models import BasicFood, TransformedFood, QRCode
from .models import Food, BasicFood, TransformedFood, QRCode
class QRCodeForms(forms.ModelForm):
@ -22,7 +22,6 @@ class QRCodeForms(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['food_container'].queryset = self.fields['food_container'].queryset.filter(
is_ready=False,
end_of_life__isnull=True,
polymorphic_ctype__model='transformedfood',
).filter(PermissionBackend.filter_queryset(
@ -151,3 +150,38 @@ class AddIngredientForms(forms.ModelForm):
class Meta:
model = TransformedFood
fields = ('ingredients',)
class ManageIngredientsForm(forms.Form):
"""
Form to manage ingredient
"""
fully_used = forms.BooleanField()
fully_used.initial = True
fully_used.required = True
fully_used.label = _('Fully used')
name = forms.CharField()
name.widget = Autocomplete(
model=Food,
resetable=True,
attrs={"api_url": "/api/food/food",
"class": "autocomplete"},
)
name.label = _('Name')
qrcode = forms.IntegerField()
qrcode.widget = Autocomplete(
model=QRCode,
resetable=True,
attrs={"api_url": "/api/food/qrcode/",
"name_field": "qr_code_number",
"class": "autocomplete"},
)
qrcode.label = _('QR code number')
ManageIngredientsFormSet = forms.formset_factory(
ManageIngredientsForm,
extra=1,
)

View File

@ -39,6 +39,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
<a class="btn btn-sm btn-primary" href="{% url "food:add_ingredient" pk=food.pk %}">
{% trans "Add to a meal" %}
</a>
{% endif %}
{% if manage_ingredients %}
<a class="btn btn-sm btn-secondary" href="{% url "food:manage_ingredients" pk=food.pk %}">
{% trans "Manage ingredients" %}
</a>
{% endif %}
<a class="btn btn-sm btn-primary" href="{% url "food:food_list" %}">
{% trans "Return to the food list" %}

View File

@ -0,0 +1,116 @@
{% 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>
<div class="card-body" id="form"></div>
<form method="post" action="">
{% csrf_token %}
<table class="table table-condensed table-striped">
{# Fill initial data #}
{% for display, form in formset %}
{% if forloop.first %}
<thead>
<tr>
<th>{{ form.name.label }}</th>
<th>{{ form.qrcode.label }}</th>
<th>{{ form.fully_used.label }}</th>
</tr>
</thead>
<tbody id="form_body">
{% endif %}
{% if display %}
<tr class="row-formset ingredients">
{% else %}
<tr class="row-formset ingredients" style="display: none">
{% endif %}
<td>{{ form.name }}</td>
<td>{{ form.qrcode }}</td>
<td>{{ form.fully_used }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{# 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">{% trans "Submit"%}</button>
</div>
</form>
</div>
{% endblock %}
{% block extrajavascript %}
<script>
/* script that handles add and remove lines */
const foods = {{ ingredients | safe }};
function set_ingredient_id () {
let ingredients = document.getElementsByClassName('ingredients');
for (var i = 0; i < ingredients.length; i++) {
ingredients[i].id = 'ingredients-' + parseInt(i);
};
}
set_ingredient_id();
function prepopulate () {
for (var i = 0; i < {{ ingredients_count }}; i++) {
let prefix = 'id_form-' + parseInt(i) + '-';
document.getElementById(prefix + 'name_pk').value = parseInt(foods[i]['food_pk']);
document.getElementById(prefix + 'name').value = foods[i]['food_name'];
document.getElementById(prefix + 'qrcode_pk').value = parseInt(foods[i]['qr_pk']);
if (foods[i]['qr_number'] === '') {
document.getElementById(prefix + 'qrcode').value = '';
}
else {
document.getElementById(prefix + 'qrcode').value = parseInt(foods[i]['qr_number']);
};
document.getElementById(prefix + 'fully_used').checked = Boolean(foods[i]['fully_used']);
};
}
prepopulate();
function delete_form_data (form_id) {
let prefix = "id_form-" + parseInt(form_id) + "-";
document.getElementById(prefix + "name_pk").value = "";
document.getElementById(prefix + "name").value = "";
document.getElementById(prefix + "qrcode_pk").value = "";
document.getElementById(prefix + "qrcode").value = "";
document.getElementById(prefix + "fully_used").checked = true;
}
var form_count = {{ ingredients_count }} + 1;
$('#add_more').click(function () {
let ingredient_form = document.getElementById('ingredients-' + parseInt(form_count));
if (ingredient_form === null) {
addMsg(gettext("You can't add more ingredient"), "danger", 5000);
return;};
ingredient_form.style = "display: true";
form_count += 1;
});
$('#remove_one').click(function () {
let ingredient_form = document.getElementById('ingredients-' + parseInt(form_count - 1));
if (ingredient_form === null) {
return;};
ingredient_form.style = "display: none";
delete_form_data(form_count - 1);
form_count -= 1;
});
addMsg(gettext("Add ingredient with their name or their qrcode, if two different priority is given to qrcode"), "warning");
</script>
{% endblock %}

View File

@ -0,0 +1,87 @@
{% extends "base.html" %}
{% comment %}
Copyright (C) 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>
<div class="card-body" id="form">
<form method="post">
{% csrf_token %}
{{ form | crispy }}
<table class="table table-condensed table-striped">
{# Fill initial data #}
{% for ingredient_form in formset %}
{% if forloop.first %}
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "QR-code number" %}</th>
<th>{% trans "Fully used" %}<th>
</tr>
</thead>
<tbody id="form_body">
{% endif %}
<tr class="row-formset">
{{ ingredient_form | crispy }}
<td>{{ ingredient_form.name }}</td>
<td>{{ ingredient_form.qrcode }}</td>
<td>{{ ingredient_form.fully_used }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{# Display buttons to add and remove products #}
<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 type="submit" class="btn btn-block btn-primary">{% trans "Submit" %}</button>
</div>
</form>
</div>
</div>
{# Hidden div that store an empty product 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>
<td>{{ formset.empty_form.qrcode }}</td>
<td>{{ formset.empty_form.fully_used }}</td>
</tr>
</tbody>
</table>
</div>
{% endblock %}
{% block extrajavascript %}
<script>
/* script that handles add and remove lines */
IDS = {};
$("#id_form-TOTAL_FORMS").val($(".row-formset").length - 1);
$('#add_more').click(function () {
let form_idx = $('#id_form-TOTAL_FORMS').val();
$('#form_body').append($('#for_real').html().replace(/__prefix__/g, form_idx));
$('#id_form-TOTAL_FORMS').val(parseInt(form_idx) + 1);
$('#id_form-' + parseInt(form_idx) + '-id').val(IDS[parseInt(form_idx)]);
});
$('#remove_one').click(function () {
let form_idx = $('#id_form-TOTAL_FORMS').val();
if (form_idx > 0) {
IDS[parseInt(form_idx) - 1] = $('#id_form-' + (parseInt(form_idx) - 1) + '-id').val();
$('#form_body tr:last-child').remove();
$('#id_form-TOTAL_FORMS').val(parseInt(form_idx) - 1);
}
});
</script>
{% endblock %}

View File

@ -13,6 +13,7 @@ urlpatterns = [
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'),

View File

@ -18,7 +18,9 @@ from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin, ProtectedCreateView, LoginRequiredMixin
from .models import Food, BasicFood, TransformedFood, QRCode
from .forms import AddIngredientForms, BasicFoodForms, TransformedFoodForms, BasicFoodUpdateForms, TransformedFoodUpdateForms, QRCodeForms
from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \
ManageIngredientsForm, ManageIngredientsFormSet, AddIngredientForms, \
BasicFoodUpdateForms, TransformedFoodUpdateForms
from .tables import FoodTable
from .utils import pretty_duration
@ -73,7 +75,7 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li
PermissionBackend.filter_queryset(self.request, Food, 'view'))
# table served
served_table = self.get_queryset().order_by('-pk').filter(
end_of_life='', is_ready=True).filter(
end_of_life='', is_ready=True).exclude(
Q(polymorphic_ctype__model='basicfood',
basicfood__date_type='DLC',
expiry_date__lte=timezone.now(),)
@ -106,7 +108,7 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li
return context
class QRCodeCreateView(ProtectQuerysetMixin, CreateView):
class QRCodeCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
"""
A view to add qrcode
"""
@ -238,12 +240,82 @@ class TransformedFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
form.instance.is_ready = False
return super().form_valid(form)
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['title'] += ' ' + self.object.name
return context
def get_success_url(self, **kwargs):
self.object.refresh_from_db()
return reverse_lazy('food:transformedfood_view', kwargs={"pk": self.object.pk})
class AddIngredientView(ProtectQuerysetMixin, UpdateView):
MAX_FORMS = 10
class ManageIngredientsView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
"""
A view to manage ingredient for a transformed food
"""
model = TransformedFood
fields = ['ingredients']
extra_context = {"title": _("Manage ingredients of:")}
template_name = 'food/manage_ingredients.html'
@transaction.atomic
def form_valid(self, form):
old_ingredients = list(self.object.ingredients.all()).copy()
old_allergens = list(self.object.allergens.all()).copy()
self.object.ingredients.clear()
for i in range(self.object.ingredients.all().count() + 1 + MAX_FORMS):
prefix = 'form-' + str(i) + '-'
if form.data[prefix + 'qrcode'] not in ['0', '']:
ingredient = QRCode.objects.get(pk=form.data[prefix + 'qrcode']).food_container
self.object.ingredients.add(ingredient)
if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on':
ingredient.end_of_life = _('Fully used in {meal}'.format(
meal=self.object.name))
ingredient.save()
elif form.data[prefix + 'name'] != '':
ingredient = Food.objects.get(pk=form.data[prefix + 'name'])
self.object.ingredients.add(ingredient)
if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on':
ingredient.end_of_life = _('Fully used in {meal}'.format(
meal=self.object.name))
ingredient.save()
self.object.save(old_ingredients=old_ingredients, old_allergens=old_allergens)
return HttpResponseRedirect(self.get_success_url())
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['title'] += ' ' + self.object.name
formset = ManageIngredientsFormSet()
ingredients = self.object.ingredients.all()
formset.extra += ingredients.count() + MAX_FORMS
context['form'] = ManageIngredientsForm()
context['ingredients_count'] = ingredients.count()
display = [True] * (1 + ingredients.count()) + [False] * (formset.extra - ingredients.count() - 1)
context['formset'] = zip(display, formset)
context['ingredients'] = []
for ingredient in ingredients:
qr = QRCode.objects.filter(food_container=ingredient)
context['ingredients'].append({
'food_pk': ingredient.pk,
'food_name': ingredient.name,
'qr_pk': '' if qr.count() == 0 else qr[0].pk,
'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):
return reverse_lazy('food:transformedfood_view', kwargs={"pk": self.object.pk})
class AddIngredientView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
"""
A view to add ingredient to a meal
"""
@ -404,6 +476,7 @@ class TransformedFoodDetailView(FoodDetailView):
pretty_duration(self.object.shelf_life)
))
context["foods"] = self.object.ingredients.all()
context["manage_ingredients"] = True
return context
def get(self, *args, **kwargs):

View File

@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-24 18:22+0200\n"
"POT-Creation-Date: 2025-04-30 11:44+0200\n"
"PO-Revision-Date: 2022-04-11 22:05+0200\n"
"Last-Translator: bleizi <bleizi@crans.org>\n"
"Language-Team: French <http://translate.ynerant.fr/projects/nk20/nk20/fr/>\n"
@ -383,7 +383,9 @@ msgstr "Entrée effectuée !"
#: apps/activity/templates/activity/activity_form.html:16
#: apps/food/templates/food/food_update.html:17
#: apps/food/templates/food/manage_ingredients.html:48
#: apps/food/templates/food/qrcode.html:18
#: apps/food/templates/food/transformedfood_update.html:45
#: apps/member/templates/member/add_members.html:46
#: apps/member/templates/member/club_form.html:16
#: apps/note/templates/note/transactiontemplate_form.html:18
@ -495,26 +497,40 @@ msgstr "API"
msgid "food"
msgstr "bouffe"
#: apps/food/forms.py:50
#: apps/food/forms.py:49
msgid "Pasta METRO 5kg"
msgstr "Pâtes METRO 5kg"
#: apps/food/forms.py:54 apps/food/forms.py:82
#: apps/food/forms.py:53 apps/food/forms.py:81
msgid "Specific order given to GCKs"
msgstr ""
#: apps/food/forms.py:78
#: apps/food/forms.py:77
msgid "Lasagna"
msgstr "Lasagnes"
#: apps/food/forms.py:117
#: apps/food/forms.py:116
msgid "Shelf life (in hours)"
msgstr "Durée de vie (en heure)"
#: apps/food/forms.py:139
#: apps/food/forms.py:138 apps/food/forms.py:162
#: apps/food/templates/food/transformedfood_update.html:25
msgid "Fully used"
msgstr "Entièrement utilisé"
#: apps/food/forms.py:171 apps/food/templates/food/qrcode.html:29
#: apps/food/templates/food/transformedfood_update.html:23
#: apps/note/templates/note/transaction_form.html:132
#: apps/treasury/models.py:61
msgid "Name"
msgstr "Nom"
#: apps/food/forms.py:181
#, fuzzy
#| msgid "QR-code number"
msgid "QR code number"
msgstr "numéro de QR-code"
#: apps/food/models.py:23
msgid "Allergen"
msgstr "Allergène"
@ -547,7 +563,7 @@ msgstr "est prêt"
msgid "order"
msgstr "consigne"
#: apps/food/models.py:107 apps/food/views.py:32
#: apps/food/models.py:107 apps/food/views.py:34
#: note_kfet/templates/base.html:72
msgid "Food"
msgstr "Bouffe"
@ -605,6 +621,7 @@ msgid "QR-codes"
msgstr "QR-codes"
#: apps/food/models.py:286
#: apps/food/templates/food/transformedfood_update.html:24
msgid "QR-code number"
msgstr "numéro de QR-code"
@ -624,7 +641,11 @@ msgstr "Modifier"
msgid "Add to a meal"
msgstr "Ajouter à un plat"
#: apps/food/templates/food/food_detail.html:44
#: apps/food/templates/food/food_detail.html:45
msgid "Manage ingredients"
msgstr "Gérer les ingrédients"
#: apps/food/templates/food/food_detail.html:49
msgid "Return to the food list"
msgstr "Retour à la liste de nourriture"
@ -660,6 +681,16 @@ msgstr "Bouffe du club"
msgid "Yours club has not food yet."
msgstr "Ton club n'a pas de bouffe pour l'instant"
#: apps/food/templates/food/manage_ingredients.html:45
#: apps/food/templates/food/transformedfood_update.html:42
msgid "Add ingredient"
msgstr "Ajouter un ingrédient"
#: apps/food/templates/food/manage_ingredients.html:46
#: apps/food/templates/food/transformedfood_update.html:43
msgid "Remove ingredient"
msgstr "Enlever un ingrédient"
#: apps/food/templates/food/qrcode.html:22
msgid "Copy constructor"
msgstr "Constructeur de copie"
@ -668,12 +699,6 @@ msgstr "Constructeur de copie"
msgid "New food"
msgstr "Nouvel aliment"
#: apps/food/templates/food/qrcode.html:29
#: apps/note/templates/note/transaction_form.html:132
#: apps/treasury/models.py:61
msgid "Name"
msgstr "Nom"
#: apps/food/templates/food/qrcode.html:32
msgid "Owner"
msgstr "Propriétaire"
@ -726,40 +751,49 @@ msgstr "semaines"
msgid "and"
msgstr "et"
#: apps/food/views.py:116
#: apps/food/views.py:118
msgid "Add a new QRCode"
msgstr "Ajouter un nouveau QR-code"
#: apps/food/views.py:165
#: apps/food/views.py:167
msgid "Add an aliment"
msgstr "Ajouter un nouvel aliment"
#: apps/food/views.py:224
#: apps/food/views.py:226
msgid "Add a meal"
msgstr "Ajouter un plat"
#: apps/food/views.py:251
#: apps/food/views.py:262
msgid "Manage ingredients of:"
msgstr "Gestion des ingrédienrs de :"
#: apps/food/views.py:276 apps/food/views.py:284
#, python-brace-format
msgid "Fully used in {meal}"
msgstr "Aliment entièrement utilisé dans : {meal}"
#: apps/food/views.py:323
msgid "Add the ingredient:"
msgstr "Ajouter l'ingrédient"
#: apps/food/views.py:275
#: apps/food/views.py:349
#, python-brace-format
msgid "Food fully used in : {meal.name}"
msgstr "Aliment entièrement utilisé dans : {meal.name}"
#: apps/food/views.py:294
#: apps/food/views.py:368
msgid "Update an aliment"
msgstr "Modifier un aliment"
#: apps/food/views.py:342
#: apps/food/views.py:416
msgid "Details of:"
msgstr "Détails de :"
#: apps/food/views.py:352 apps/treasury/tables.py:149
#: apps/food/views.py:426 apps/treasury/tables.py:149
msgid "Yes"
msgstr "Oui"
#: apps/food/views.py:354 apps/member/models.py:99 apps/treasury/tables.py:149
#: apps/food/views.py:428 apps/member/models.py:99 apps/treasury/tables.py:149
msgid "No"
msgstr "Non"