From 6d6583bfe698ceb1dda3d0a3bb856c1b8ac838e3 Mon Sep 17 00:00:00 2001 From: quark Date: Tue, 22 Apr 2025 19:52:32 +0200 Subject: [PATCH] Rewrite food apps, new feature some changes to model --- apps/food/admin.py | 59 ++++ apps/food/api/__init__.py | 0 apps/food/api/serializers.py | 46 +++ apps/food/api/urls.py | 14 + apps/food/api/views.py | 61 ++++ apps/food/forms.py | 153 ++++++++ apps/food/migrations/0001_initial.py | 199 +++++++++++ apps/food/migrations/__init__.py | 0 apps/food/models.py | 286 +++++++++++++++ apps/food/tables.py | 21 ++ apps/food/templates/food/food_detail.html | 48 +++ apps/food/templates/food/food_list.html | 71 ++++ apps/food/templates/food/food_update.html | 21 ++ apps/food/templates/food/qrcode.html | 52 +++ apps/food/tests/test_food.py | 170 +++++++++ apps/food/urls.py | 20 ++ apps/food/utils.py | 53 +++ apps/food/views.py | 402 ++++++++++++++++++++++ apps/permission/fixtures/initial.json | 51 +-- 19 files changed, 1693 insertions(+), 34 deletions(-) create mode 100644 apps/food/admin.py create mode 100644 apps/food/api/__init__.py create mode 100644 apps/food/api/serializers.py create mode 100644 apps/food/api/urls.py create mode 100644 apps/food/api/views.py create mode 100644 apps/food/forms.py create mode 100644 apps/food/migrations/0001_initial.py create mode 100644 apps/food/migrations/__init__.py create mode 100644 apps/food/models.py create mode 100644 apps/food/tables.py create mode 100644 apps/food/templates/food/food_detail.html create mode 100644 apps/food/templates/food/food_list.html create mode 100644 apps/food/templates/food/food_update.html create mode 100644 apps/food/templates/food/qrcode.html create mode 100644 apps/food/tests/test_food.py create mode 100644 apps/food/urls.py create mode 100644 apps/food/utils.py create mode 100644 apps/food/views.py diff --git a/apps/food/admin.py b/apps/food/admin.py new file mode 100644 index 00000000..613ebade --- /dev/null +++ b/apps/food/admin.py @@ -0,0 +1,59 @@ +# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.contrib import admin +from polymorphic.admin import PolymorphicChildModelAdmin, PolymorphicParentModelAdmin +from note_kfet.admin import admin_site + +from .models import Allergen, Food, BasicFood, TransformedFood, QRCode + + +@admin.register(Allergen, site=admin_site) +class AllergenAdmin(admin.ModelAdmin): + """ + Admin customisation for Allergen + """ + ordering = ['name'] + + +@admin.register(Food, site=admin_site) +class FoodAdmin(PolymorphicParentModelAdmin): + """ + Admin customisation for Food + """ + child_models = (Food, BasicFood, TransformedFood) + list_display = ('name', 'expiry_date', 'owner', 'is_ready') + list_filter = ('is_ready', 'end_of_life') + search_fields = ['name'] + ordering = ['expiry_date', 'name'] + + +@admin.register(BasicFood, site=admin_site) +class BasicFood(PolymorphicChildModelAdmin): + """ + Admin customisation for BasicFood + """ + list_display = ('name', 'expiry_date', 'date_type', 'owner', 'is_ready') + list_filter = ('is_ready', 'date_type', 'end_of_life') + search_fields = ['name'] + ordering = ['expiry_date', 'name'] + + +@admin.register(TransformedFood, site=admin_site) +class TransformedFood(PolymorphicChildModelAdmin): + """ + Admin customisation for TransformedFood + """ + list_display = ('name', 'expiry_date', 'shelf_life', 'owner', 'is_ready') + list_filter = ('is_ready', 'end_of_life', 'shelf_life') + search_fields = ['name'] + ordering = ['expiry_date', 'name'] + + +@admin.register(QRCode, site=admin_site) +class QRCodeAdmin(admin.ModelAdmin): + """ + Admin customisation for QRCode + """ + list_diplay = ('qr_code_number', 'food_container') + search_fields = ['food_container__name'] diff --git a/apps/food/api/__init__.py b/apps/food/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/food/api/serializers.py b/apps/food/api/serializers.py new file mode 100644 index 00000000..fa0641e8 --- /dev/null +++ b/apps/food/api/serializers.py @@ -0,0 +1,46 @@ +# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from rest_framework import serializers + +from ..models import Allergen, BasicFood, TransformedFood, QRCode + + +class AllergenSerializer(serializers.ModelSerializer): + """ + REST API Serializer for Allergen. + The djangorestframework plugin will analyse the model `Allergen` and parse all fields in the API. + """ + class Meta: + model = Allergen + fields = '__all__' + + +class BasicFoodSerializer(serializers.ModelSerializer): + """ + REST API Serializer for BasicFood. + The djangorestframework plugin will analyse the model `BasicFood` and parse all fields in the API. + """ + class Meta: + model = BasicFood + fields = '__all__' + + +class TransformedFoodSerializer(serializers.ModelSerializer): + """ + REST API Serializer for TransformedFood. + The djangorestframework plugin will analyse the model `TransformedFood` and parse all fields in the API. + """ + class Meta: + model = TransformedFood + fields = '__all__' + + +class QRCodeSerializer(serializers.ModelSerializer): + """ + REST API Serializer for QRCode. + The djangorestframework plugin will analyse the model `QRCode` and parse all fields in the API. + """ + class Meta: + model = QRCode + fields = '__all__' diff --git a/apps/food/api/urls.py b/apps/food/api/urls.py new file mode 100644 index 00000000..5a8ce881 --- /dev/null +++ b/apps/food/api/urls.py @@ -0,0 +1,14 @@ +# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from .views import AllergenViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet + + +def register_food_urls(router, path): + """ + Configure router for Food REST API. + """ + router.register(path + '/allergen', AllergenViewSet) + 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 new file mode 100644 index 00000000..2c75a570 --- /dev/null +++ b/apps/food/api/views.py @@ -0,0 +1,61 @@ +# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +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 + + +class AllergenViewSet(ReadProtectedModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `Allergen` objects, serialize it to JSON with the given serializer, + then render it on /api/food/allergen/ + """ + queryset = Allergen.objects.order_by('id') + serializer_class = AllergenSerializer + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['name', ] + search_fields = ['$name', ] + + +class BasicFoodViewSet(ReadProtectedModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `BasicFood` objects, serialize it to JSON with the given serializer, + then render it on /api/food/basicfood/ + """ + queryset = BasicFood.objects.order_by('id') + serializer_class = BasicFoodSerializer + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['name', ] + search_fields = ['$name', ] + + +class TransformedFoodViewSet(ReadProtectedModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `TransformedFood` objects, serialize it to JSON with the given serializer, + then render it on /api/food/transformedfood/ + """ + queryset = TransformedFood.objects.order_by('id') + serializer_class = TransformedFoodSerializer + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['name', ] + search_fields = ['$name', ] + + +class QRCodeViewSet(ReadProtectedModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `QRCode` objects, serialize it to JSON with the given serializer, + then render it on /api/food/qrcode/ + """ + queryset = QRCode.objects.order_by('id') + serializer_class = QRCodeSerializer + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['qr_code_number', ] + search_fields = ['$qr_code_number', ] diff --git a/apps/food/forms.py b/apps/food/forms.py new file mode 100644 index 00000000..c823b0b1 --- /dev/null +++ b/apps/food/forms.py @@ -0,0 +1,153 @@ +# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from random import shuffle + +from bootstrap_datepicker_plus.widgets import DateTimePickerInput +from django import forms +from django.forms.widgets import NumberInput +from django.utils.translation import gettext_lazy as _ +from member.models import Club +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 + + +class QRCodeForms(forms.ModelForm): + """ + Form for create QRCode for container + """ + 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( + get_current_request(), + TransformedFood, + "view", + )) + + class Meta: + model = QRCode + fields = ('food_container',) + + +class BasicFoodForms(forms.ModelForm): + """ + Form for add basicfood + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['name'].widget.attrs.update({"autofocus": "autofocus"}) + self.fields['name'].required = True + self.fields['owner'].required = True + + # Some example + self.fields['name'].widget.attrs.update({"placeholder": _("Pasta METRO 5kg")}) + clubs = list(Club.objects.filter(PermissionBackend.filter_queryset(get_current_request(), Club, "change")).all()) + shuffle(clubs) + self.fields['owner'].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..." + self.fields['order'].widget.attrs["placeholder"] = _("Specific order given to GCKs") + + class Meta: + model = BasicFood + fields = ('name', 'owner', 'date_type', 'expiry_date', 'allergens', 'order',) + widgets = { + "owner": Autocomplete( + model=Club, + attrs={"api_url": "/api/members/club/"}, + ), + "expiry_date": DateTimePickerInput(), + } + + +class TransformedFoodForms(forms.ModelForm): + """ + Form for add transformedfood + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['name'].required = True + self.fields['owner'].required = True + + # Some example + self.fields['name'].widget.attrs.update({"placeholder": _("Lasagna")}) + clubs = list(Club.objects.filter(PermissionBackend.filter_queryset(get_current_request(), Club, "change")).all()) + shuffle(clubs) + self.fields['owner'].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..." + self.fields['order'].widget.attrs["placeholder"] = _("Specific order given to GCKs") + + class Meta: + model = TransformedFood + fields = ('name', 'owner', 'order',) + widgets = { + "owner": Autocomplete( + model=Club, + attrs={"api_url": "/api/members/club/"}, + ), + } + + +class BasicFoodUpdateForms(forms.ModelForm): + """ + Form for update basicfood object + """ + class Meta: + model = BasicFood + fields = ('name', 'owner', 'date_type', 'expiry_date', 'end_of_life', 'is_ready', 'order', 'allergens') + widgets = { + "owner": Autocomplete( + model=Club, + attrs={"api_url": "/api/members/club/"}, + ), + "expiry_date": DateTimePickerInput(), + } + + +class TransformedFoodUpdateForms(forms.ModelForm): + """ + Form for update transformedfood object + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['shelf_life'].label = _('Shelf life (in hours)') + + class Meta: + model = TransformedFood + fields = ('name', 'owner', 'end_of_life', 'is_ready', 'order', 'shelf_life') + widgets = { + "owner": Autocomplete( + model=Club, + attrs={"api_url": "/api/members/club/"}, + ), + "expiry_date": DateTimePickerInput(), + "shelf_life": NumberInput(), + } + + +class AddIngredientForms(forms.ModelForm): + """ + Form for add an ingredient + """ + fully_used = forms.BooleanField() + fully_used.initial = True + fully_used.required = False + fully_used.label = _("Fully used") + + def __init__(self, *args, **kwargs): + 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( + polymorphic_ctype__model="transformedfood", + is_ready=False, + end_of_life='', + ).filter(PermissionBackend.filter_queryset(get_current_request(), TransformedFood, "change")).exclude(pk=pk) + + class Meta: + model = TransformedFood + fields = ('ingredients',) diff --git a/apps/food/migrations/0001_initial.py b/apps/food/migrations/0001_initial.py new file mode 100644 index 00000000..706a0590 --- /dev/null +++ b/apps/food/migrations/0001_initial.py @@ -0,0 +1,199 @@ +# Generated by Django 4.2.20 on 2025-04-17 21:43 + +import datetime +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("member", "0013_auto_20240801_1436"), + ] + + operations = [ + migrations.CreateModel( + name="Allergen", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255, verbose_name="name")), + ], + options={ + "verbose_name": "Allergen", + "verbose_name_plural": "Allergens", + }, + ), + migrations.CreateModel( + name="Food", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255, verbose_name="name")), + ("expiry_date", models.DateTimeField(verbose_name="expiry date")), + ( + "end_of_life", + models.CharField(max_length=255, verbose_name="end of life"), + ), + ( + "is_ready", + models.BooleanField(max_length=255, verbose_name="is ready"), + ), + ("order", models.CharField(max_length=255, verbose_name="order")), + ( + "allergens", + models.ManyToManyField( + blank=True, to="food.allergen", verbose_name="allergens" + ), + ), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="member.club", + verbose_name="owner", + ), + ), + ( + "polymorphic_ctype", + models.ForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="polymorphic_%(app_label)s.%(class)s_set+", + to="contenttypes.contenttype", + ), + ), + ], + options={ + "verbose_name": "Food", + "verbose_name_plural": "Foods", + }, + ), + migrations.CreateModel( + name="BasicFood", + fields=[ + ( + "food_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="food.food", + ), + ), + ( + "arrival_date", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="arrival date" + ), + ), + ( + "date_type", + models.CharField( + choices=[("DLC", "DLC"), ("DDM", "DDM")], max_length=255 + ), + ), + ], + options={ + "verbose_name": "Basic food", + "verbose_name_plural": "Basic foods", + }, + bases=("food.food",), + ), + migrations.CreateModel( + name="QRCode", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "qr_code_number", + models.PositiveIntegerField( + unique=True, verbose_name="qr code number" + ), + ), + ( + "food_container", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="QR_code", + to="food.food", + verbose_name="food container", + ), + ), + ], + options={ + "verbose_name": "QR-code", + "verbose_name_plural": "QR-codes", + }, + ), + migrations.CreateModel( + name="TransformedFood", + fields=[ + ( + "food_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="food.food", + ), + ), + ( + "creation_date", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="creation date" + ), + ), + ( + "shelf_life", + models.DurationField( + default=datetime.timedelta(days=3), verbose_name="shelf life" + ), + ), + ( + "ingredients", + models.ManyToManyField( + blank=True, + related_name="transformed_ingredient_inv", + to="food.food", + verbose_name="transformed ingredient", + ), + ), + ], + options={ + "verbose_name": "Transformed food", + "verbose_name_plural": "Transformed foods", + }, + bases=("food.food",), + ), + ] diff --git a/apps/food/migrations/__init__.py b/apps/food/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/food/models.py b/apps/food/models.py new file mode 100644 index 00000000..c0b25078 --- /dev/null +++ b/apps/food/models.py @@ -0,0 +1,286 @@ +# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from datetime import timedelta + +from django.db import models, transaction +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from polymorphic.models import PolymorphicModel +from member.models import Club + + +class Allergen(models.Model): + """ + Allergen and alimentary restrictions + """ + name = models.CharField( + verbose_name=_('name'), + max_length=255, + ) + + class Meta: + verbose_name = _("Allergen") + verbose_name_plural = _("Allergens") + + def __str__(self): + return self.name + + +class Food(PolymorphicModel): + """ + Describe any type of food + """ + name = models.CharField( + verbose_name=_("name"), + max_length=255, + ) + + owner = models.ForeignKey( + Club, + on_delete=models.PROTECT, + related_name='+', + verbose_name=_('owner'), + ) + + allergens = models.ManyToManyField( + Allergen, + blank=True, + verbose_name=_('allergens'), + ) + + expiry_date = models.DateTimeField( + verbose_name=_('expiry date'), + null=False, + ) + + end_of_life = models.CharField( + blank=True, + verbose_name=_('end of life'), + max_length=255, + ) + + is_ready = models.BooleanField( + verbose_name=_('is ready'), + max_length=255, + ) + + order = models.CharField( + blank=True, + verbose_name=_('order'), + max_length=255, + ) + + def __str__(self): + return self.name + + @transaction.atomic + def update_allergens(self): + # update parents + for parent in self.transformed_ingredient_inv.iterator(): + old_allergens = list(parent.allergens.all()).copy() + parent.allergens.clear() + for child in parent.ingredients.iterator(): + if child.pk != self.pk: + parent.allergens.set(parent.allergens.union(child.allergens.all())) + parent.allergens.set(parent.allergens.union(self.allergens.all())) + if old_allergens != list(parent.allergens.all()): + parent.save(old_allergens=old_allergens) + + def update_expiry_date(self): + # update parents + for parent in self.transformed_ingredient_inv.iterator(): + old_expiry_date = parent.expiry_date + parent.expiry_date = parent.shelf_life + parent.creation_date + for child in parent.ingredients.iterator(): + if (child.pk != self.pk + and not (child.polymorphic_ctype.model == 'basicfood' + and child.date_type == 'DDM')): + parent.expiry_date = min(parent.expiry_date, child.expiry_date) + + if self.polymorphic_ctype.model == 'basicfood' and self.date_type == 'DLC': + parent.expiry_date = min(parent.expiry_date, self.expiry_date) + if old_expiry_date != parent.expiry_date: + parent.save() + + class Meta: + verbose_name = _('Food') + verbose_name_plural = _('Foods') + + +class BasicFood(Food): + """ + A basic food is a food directly buy and stored + """ + arrival_date = models.DateTimeField( + default=timezone.now, + verbose_name=_('arrival date'), + ) + + date_type = models.CharField( + max_length=255, + choices=( + ("DLC", "DLC"), + ("DDM", "DDM"), + ) + ) + + @transaction.atomic + def save(self, force_insert=False, force_update=False, using=None, update_fields=None, **kwargs): + created = self.pk is None + if not created: + # Check if important fields are updated + old_food = Food.objects.select_for_update().get(pk=self.pk) + if not hasattr(self, "_force_save"): + # Allergens + + if ('old_allergens' in kwargs + and list(self.allergens.all()) != kwargs['old_allergens']): + self.update_allergens() + + # Expiry date + if ((self.expiry_date != old_food.expiry_date + and self.date_type == 'DLC') + or old_food.date_type != self.date_type): + self.update_expiry_date() + + return super().save(force_insert, force_update, using, update_fields) + + @staticmethod + def get_lastests_objects(number, distinct_field, order_by_field): + """ + Get the last object with distinct field and ranked with order_by + This methods exist because we can't distinct with one field and + order with another + """ + foods = BasicFood.objects.order_by(order_by_field).all() + field = [] + for food in foods: + if getattr(food, distinct_field) in field: + continue + else: + field.append(getattr(food, distinct_field)) + number -= 1 + yield food + if not number: + return + + class Meta: + verbose_name = _('Basic food') + verbose_name_plural = _('Basic foods') + + def __str__(self): + return self.name + + +class TransformedFood(Food): + """ + A transformed food is a food with ingredients + """ + creation_date = models.DateTimeField( + default=timezone.now, + verbose_name=_('creation date'), + ) + + # Without microbiological analyzes, the storage time is 3 days + shelf_life = models.DurationField( + default=timedelta(days=3), + verbose_name=_('shelf life'), + ) + + ingredients = models.ManyToManyField( + Food, + blank=True, + symmetrical=False, + related_name='transformed_ingredient_inv', + verbose_name=_('transformed ingredient'), + ) + + def check_cycle(self, ingredients, origin, checked): + for ingredient in ingredients: + if ingredient == origin: + # We break the cycle + self.ingredients.remove(ingredient) + if ingredient.polymorphic_ctype.model == 'transformedfood' and ingredient not in checked: + ingredient.check_cycle(ingredient.ingredients.all(), origin, checked) + checked.append(ingredient) + + @transaction.atomic + def save(self, force_insert=False, force_update=False, using=None, update_fields=None, **kwargs): + created = self.pk is None + if not created: + # Check if important fields are updated + update = {'allergens': False, 'expiry_date': False} + old_food = Food.objects.select_for_update().get(pk=self.pk) + if not hasattr(self, "_force_save"): + # Allergens + # Unfortunately with the many-to-many relation we can't access + # to old allergens + if ('old_allergens' in kwargs + and list(self.allergens.all()) != kwargs['old_allergens']): + update['allergens'] = True + + # Expiry date + update['expiry_date'] = (self.shelf_life != old_food.shelf_life + or self.creation_date != old_food.creation_date) + if update['expiry_date']: + self.expiry_date = self.creation_date + self.shelf_life + # Unfortunately with the set method ingredients are already save, + # we check cycle after if possible + if ('old_ingredients' in kwargs + and list(self.ingredients.all()) != list(kwargs['old_ingredients'])): + update['allergens'] = True + update['expiry_date'] = True + + # it's preferable to keep a queryset but we allow list too + if type(kwargs['old_ingredients']) is list: + kwargs['old_ingredients'] = Food.objects.filter( + pk__in=[food.pk for food in kwargs['old_ingredients']]) + self.check_cycle(self.ingredients.all().difference(kwargs['old_ingredients']), self, []) + if update['allergens']: + self.update_allergens() + if update['expiry_date']: + self.update_expiry_date() + + if created: + self.expiry_date = self.shelf_life + self.creation_date + + # We save here because we need pk for many-to-many relation + super().save(force_insert, force_update, using, update_fields) + + for child in self.ingredients.iterator(): + self.allergens.set(self.allergens.union(child.allergens.all())) + if not (child.polymorphic_ctype.model == 'basicfood' and child.date_type == 'DDM'): + self.expiry_date = min(self.expiry_date, child.expiry_date) + return super().save(force_insert, force_update, using, update_fields) + + class Meta: + verbose_name = _('Transformed food') + verbose_name_plural = _('Transformed foods') + + def __str__(self): + return self.name + + +class QRCode(models.Model): + """ + QR-code for register food + """ + qr_code_number = models.PositiveIntegerField( + unique=True, + verbose_name=_('qr code number'), + ) + + food_container = models.ForeignKey( + Food, + on_delete=models.CASCADE, + related_name='QR_code', + verbose_name=_('food container'), + ) + + class Meta: + verbose_name = _('QR-code') + verbose_name_plural = _('QR-codes') + + def __str__(self): + return _('QR-code number') + ' ' + str(self.qr_code_number) diff --git a/apps/food/tables.py b/apps/food/tables.py new file mode 100644 index 00000000..7789ad76 --- /dev/null +++ b/apps/food/tables.py @@ -0,0 +1,21 @@ +# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +import django_tables2 as tables + +from .models import Food + + +class FoodTable(tables.Table): + """ + List all foods. + """ + class Meta: + model = Food + template_name = 'django_tables2/bootstrap4.html' + fields = ('name', 'owner', 'allergens', 'expiry_date') + row_attrs = { + 'class': 'table-row', + 'data-href': lambda record: 'detail/' + str(record.pk), + 'style': 'cursor:pointer', + } diff --git a/apps/food/templates/food/food_detail.html b/apps/food/templates/food/food_detail.html new file mode 100644 index 00000000..d330ad64 --- /dev/null +++ b/apps/food/templates/food/food_detail.html @@ -0,0 +1,48 @@ +{% 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 }} {{ food.name }} +

