From ad0a219ed3ba7dd8fd6a1a39096b14198c7707e2 Mon Sep 17 00:00:00 2001 From: quark Date: Wed, 30 Apr 2025 11:59:08 +0200 Subject: [PATCH] Add manage ingredient feature, fix some bug --- apps/food/api/serializers.py | 12 +- apps/food/api/urls.py | 3 +- apps/food/api/views.py | 17 ++- apps/food/forms.py | 38 +++++- apps/food/templates/food/food_detail.html | 5 + .../templates/food/manage_ingredients.html | 116 ++++++++++++++++++ .../food/transformedfood_update.html | 87 +++++++++++++ apps/food/urls.py | 1 + apps/food/views.py | 81 +++++++++++- locale/fr/LC_MESSAGES/django.po | 80 ++++++++---- 10 files changed, 407 insertions(+), 33 deletions(-) create mode 100644 apps/food/templates/food/manage_ingredients.html create mode 100644 apps/food/templates/food/transformedfood_update.html diff --git a/apps/food/api/serializers.py b/apps/food/api/serializers.py index fa0641e8..eb1621b6 100644 --- a/apps/food/api/serializers.py +++ b/apps/food/api/serializers.py @@ -3,7 +3,7 @@ from rest_framework import serializers -from ..models import Allergen, 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. diff --git a/apps/food/api/urls.py b/apps/food/api/urls.py index 5a8ce881..8fa6995d 100644 --- a/apps/food/api/urls.py +++ b/apps/food/api/urls.py @@ -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) diff --git a/apps/food/api/views.py b/apps/food/api/views.py index 2c75a570..0aead0de 100644 --- a/apps/food/api/views.py +++ b/apps/food/api/views.py @@ -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. diff --git a/apps/food/forms.py b/apps/food/forms.py index c823b0b1..dfa32008 100644 --- a/apps/food/forms.py +++ b/apps/food/forms.py @@ -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, +) diff --git a/apps/food/templates/food/food_detail.html b/apps/food/templates/food/food_detail.html index d330ad64..9343f6d1 100644 --- a/apps/food/templates/food/food_detail.html +++ b/apps/food/templates/food/food_detail.html @@ -39,6 +39,11 @@ SPDX-License-Identifier: GPL-3.0-or-later {% trans "Add to a meal" %} + {% endif %} + {% if manage_ingredients %} + + {% trans "Manage ingredients" %} + {% endif %} {% trans "Return to the food list" %} diff --git a/apps/food/templates/food/manage_ingredients.html b/apps/food/templates/food/manage_ingredients.html new file mode 100644 index 00000000..0dd7acb5 --- /dev/null +++ b/apps/food/templates/food/manage_ingredients.html @@ -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 %} +
+

+ {{ title }} +

+
+
+ {% csrf_token %} + + {# Fill initial data #} + {% for display, form in formset %} + {% if forloop.first %} + + + + + + + + + {% endif %} + {% if display %} + + {% else %} + + {% endif %} + + + + + {% endfor %} + +
{{ form.name.label }}{{ form.qrcode.label }}{{ form.fully_used.label }}
+ + {# Display buttons to add and remove ingredients #} +
+
+ + +
+ +
+
+
+ +{% endblock %} +{% block extrajavascript %} + +{% endblock %} diff --git a/apps/food/templates/food/transformedfood_update.html b/apps/food/templates/food/transformedfood_update.html new file mode 100644 index 00000000..820970b7 --- /dev/null +++ b/apps/food/templates/food/transformedfood_update.html @@ -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 %} +
+

+ {{ title }} +

+
+
+ {% csrf_token %} + {{ form | crispy }} + + {# Fill initial data #} + {% for ingredient_form in formset %} + {% if forloop.first %} + + + + + + + + {% endif %} + + {{ ingredient_form | crispy }} + + + + + {% endfor %} + +
{% trans "Name" %}{% trans "QR-code number" %}{% trans "Fully used" %} +
{{ ingredient_form.name }}{{ ingredient_form.qrcode }}{{ ingredient_form.fully_used }}
+ {# Display buttons to add and remove products #} +
+
+ + +
+ +
+
+
+
+ +{# Hidden div that store an empty product form, to be copied into new forms #} + +{% endblock %} +{% block extrajavascript %} + +{% endblock %} diff --git a/apps/food/urls.py b/apps/food/urls.py index 8137a6f1..81acccdd 100644 --- a/apps/food/urls.py +++ b/apps/food/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ 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'), diff --git a/apps/food/views.py b/apps/food/views.py index 90d6e8b7..4c6ad57e 100644 --- a/apps/food/views.py +++ b/apps/food/views.py @@ -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): diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 14dc6a1e..23ba06dd 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -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 \n" "Language-Team: French \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"