+
+
    + {% for field, value in fields %} +
  • {{ field }} : {{ value }}
  • + {% endfor %} + {% if meals %} +
  • {% trans "Contained in" %} : + {% for meal in meals %} + {{ meal.name }}{% if not forloop.last %},{% endif %} + {% endfor %} +
  • + {% endif %} + {% if foods %} +
  • {% trans "Contain" %} : + {% for food in foods %} + {{ food.name }}{% if not forloop.last %},{% endif %} + {% endfor %} +
  • + {% endif %} +
+ {% if update %} + + {% trans "Update" %} + + {% endif %} + {% if add_ingredient %} + + {% trans "Add to a meal" %} + + {% endif %} + + {% trans "Return to the food list" %} + +
+
+{% endblock %} diff --git a/apps/food/templates/food/food_list.html b/apps/food/templates/food/food_list.html new file mode 100644 index 00000000..efc7a554 --- /dev/null +++ b/apps/food/templates/food/food_list.html @@ -0,0 +1,71 @@ +{% extends "base_search.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 %} +{{ block.super }} +
+
+

+ {% trans "Meal served" %} +

+ {% if can_add_meal %} + + {% endif %} + {% if served.data %} + {% render_table served %} + {% else %} +
+
+ {% trans "There is no meal served." %} +
+
+
+ {% endif %} +
+

+ {% trans "Free food" %} +

+ {% if open.data %} + {% render_table open %} + {% else %} +
+
+ {% trans "There is no free food." %} +
+
+ {% endif %} +
+{% if club_tables %} +
+

+ {% trans "Food of your clubs" %} +

+
+ {% for table in club_tables %} +
+

+ {% trans "Food of club" %} {{ table.prefix }} +

+ {% if table.data %} + {% render_table table %} + {% else %} +
+
+ {% trans "Yours club has not food yet." %} +
+
+ {% endif %} +
+ {% endfor %} + {% endif %} + +{% endblock %} diff --git a/apps/food/templates/food/food_update.html b/apps/food/templates/food/food_update.html new file mode 100644 index 00000000..67de3e27 --- /dev/null +++ b/apps/food/templates/food/food_update.html @@ -0,0 +1,21 @@ +{% 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 }} + +
+
+
+{% endblock %} diff --git a/apps/food/templates/food/qrcode.html b/apps/food/templates/food/qrcode.html new file mode 100644 index 00000000..49c9eccb --- /dev/null +++ b/apps/food/templates/food/qrcode.html @@ -0,0 +1,52 @@ +{% 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 %} +{% load render_table from django_tables2 %} + +{% block content %} +
+

+ {{ title }} +

+
+
+ {% csrf_token %} + {{ form | crispy }} + +
+
+

+ {% trans "Copy constructor" %} + {% trans "New food" %} +

+ + + + + + + + + + {% for food in last_items %} + + + + + + {% endfor %} + +
+ {% trans "Name" %} + + {% trans "Owner" %} + + {% trans "Expiry date" %} +
{{ food.name }}{{ food.owner }}{{ food.expiry_date }}
+
+
+
+{% endblock %} diff --git a/apps/food/tests/test_food.py b/apps/food/tests/test_food.py new file mode 100644 index 00000000..9c314bf7 --- /dev/null +++ b/apps/food/tests/test_food.py @@ -0,0 +1,170 @@ +# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from api.tests import TestAPI +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from ..api.views import AllergenViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet +from ..models import Allergen, BasicFood, TransformedFood, QRCode + + +class TestFood(TestCase): + """ + Test food + """ + fixtures = ('initial',) + + def setUp(self): + self.user = User.objects.create_superuser( + username='admintoto', + password='toto1234', + email='toto@example.com' + ) + self.client.force_login(self.user) + + sess = self.client.session + sess['permission_mask'] = 42 + sess.save() + + self.allergen = Allergen.objects.create( + name='allergen', + ) + + self.basicfood = BasicFood.objects.create( + name='basicfood', + owner_id=1, + expiry_date=timezone.now(), + is_ready=False, + date_type='DLC', + ) + + self.transformedfood = TransformedFood.objects.create( + name='transformedfood', + owner_id=1, + expiry_date=timezone.now(), + is_ready=False, + ) + + self.qrcode = QRCode.objects.create( + qr_code_number=1, + food_container=self.basicfood, + ) + + def test_food_list(self): + """ + Display food list + """ + response = self.client.get(reverse('food:food_list')) + self.assertEqual(response.status_code, 200) + + def test_qrcode_create(self): + """ + Display QRCode creation + """ + response = self.client.get(reverse('food:qrcode_create')) + self.assertEqual(response.status_code, 200) + + def test_basicfood_create(self): + """ + Display BasicFood creation + """ + response = self.client.get(reverse('food:basicfood_create')) + self.assertEqual(response.status_code, 200) + + def test_transformedfood_create(self): + """ + Display TransformedFood creation + """ + response = self.client.get(reverse('food:transformedfood_create')) + self.assertEqual(response.status_code, 200) + + def test_food_create(self): + """ + Display Food update + """ + response = self.client.get(reverse('food:food_update')) + self.assertEqual(response.status_code, 200) + + def test_food_view(self): + """ + Display Food detail + """ + response = self.client.get(reverse('food:food_view')) + self.assertEqual(response.status_code, 302) + + def test_basicfood_view(self): + """ + Display BasicFood detail + """ + response = self.client.get(reverse('food:basicfood_view')) + self.assertEqual(response.status_code, 200) + + def test_transformedfood_view(self): + """ + Display TransformedFood detail + """ + response = self.client.get(reverse('food:transformedfood_view')) + self.assertEqual(response.status_code, 200) + + def test_add_ingredient(self): + """ + Display add ingredient view + """ + response = self.client.get(reverse('food:add_ingredient')) + self.assertEqual(response.status_code, 200) + + +class TestFoodAPI(TestAPI): + def setUp(self) -> None: + super().setUP() + + self.allergen = Allergen.objects.create( + name='name', + ) + + self.basicfood = BasicFood.objects.create( + name='basicfood', + owner_id=1, + expiry_date=timezone.now(), + is_ready=False, + date_type='DLC', + ) + + self.transformedfood = TransformedFood.objects.create( + name='transformedfood', + owner_id=1, + expiry_date=timezone.now(), + is_ready=False, + ) + + self.qrcode = QRCode.objects.create( + qr_code_number=1, + food_container=self.basicfood, + ) + + def test_allergen_api(self): + """ + Load Allergen API page and test all filters and permissions + """ + self.check_viewset(AllergenViewSet, '/api/food/allergen/') + + def test_basicfood_api(self): + """ + Load BasicFood API page and test all filters and permissions + """ + self.check_viewset(BasicFoodViewSet, '/api/food/basicfood/') + + def test_transformedfood_api(self): + """ + Load TransformedFood API page and test all filters and permissions + """ + self.check_viewset(TransformedFoodViewSet, '/api/food/transformedfood/') + + def test_qrcode_api(self): + """ + Load QRCode API page and test all filters and permissions + """ + self.check_viewset(QRCodeViewSet, '/api/food/qrcode/') diff --git a/apps/food/urls.py b/apps/food/urls.py new file mode 100644 index 00000000..8137a6f1 --- /dev/null +++ b/apps/food/urls.py @@ -0,0 +1,20 @@ +# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.urls import path + +from . import views + +app_name = 'food' + +urlpatterns = [ + path('', views.FoodListView.as_view(), name='food_list'), + path('', views.QRCodeCreateView.as_view(), name='qrcode_create'), + path('/add/basic', views.BasicFoodCreateView.as_view(), name='basicfood_create'), + path('add/transformed', views.TransformedFoodCreateView.as_view(), name='transformedfood_create'), + path('update/', views.FoodUpdateView.as_view(), name='food_update'), + path('detail/', views.FoodDetailView.as_view(), name='food_view'), + path('detail/basic/', views.BasicFoodDetailView.as_view(), name='basicfood_view'), + path('detail/transformed/', views.TransformedFoodDetailView.as_view(), name='transformedfood_view'), + path('add/ingredient/', views.AddIngredientView.as_view(), name='add_ingredient'), +] diff --git a/apps/food/utils.py b/apps/food/utils.py new file mode 100644 index 00000000..a08d949a --- /dev/null +++ b/apps/food/utils.py @@ -0,0 +1,53 @@ +# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.utils.translation import gettext_lazy as _ + +seconds = (_('second'), _('seconds')) +minutes = (_('minute'), _('minutes')) +hours = (_('hour'), _('hours')) +days = (_('day'), _('days')) +weeks = (_('week'), _('weeks')) + + +def plural(x): + if x == 1: + return 0 + return 1 + + +def pretty_duration(duration): + """ + I receive datetime.timedelta object + You receive string object + """ + text = [] + sec = duration.seconds + d = duration.days + + if d >= 7: + w = d // 7 + text.append(str(w) + ' ' + weeks[plural(w)]) + d -= w * 7 + if d > 0: + text.append(str(d) + ' ' + days[plural(d)]) + + if sec >= 3600: + h = sec // 3600 + text.append(str(h) + ' ' + hours[plural(h)]) + sec -= h * 3600 + + if sec >= 60: + m = sec // 60 + text.append(str(m) + ' ' + minutes[plural(m)]) + sec -= m * 60 + + if sec > 0: + text.append(str(sec) + ' ' + seconds[plural(sec)]) + + if len(text) == 0: + return '' + if len(text) == 1: + return text[0] + if len(text) >= 2: + return ', '.join(t for t in text[:-1]) + ' ' + _('and') + ' ' + text[-1] diff --git a/apps/food/views.py b/apps/food/views.py new file mode 100644 index 00000000..a0efd6df --- /dev/null +++ b/apps/food/views.py @@ -0,0 +1,402 @@ +# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from datetime import timedelta + +from api.viewsets import is_regex +from django_tables2.views import MultiTableMixin +from django.db import transaction +from django.db.models import Q +from django.http import HttpResponseRedirect +from django.views.generic import DetailView, UpdateView +from django.views.generic.list import ListView +from django.urls import reverse_lazy +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from member.models import Club, Membership +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 .tables import FoodTable +from .utils import pretty_duration + + +class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView): + """ + Display Food + """ + model = Food + tables = [FoodTable, FoodTable, FoodTable, ] + extra_context = {"title": _('Food')} + template_name = 'food/food_list.html' + + def get_queryset(self, **kwargs): + return super().get_queryset(**kwargs).distinct() + + def get_tables(self): + bureau_role_pk = 4 + clubs = Club.objects.filter(membership__in=Membership.objects.filter( + user=self.request.user, roles=bureau_role_pk).filter( + date_end__gte=timezone.now())) + + tables = [FoodTable] * (clubs.count() + 3) + self.tables = tables + tables = super().get_tables() + tables[0].prefix = 'search-' + tables[1].prefix = 'open-' + tables[2].prefix = 'served-' + for i in range(clubs.count()): + tables[i + 3].prefix = clubs[i].name + return tables + + def get_tables_data(self): + # table search + qs = self.get_queryset().order_by('name') + if "search" in self.request.GET and self.request.GET['search']: + pattern = self.request.GET['search'] + + # check regex + valid_regex = is_regex(pattern) + suffix = '__iregex' if valid_regex else '__istartswith' + prefix = '^' if valid_regex else '' + qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern})) + else: + qs = qs.none() + search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Food, 'view')) + # table open + open_table = self.get_queryset().order_by('expiry_date').filter( + Q(polymorphic_ctype__model='transformedfood') + | Q(polymorphic_ctype__model='basicfood', basicfood__date_type='DLC')).filter( + expiry_date__lt=timezone.now()).filter( + PermissionBackend.filter_queryset(self.request, Food, 'view')) + # table served + served_table = self.get_queryset().order_by('-pk').filter( + end_of_life='', is_ready=True) + # tables club + bureau_role_pk = 4 + clubs = Club.objects.filter(membership__in=Membership.objects.filter( + user=self.request.user, roles=bureau_role_pk).filter( + date_end__gte=timezone.now())) + club_table = [] + for club in clubs: + club_table.append(self.get_queryset().order_by('expiry_date').filter( + owner=club, end_of_life='').filter( + PermissionBackend.filter_queryset(self.request, Food, 'view') + )) + return [search_table, open_table, served_table] + club_table + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + tables = context['tables'] + # for extends base_search.html we need to name 'search_table' in 'table' + for name, table in zip(['table', 'open', 'served'], tables): + context[name] = table + context['club_tables'] = tables[3:] + + context['can_add_meal'] = PermissionBackend.check_perm(self.request, 'food.transformedfood_add') + return context + + +class QRCodeCreateView(ProtectQuerysetMixin, ProtectedCreateView): + """ + A view to add qrcode + """ + model = QRCode + template_name = 'food/qrcode.html' + form_class = QRCodeForms + extra_context = {"title": _("Add a new QRCode")} + + def get(self, *args, **kwargs): + qrcode = kwargs["slug"] + if self.model.objects.filter(qr_code_number=qrcode).count() > 0: + pk = self.model.objects.get(qr_code_number=qrcode).food_container.pk + return HttpResponseRedirect(reverse_lazy("food:food_view", kwargs={"pk": pk})) + else: + return super().get(*args, **kwargs) + + @transaction.atomic + def form_valid(self, form): + qrcode_food_form = QRCodeForms(data=self.request.POST) + if not qrcode_food_form.is_valid(): + return self.form_invalid(form) + + qrcode = form.save(commit=False) + qrcode.qr_code_number = self.kwargs['slug'] + qrcode._force_save = True + qrcode.save() + qrcode.refresh_from_db() + return super().form_valid(form) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['slug'] = self.kwargs['slug'] + + # get last 10 BasicFood objects with distincts 'name' ordered by '-pk' + # we can't use .distinct and .order_by with differents columns hence the generator + context['last_items'] = [food for food in BasicFood.get_lastests_objects(10, 'name', '-pk')] + return context + + def get_success_url(self, **kwargs): + self.object.refresh_from_db() + return reverse_lazy('food:food_view', kwargs={'pk': self.object.food_container.pk}) + + def get_sample_object(self): + return QRCode( + qr_code_number=self.kwargs['slug'], + food_container_id=1, + ) + + +class BasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView): + """ + A view to add basicfood + """ + model = BasicFood + form_class = BasicFoodForms + extra_context = {"title": _("Add an aliment")} + template_name = "food/food_update.html" + + def get_sample_object(self): + return BasicFood( + name="", + owner_id=1, + expiry_date=timezone.now(), + is_ready=True, + arrival_date=timezone.now(), + date_type='DLC', + ) + + @transaction.atomic + def form_valid(self, form): + if QRCode.objects.filter(qr_code_number=self.kwargs['slug']).count() > 0: + return HttpResponseRedirect(reverse_lazy('food:qrcode_create', kwargs={'slug': self.kwargs['slug']})) + food_form = BasicFoodForms(data=self.request.POST) + if not food_form.is_valid(): + return self.form_invalid(form) + + food = form.save(commit=False) + food.is_ready = False + food.save() + food.refresh_from_db() + + qrcode = QRCode() + qrcode.qr_code_number = self.kwargs['slug'] + qrcode.food_container = food + qrcode.save() + + return super().form_valid(form) + + def get_success_url(self, **kwargs): + self.object.refresh_from_db() + return reverse_lazy('food:basicfood_view', kwargs={"pk": self.object.pk}) + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + + copy = self.request.GET.get('copy', None) + if copy is not None: + food = BasicFood.objects.get(pk=copy) + print(context['form'].fields) + for field in context['form'].fields: + if field == 'allergens': + context['form'].fields[field].initial = getattr(food, field).all() + else: + context['form'].fields[field].initial = getattr(food, field) + + return context + + +class TransformedFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView): + """ + A view to add transformedfood + """ + model = TransformedFood + form_class = TransformedFoodForms + extra_context = {"title": _("Add a meal")} + template_name = "food/food_update.html" + + def get_sample_object(self): + return TransformedFood( + name="", + owner_id=1, + expiry_date=timezone.now(), + is_ready=True, + ) + + @transaction.atomic + def form_valid(self, form): + form.instance.expiry_date = timezone.now() + timedelta(days=3) + form.instance.is_ready = False + return super().form_valid(form) + + 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): + """ + A view to add ingredient to a meal + """ + model = Food + extra_context = {"title": _("Add the ingredient:")} + form_class = AddIngredientForms + template_name = 'food/food_update.html' + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + context['title'] += ' ' + self.object.name + return context + + @transaction.atomic + def form_valid(self, form): + meals = TransformedFood.objects.filter(pk__in=form.data.getlist('ingredients')).all() + for meal in meals: + old_ingredients = list(meal.ingredients.all()).copy() + old_allergens = list(meal.allergens.all()).copy() + meal.ingredients.add(self.object.pk) + # update allergen and expiry date if necessary + if not (self.object.polymorphic_ctype.model == 'basicfood' + and self.object.date_type == 'DDM'): + meal.expiry_date = min(meal.expiry_date, self.object.expiry_date) + meal.allergens.set(meal.allergens.union(self.object.allergens.all())) + meal.save(old_ingredients=old_ingredients, old_allergens=old_allergens) + if 'fully_used' in form.data: + if not self.object.end_of_life: + self.object.end_of_life = _(f'Food fully used in : {meal.name}') + else: + self.object.end_of_life += ', ' + meal.name + if 'fully_used' in form.data: + self.object.is_ready = False + self.object.save() + # We redirect only the first parent + parent_pk = meals[0].pk + return HttpResponseRedirect(self.get_success_url(parent_pk=parent_pk)) + + def get_success_url(self, **kwargs): + return reverse_lazy('food:transformedfood_view', kwargs={"pk": kwargs['parent_pk']}) + + +class FoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): + """ + A view to update Food + """ + model = Food + extra_context = {"title": _("Update an aliment")} + template_name = 'food/food_update.html' + + @transaction.atomic + def form_valid(self, form): + form.instance.creater = self.request.user + food = Food.objects.get(pk=self.kwargs['pk']) + old_allergens = list(food.allergens.all()).copy() + + if food.polymorphic_ctype.model == 'transformedfood': + old_ingredients = food.ingredients.all() + form.instance.shelf_life = timedelta( + seconds=int(form.data['shelf_life']) * 60 * 60) + + food_form = self.get_form_class()(data=self.request.POST) + if not food_form.is_valid(): + return self.form_invalid(form) + ans = super().form_valid(form) + if food.polymorphic_ctype.model == 'transformedfood': + form.instance.save(old_ingredients=old_ingredients) + else: + form.instance.save(old_allergens=old_allergens) + return ans + + def get_form_class(self, **kwargs): + food = Food.objects.get(pk=self.kwargs['pk']) + if food.polymorphic_ctype.model == 'basicfood': + return BasicFoodUpdateForms + else: + return TransformedFoodUpdateForms + + def get_form(self, **kwargs): + form = super().get_form(**kwargs) + if 'shelf_life' in form.initial: + hours = form.initial['shelf_life'].days * 24 + form.initial['shelf_life'].seconds // 3600 + form.initial['shelf_life'] = hours + return form + + def get_success_url(self, **kwargs): + self.object.refresh_from_db() + return reverse_lazy('food:food_view', kwargs={"pk": self.object.pk}) + + +class FoodDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): + """ + A view to see a food + """ + model = Food + extra_context = {"title": _('Details of:')} + context_object_name = "food" + template_name = "food/food_detail.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + fields = ["name", "owner", "expiry_date", "allergens", "is_ready", "end_of_life", "order"] + + fields = dict([(field, getattr(self.object, field)) for field in fields]) + if fields["is_ready"]: + fields["is_ready"] = _("Yes") + else: + fields["is_ready"] = _("No") + fields["allergens"] = ", ".join( + allergen.name for allergen in fields["allergens"].all()) + + context["fields"] = [( + Food._meta.get_field(field).verbose_name.capitalize(), + value) for field, value in fields.items()] + context["meals"] = self.object.transformed_ingredient_inv.all() + context["update"] = PermissionBackend.check_perm(self.request, "food.change_food") + context["add_ingredient"] = self.object.end_of_life = '' and PermissionBackend.check_perm(self.request, "food.change_transformedfood") + return context + + def get(self, *args, **kwargs): + model = Food.objects.get(pk=kwargs['pk']).polymorphic_ctype.model + if 'stop_redirect' in kwargs and kwargs['stop_redirect']: + return super().get(*args, **kwargs) + kwargs = {'pk': kwargs['pk']} + if model == 'basicfood': + return HttpResponseRedirect(reverse_lazy("food:basicfood_view", kwargs=kwargs)) + return HttpResponseRedirect(reverse_lazy("food:transformedfood_view", kwargs=kwargs)) + + +class BasicFoodDetailView(FoodDetailView): + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + fields = ['arrival_date', 'date_type'] + for field in fields: + context["fields"].append(( + BasicFood._meta.get_field(field).verbose_name.capitalize(), + getattr(self.object, field) + )) + return context + + def get(self, *args, **kwargs): + kwargs['stop_redirect'] = (Food.objects.get(pk=kwargs['pk']).polymorphic_ctype.model == 'basicfood') + return super().get(*args, **kwargs) + + +class TransformedFoodDetailView(FoodDetailView): + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["fields"].append(( + TransformedFood._meta.get_field("creation_date").verbose_name.capitalize(), + self.object.creation_date + )) + context["fields"].append(( + TransformedFood._meta.get_field("shelf_life").verbose_name.capitalize(), + pretty_duration(self.object.shelf_life) + )) + context["foods"] = self.object.ingredients.all() + return context + + def get(self, *args, **kwargs): + kwargs['stop_redirect'] = (Food.objects.get(pk=kwargs['pk']).polymorphic_ctype.model == 'transformedfood') + return super().get(*args, **kwargs) diff --git a/apps/permission/fixtures/initial.json b/apps/permission/fixtures/initial.json index dc2ca4c0..e6433f82 100644 --- a/apps/permission/fixtures/initial.json +++ b/apps/permission/fixtures/initial.json @@ -3346,7 +3346,7 @@ "food", "transformedfood" ], - "query": "{\"is_ready\": true, \"is_active\": true, \"was_eaten\": false}", + "query": "{\"is_ready\": true}", "type": "view", "mask": 1, "field": "", @@ -3426,7 +3426,7 @@ "food", "basicfood" ], - "query": "{\"is_active\": true}", + "query": "{\"is_ready\": true}", "type": "view", "mask": 3, "field": "", @@ -3442,7 +3442,7 @@ "food", "basicfood" ], - "query": "{\"is_active\": true, \"owner\": [\"club\"]}", + "query": "{\"is_ready\": true, \"owner\": [\"club\"]}", "type": "view", "mask": 3, "field": "", @@ -3474,7 +3474,7 @@ "food", "basicfood" ], - "query": "{\"is_active\": true, \"was_eaten\": false}", + "query": "{\"is_ready\": true}", "type": "change", "mask": 3, "field": "allergens", @@ -3490,7 +3490,7 @@ "food", "basicfood" ], - "query": "{\"is_active\": true, \"was_eaten\": false, \"owner\": [\"club\"]}", + "query": "{\"is_ready\": true, \"owner\": [\"club\"]}", "type": "change", "mask": 3, "field": "allergens", @@ -3554,10 +3554,10 @@ "food", "transformedfood" ], - "query": "{\"is_active\": true}", + "query": "{\"is_ready\": true}", "type": "change", "mask": 3, - "field": "was_eaten", + "field": "end_of_life", "permanent": false, "description": "Indiquer si un plat a été mangé" } @@ -3570,7 +3570,7 @@ "food", "transformedfood" ], - "query": "{\"is_active\": true, \"owner\": [\"club\"]}", + "query": "{\"is_ready\": true, \"owner\": [\"club\"]}", "type": "change", "mask": 3, "field": "is_ready", @@ -3586,10 +3586,10 @@ "food", "transformedfood" ], - "query": "{\"is_active\": true}", + "query": "{\"is_ready\": true}", "type": "change", "mask": 3, - "field": "is_active", + "field": "is_ready", "permanent": false, "description": "Archiver un plat" } @@ -3602,30 +3602,14 @@ "food", "basicfood" ], - "query": "{\"is_active\": true}", + "query": "{\"is_ready\": true}", "type": "change", "mask": 3, - "field": "is_active", + "field": "is_ready", "permanent": false, "description": "Archiver de la bouffe" } }, - { - "model": "permission.permission", - "pk": 230, - "fields": { - "model": [ - "food", - "transformedfood" - ], - "query": "{\"is_active\": true}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir tout les plats actifs" - } - }, { "model": "permission.permission", "pk": 231, @@ -3650,7 +3634,7 @@ "food", "qrcode" ], - "query": "{\"food_container__is_active\": true}", + "query": "{\"food_container__is_ready\": true}", "type": "view", "mask": 3, "field": "", @@ -3666,7 +3650,7 @@ "food", "qrcode" ], - "query": "{\"food_container__owner\": [\"club\"], \"food_container__is_active\": true}", + "query": "{\"food_container__owner\": [\"club\"], \"food_container__is_ready\": true}", "type": "view", "mask": 3, "field": "", @@ -3682,7 +3666,7 @@ "food", "transformedfood" ], - "query": "{\"owner\": [\"club\"], \"is_active\": true}", + "query": "{\"owner\": [\"club\"]}", "type": "change", "mask": 3, "field": "ingredients", @@ -3714,7 +3698,7 @@ "food", "food" ], - "query": "{\"is_active\": true}", + "query": "[]", "type": "view", "mask": 3, "field": "", @@ -3730,7 +3714,7 @@ "food", "food" ], - "query": "{\"is_active\": true, \"owner\": [\"club\"]}", + "query": "{\"owner\": [\"club\"]}", "type": "view", "mask": 3, "field": "", @@ -4601,7 +4585,6 @@ 227, 228, 229, - 230, 232, 234, 236