diff --git a/apps/food/admin.py b/apps/food/admin.py index 89f042e1..613ebade 100644 --- a/apps/food/admin.py +++ b/apps/food/admin.py @@ -2,36 +2,58 @@ # SPDX-License-Identifier: GPL-3.0-or-later from django.contrib import admin -from django.db import transaction +from polymorphic.admin import PolymorphicChildModelAdmin, PolymorphicParentModelAdmin from note_kfet.admin import admin_site -from .models import Allergen, BasicFood, QRCode, TransformedFood - - -@admin.register(QRCode, site=admin_site) -class QRCodeAdmin(admin.ModelAdmin): - pass - - -@admin.register(BasicFood, site=admin_site) -class BasicFoodAdmin(admin.ModelAdmin): - @transaction.atomic - def save_related(self, *args, **kwargs): - ans = super().save_related(*args, **kwargs) - args[1].instance.update() - return ans - - -@admin.register(TransformedFood, site=admin_site) -class TransformedFoodAdmin(admin.ModelAdmin): - exclude = ["allergens", "expiry_date"] - - @transaction.atomic - def save_related(self, request, form, *args, **kwargs): - super().save_related(request, form, *args, **kwargs) - form.instance.update() +from .models import Allergen, Food, BasicFood, TransformedFood, QRCode @admin.register(Allergen, site=admin_site) class AllergenAdmin(admin.ModelAdmin): - pass + """ + 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/serializers.py b/apps/food/api/serializers.py index acac2ba9..fa0641e8 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, QRCode, TransformedFood +from ..models import Allergen, BasicFood, TransformedFood, QRCode class AllergenSerializer(serializers.ModelSerializer): @@ -11,7 +11,6 @@ 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__' @@ -22,29 +21,26 @@ 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 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__' - - 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 index 23c67bdd..5a8ce881 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, QRCodeViewSet, TransformedFoodViewSet +from .views import AllergenViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet def register_food_urls(router, path): @@ -9,6 +9,6 @@ def register_food_urls(router, path): Configure router for Food REST API. """ router.register(path + '/allergen', AllergenViewSet) - router.register(path + '/basic_food', BasicFoodViewSet) + router.register(path + '/basicfood', BasicFoodViewSet) + router.register(path + '/transformedfood', TransformedFoodViewSet) router.register(path + '/qrcode', QRCodeViewSet) - router.register(path + '/transformed_food', TransformedFoodViewSet) diff --git a/apps/food/api/views.py b/apps/food/api/views.py index af616074..2c75a570 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, QRCodeSerializer, TransformedFoodSerializer -from ..models import Allergen, BasicFood, QRCode, TransformedFood +from .serializers import AllergenSerializer, BasicFoodSerializer, TransformedFoodSerializer, QRCodeSerializer +from ..models import Allergen, BasicFood, TransformedFood, QRCode class AllergenViewSet(ReadProtectedModelViewSet): @@ -26,7 +26,7 @@ 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/basic_food/ + then render it on /api/food/basicfood/ """ queryset = BasicFood.objects.order_by('id') serializer_class = BasicFoodSerializer @@ -35,6 +35,19 @@ class BasicFoodViewSet(ReadProtectedModelViewSet): 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. @@ -46,16 +59,3 @@ class QRCodeViewSet(ReadProtectedModelViewSet): filter_backends = [DjangoFilterBackend, SearchFilter] filterset_fields = ['qr_code_number', ] search_fields = ['$qr_code_number', ] - - -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/transformed_food/ - """ - queryset = TransformedFood.objects.order_by('id') - serializer_class = TransformedFoodSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] - filterset_fields = ['name', ] - search_fields = ['$name', ] diff --git a/apps/food/fixtures/initial.json b/apps/food/fixtures/initial.json new file mode 100644 index 00000000..43a0ffe1 --- /dev/null +++ b/apps/food/fixtures/initial.json @@ -0,0 +1,100 @@ +[ + { + "model": "food.allergen", + "pk": 1, + "fields": { + "name": "Lait" + } + }, + { + "model": "food.allergen", + "pk": 2, + "fields": { + "name": "Oeufs" + } + }, + { + "model": "food.allergen", + "pk": 3, + "fields": { + "name": "Gluten" + } + }, + { + "model": "food.allergen", + "pk": 4, + "fields": { + "name": "Fruits à coques" + } + }, + { + "model": "food.allergen", + "pk": 5, + "fields": { + "name": "Arachides" + } + }, + { + "model": "food.allergen", + "pk": 6, + "fields": { + "name": "Sésame" + } + }, + { + "model": "food.allergen", + "pk": 7, + "fields": { + "name": "Soja" + } + }, + { + "model": "food.allergen", + "pk": 8, + "fields": { + "name": "Céléri" + } + }, + { + "model": "food.allergen", + "pk": 9, + "fields": { + "name": "Lupin" + } + }, + { + "model": "food.allergen", + "pk": 10, + "fields": { + "name": "Moutarde" + } + }, + { + "model": "food.allergen", + "pk": 11, + "fields": { + "name": "Sulfites" + } + }, + { + "model": "food.allergen", + "pk": 12, + "fields": { + "name": "Crustacés" + } + }, + { + "model": "food.allergen", + "pk": 13, + "fields": { + "name": "Mollusques" + } + }, + { + "model": "food.allergen", + "pk": 14, + "fields": { + "name": "Poissons" + } + } +] diff --git a/apps/food/forms.py b/apps/food/forms.py index af468c7f..c823b0b1 100644 --- a/apps/food/forms.py +++ b/apps/food/forms.py @@ -3,42 +3,42 @@ from random import shuffle -from django import forms -from django.utils.translation import gettext_lazy as _ -from django.utils import timezone -from member.models import Club 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, QRCode, TransformedFood +from .models import BasicFood, TransformedFood, QRCode -class AddIngredientForms(forms.ModelForm): +class QRCodeForms(forms.ModelForm): """ - Form for add an ingredient + Form for create QRCode for container """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['ingredient'].queryset = self.fields['ingredient'].queryset.filter( - polymorphic_ctype__model='transformedfood', + self.fields['food_container'].queryset = self.fields['food_container'].queryset.filter( is_ready=False, - is_active=True, - was_eaten=False, - ) - # Caution, the logic is inverted here, we flip the logic on saving in AddIngredientView - self.fields['is_active'].initial = True - self.fields['is_active'].label = _("Fully used") + end_of_life__isnull=True, + polymorphic_ctype__model='transformedfood', + ).filter(PermissionBackend.filter_queryset( + get_current_request(), + TransformedFood, + "view", + )) class Meta: - model = TransformedFood - fields = ('ingredient', 'is_active') + model = QRCode + fields = ('food_container',) class BasicFoodForms(forms.ModelForm): """ - Form for add non-transformed food + Form for add basicfood """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -51,64 +51,103 @@ class BasicFoodForms(forms.ModelForm): 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', 'is_active', 'was_eaten', 'allergens',) + fields = ('name', 'owner', 'date_type', 'expiry_date', 'allergens', 'order',) widgets = { "owner": Autocomplete( model=Club, attrs={"api_url": "/api/members/club/"}, ), - 'expiry_date': DateTimePickerInput(), + "expiry_date": DateTimePickerInput(), } -class QRCodeForms(forms.ModelForm): - """ - Form for create QRCode - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields['food_container'].queryset = self.fields['food_container'].queryset.filter( - is_active=True, - was_eaten=False, - polymorphic_ctype__model='transformedfood', - ) - - class Meta: - model = QRCode - fields = ('food_container',) - - class TransformedFoodForms(forms.ModelForm): """ - Form for add transformed food + Form for add transformedfood """ 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 - self.fields['creation_date'].required = True - self.fields['creation_date'].initial = timezone.now - self.fields['is_active'].initial = True - self.fields['is_ready'].initial = False - self.fields['was_eaten'].initial = False # 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', 'creation_date', 'owner', 'is_active', 'is_ready', 'was_eaten', 'shelf_life') + fields = ('name', 'owner', 'order',) widgets = { "owner": Autocomplete( model=Club, attrs={"api_url": "/api/members/club/"}, ), - 'creation_date': DateTimePickerInput(), } + + +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 index 011d0f3f..706a0590 100644 --- a/apps/food/migrations/0001_initial.py +++ b/apps/food/migrations/0001_initial.py @@ -1,84 +1,199 @@ -# Generated by Django 2.2.28 on 2024-07-05 08:57 +# 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', '0011_profile_vss_charter_read'), + ("contenttypes", "0002_remove_content_type_name"), + ("member", "0013_auto_20240801_1436"), ] operations = [ migrations.CreateModel( - name='Allergen', + 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')), + ( + "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', + "verbose_name": "Allergen", + "verbose_name_plural": "Allergens", }, ), migrations.CreateModel( - name='Food', + 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')), - ('was_eaten', models.BooleanField(default=False, verbose_name='was eaten')), - ('is_ready', models.BooleanField(default=False, verbose_name='is ready')), - ('allergens', models.ManyToManyField(blank=True, to='food.Allergen', verbose_name='allergen')), - ('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_food.food_set+', to='contenttypes.ContentType')), + ( + "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': 'foods', + "verbose_name": "Food", + "verbose_name_plural": "Foods", }, ), migrations.CreateModel( - name='BasicFood', + 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')), - ('date_type', models.CharField(choices=[('DLC', 'DLC'), ('DDM', 'DDM')], max_length=255)), - ('arrival_date', models.DateTimeField(default=django.utils.timezone.now, verbose_name='arrival date')), + ( + "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', + "verbose_name": "Basic food", + "verbose_name_plural": "Basic foods", }, - bases=('food.food',), + bases=("food.food",), ), migrations.CreateModel( - name='QRCode', + 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.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='QR_code', to='food.Food', verbose_name='food container')), + ( + "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', + "verbose_name": "QR-code", + "verbose_name_plural": "QR-codes", }, ), migrations.CreateModel( - name='TransformedFood', + 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(verbose_name='creation date')), - ('is_active', models.BooleanField(default=True, verbose_name='is active')), - ('ingredient', models.ManyToManyField(blank=True, related_name='transformed_ingredient_inv', to='food.Food', verbose_name='transformed ingredient')), + ( + "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', + "verbose_name": "Transformed food", + "verbose_name_plural": "Transformed foods", }, - bases=('food.food',), + bases=("food.food",), ), ] diff --git a/apps/food/migrations/0002_transformedfood_shelf_life.py b/apps/food/migrations/0002_transformedfood_shelf_life.py deleted file mode 100644 index 46673643..00000000 --- a/apps/food/migrations/0002_transformedfood_shelf_life.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.2.28 on 2024-07-06 20:37 - -import datetime -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('food', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='transformedfood', - name='shelf_life', - field=models.DurationField(default=datetime.timedelta(days=3), verbose_name='shelf life'), - ), - ] diff --git a/apps/food/migrations/0003_create_14_allergens_mandatory.py b/apps/food/migrations/0003_create_14_allergens_mandatory.py deleted file mode 100644 index 236eaea4..00000000 --- a/apps/food/migrations/0003_create_14_allergens_mandatory.py +++ /dev/null @@ -1,62 +0,0 @@ -from django.db import migrations - -def create_14_mandatory_allergens(apps, schema_editor): - """ - There are 14 mandatory allergens, they are pre-injected - """ - - Allergen = apps.get_model("food", "allergen") - - Allergen.objects.get_or_create( - name="Gluten", - ) - Allergen.objects.get_or_create( - name="Fruits à coques", - ) - Allergen.objects.get_or_create( - name="Crustacés", - ) - Allergen.objects.get_or_create( - name="Céléri", - ) - Allergen.objects.get_or_create( - name="Oeufs", - ) - Allergen.objects.get_or_create( - name="Moutarde", - ) - Allergen.objects.get_or_create( - name="Poissons", - ) - Allergen.objects.get_or_create( - name="Soja", - ) - Allergen.objects.get_or_create( - name="Lait", - ) - Allergen.objects.get_or_create( - name="Sulfites", - ) - Allergen.objects.get_or_create( - name="Sésame", - ) - Allergen.objects.get_or_create( - name="Lupin", - ) - Allergen.objects.get_or_create( - name="Arachides", - ) - Allergen.objects.get_or_create( - name="Mollusques", - ) - -class Migration(migrations.Migration): - dependencies = [ - ('food', '0002_transformedfood_shelf_life'), - ] - - operations = [ - migrations.RunPython(create_14_mandatory_allergens), - ] - - diff --git a/apps/food/migrations/0004_auto_20240813_2358.py b/apps/food/migrations/0004_auto_20240813_2358.py deleted file mode 100644 index d7fdf200..00000000 --- a/apps/food/migrations/0004_auto_20240813_2358.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 2.2.28 on 2024-08-13 21:58 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('food', '0003_create_14_allergens_mandatory'), - ] - - operations = [ - migrations.RemoveField( - model_name='transformedfood', - name='is_active', - ), - migrations.AddField( - model_name='food', - name='is_active', - field=models.BooleanField(default=True, verbose_name='is active'), - ), - migrations.AlterField( - model_name='qrcode', - name='food_container', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='QR_code', to='food.Food', verbose_name='food container'), - ), - ] diff --git a/apps/food/migrations/0005_alter_food_polymorphic_ctype.py b/apps/food/migrations/0005_alter_food_polymorphic_ctype.py deleted file mode 100644 index 5473bffc..00000000 --- a/apps/food/migrations/0005_alter_food_polymorphic_ctype.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 4.2.15 on 2024-08-28 08:00 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), - ('food', '0004_auto_20240813_2358'), - ] - - operations = [ - migrations.AlterField( - model_name='food', - name='polymorphic_ctype', - field=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'), - ), - ] diff --git a/apps/food/models.py b/apps/food/models.py index 199dcdd7..c0b25078 100644 --- a/apps/food/models.py +++ b/apps/food/models.py @@ -6,37 +6,13 @@ from datetime import timedelta from django.db import models, transaction from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from member.models import Club from polymorphic.models import PolymorphicModel - - -class QRCode(models.Model): - """ - An QRCode model - """ - qr_code_number = models.PositiveIntegerField( - verbose_name=_("QR-code number"), - unique=True, - ) - - 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 {qr_code_number}").format(qr_code_number=self.qr_code_number) +from member.models import Club class Allergen(models.Model): """ - A list of allergen and alimentary restrictions + Allergen and alimentary restrictions """ name = models.CharField( verbose_name=_('name'), @@ -44,16 +20,19 @@ class Allergen(models.Model): ) class Meta: - verbose_name = _('Allergen') - verbose_name_plural = _('Allergens') + 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'), + verbose_name=_("name"), max_length=255, ) @@ -67,7 +46,7 @@ class Food(PolymorphicModel): allergens = models.ManyToManyField( Allergen, blank=True, - verbose_name=_('allergen'), + verbose_name=_('allergens'), ) expiry_date = models.DateTimeField( @@ -75,41 +54,69 @@ class Food(PolymorphicModel): null=False, ) - was_eaten = models.BooleanField( - default=False, - verbose_name=_('was eaten'), + end_of_life = models.CharField( + blank=True, + verbose_name=_('end of life'), + max_length=255, ) - # is_ready != is_active : is_ready signifie que la nourriture est prête à être manger, - # is_active signifie que la nourriture n'est pas encore archivé - # il sert dans les cas où il est plus intéressant que de l'open soit conservé (confiture par ex) - is_ready = models.BooleanField( - default=False, verbose_name=_('is ready'), + max_length=255, ) - is_active = models.BooleanField( - default=True, - verbose_name=_('is active'), + order = models.CharField( + blank=True, + verbose_name=_('order'), + max_length=255, ) def __str__(self): return self.name @transaction.atomic - def save(self, force_insert=False, force_update=False, using=None, update_fields=None): - return super().save(force_insert, force_update, using, update_fields) + 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 = _('foods') + verbose_name = _('Food') + verbose_name_plural = _('Foods') class BasicFood(Food): """ - Food which has been directly buy on supermarket + 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=( @@ -118,50 +125,70 @@ class BasicFood(Food): ) ) - arrival_date = models.DateTimeField( - verbose_name=_('arrival date'), - default=timezone.now, - ) - - # label = models.ImageField( - # verbose_name=_('food label'), - # max_length=255, - # blank=False, - # null=False, - # upload_to='label/', - # ) - @transaction.atomic - def update_allergens(self): - # update parents - for parent in self.transformed_ingredient_inv.iterator(): - parent.update_allergens() + 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 - @transaction.atomic - def update_expiry_date(self): - # update parents - for parent in self.transformed_ingredient_inv.iterator(): - parent.update_expiry_date() + if ('old_allergens' in kwargs + and list(self.allergens.all()) != kwargs['old_allergens']): + self.update_allergens() - @transaction.atomic - def update(self): - self.update_allergens() - self.update_expiry_date() + # 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): """ - Transformed food are a mix between basic food and meal + A transformed food is a food with ingredients """ creation_date = models.DateTimeField( + default=timezone.now, verbose_name=_('creation date'), ) - ingredient = models.ManyToManyField( + # 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, @@ -169,58 +196,91 @@ class TransformedFood(Food): verbose_name=_('transformed ingredient'), ) - # Without microbiological analyzes, the storage time is 3 days - shelf_life = models.DurationField( - verbose_name=_("shelf life"), - default=timedelta(days=3), - ) + 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 archive(self): - # When a meal are archived, if it was eaten, update ingredient fully used for this meal - raise NotImplementedError + 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 - @transaction.atomic - def update_allergens(self): - # When allergens are changed, simply update the parents' allergens - old_allergens = list(self.allergens.all()) - self.allergens.clear() - for ingredient in self.ingredient.iterator(): - self.allergens.set(self.allergens.union(ingredient.allergens.all())) + # 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 - if old_allergens == list(self.allergens.all()): - return - super().save() + # 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() - # update parents - for parent in self.transformed_ingredient_inv.iterator(): - parent.update_allergens() + if created: + self.expiry_date = self.shelf_life + self.creation_date - @transaction.atomic - def update_expiry_date(self): - # When expiry_date is changed, simply update the parents' expiry_date - old_expiry_date = self.expiry_date - self.expiry_date = self.creation_date + self.shelf_life - for ingredient in self.ingredient.iterator(): - self.expiry_date = min(self.expiry_date, ingredient.expiry_date) + # We save here because we need pk for many-to-many relation + super().save(force_insert, force_update, using, update_fields) - if old_expiry_date == self.expiry_date: - return - super().save() - - # update parents - for parent in self.transformed_ingredient_inv.iterator(): - parent.update_expiry_date() - - @transaction.atomic - def update(self): - self.update_allergens() - self.update_expiry_date() - - @transaction.atomic - def save(self, *args, **kwargs): - super().save(*args, **kwargs) + 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 index 4ab15879..7789ad76 100644 --- a/apps/food/tables.py +++ b/apps/food/tables.py @@ -2,18 +2,20 @@ # SPDX-License-Identifier: GPL-3.0-or-later import django_tables2 as tables -from django_tables2 import A -from .models import TransformedFood +from .models import Food -class TransformedFoodTable(tables.Table): - name = tables.LinkColumn( - 'food:food_view', - args=[A('pk'), ], - ) - +class FoodTable(tables.Table): + """ + List all foods. + """ class Meta: - model = TransformedFood + model = Food template_name = 'django_tables2/bootstrap4.html' - fields = ('name', "owner", "allergens", "expiry_date") + 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/add_ingredient_form.html b/apps/food/templates/food/add_ingredient_form.html deleted file mode 100644 index 395928e4..00000000 --- a/apps/food/templates/food/add_ingredient_form.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends "base.html" %} -{% comment %} -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/basicfood_detail.html b/apps/food/templates/food/basicfood_detail.html deleted file mode 100644 index 846fadba..00000000 --- a/apps/food/templates/food/basicfood_detail.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends "base.html" %} -{% comment %} -SPDX-License-Identifier: GPL-3.0-or-later -{% endcomment %} -{% load i18n crispy_forms_tags %} - -{% block content %} -
-

- {{ title }} {{ food.name }} -

-
- - {% if can_update %} - {% trans 'Update' %} - {% endif %} - {% if can_add_ingredient %} - - {% trans 'Add to a meal' %} - - {% endif %} -
-
-{% endblock %} diff --git a/apps/food/templates/food/create_qrcode_form.html b/apps/food/templates/food/create_qrcode_form.html deleted file mode 100644 index 456b9970..00000000 --- a/apps/food/templates/food/create_qrcode_form.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends "base.html" %} -{% comment %} -SPDX-License-Identifier: GPL-3.0-or-later -{% endcomment %} -{% load render_table from django_tables2 %} -{% load i18n crispy_forms_tags %} - -{% block content %} -
-

- {{ title }} -

-
- - {% trans 'New basic food' %} - -
- {% csrf_token %} - {{ form|crispy }} - -
-
-

{% trans "Copy constructor" %}

- - - - - - - - - - - {% for basic in last_basic %} - - - - - - - {% endfor %} - -
- {% trans "Name" %} - - {% trans "Owner" %} - - {% trans "Arrival date" %} - - {% trans "Expiry date" %} -
{{ basic.name }}{{ basic.owner }}{{ basic.arrival_date }}{{ basic.expiry_date }}
-
-
-
-{% endblock %} 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 }} +

+
+ + {% 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/basicfood_form.html b/apps/food/templates/food/food_update.html similarity index 92% rename from apps/food/templates/food/basicfood_form.html rename to apps/food/templates/food/food_update.html index 6fe6f06f..67de3e27 100644 --- a/apps/food/templates/food/basicfood_form.html +++ b/apps/food/templates/food/food_update.html @@ -1,5 +1,6 @@ {% 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 %} 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/templates/food/qrcode_detail.html b/apps/food/templates/food/qrcode_detail.html deleted file mode 100644 index 6e3e8110..00000000 --- a/apps/food/templates/food/qrcode_detail.html +++ /dev/null @@ -1,39 +0,0 @@ -{% extends "base.html" %} -{% comment %} -SPDX-License-Identifier: GPL-3.0-or-later -{% endcomment %} -{% load i18n crispy_forms_tags %} - -{% block content %} -
-

- {{ title }} {% trans 'number' %} {{ qrcode.qr_code_number }} -

-
- - {% if qrcode.food_container.polymorphic_ctype.model == 'basicfood' and can_update_basic %} - - {% trans 'Update' %} - - {% elif can_update_transformed %} - - {% trans 'Update' %} - - {% endif %} - {% if can_view_detail %} - - {% trans 'View details' %} - - {% endif %} - {% if can_add_ingredient %} - - {% trans 'Add to a meal' %} - - {% endif %} -
-
-{% endblock %} diff --git a/apps/food/templates/food/transformedfood_detail.html b/apps/food/templates/food/transformedfood_detail.html deleted file mode 100644 index ca32bc06..00000000 --- a/apps/food/templates/food/transformedfood_detail.html +++ /dev/null @@ -1,51 +0,0 @@ -{% extends "base.html" %} -{% comment %} -SPDX-License-Identifier: GPL-3.0-or-later -{% endcomment %} -{% load i18n crispy_forms_tags %} - -{% block content %} -
-

- {{ title }} {{ food.name }} -

-
- - {% if can_update %} - - {% trans 'Update' %} - - {% endif %} - {% if can_add_ingredient %} - - {% trans 'Add to a meal' %} - - {% endif %} -
-
-{% endblock %} diff --git a/apps/food/templates/food/transformedfood_form.html b/apps/food/templates/food/transformedfood_form.html deleted file mode 100644 index 395928e4..00000000 --- a/apps/food/templates/food/transformedfood_form.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends "base.html" %} -{% comment %} -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/transformedfood_list.html b/apps/food/templates/food/transformedfood_list.html deleted file mode 100644 index 4416cdb7..00000000 --- a/apps/food/templates/food/transformedfood_list.html +++ /dev/null @@ -1,60 +0,0 @@ -{% extends "base.html" %} -{% comment %} -SPDX-License-Identifier: GPL-3.0-or-later -{% endcomment %} -{% load render_table from django_tables2 %} -{% load i18n %} - -{% block content %} -
-

- {% trans "Meal served" %} -

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

- {% trans "Open" %} -

- {% if open.data %} - {% render_table open %} - {% else %} -
-
- {% trans "There is no free meal." %} -
-
- {% endif %} -
- -
-

- {% trans "All meals" %} -

- {% if table.data %} - {% render_table table %} - {% else %} -
-
- {% trans "There is no meal." %} -
-
- {% endif %} -
-{% endblock %} diff --git a/apps/food/tests.py b/apps/food/tests.py deleted file mode 100644 index a79ca8be..00000000 --- a/apps/food/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -# from django.test import TestCase - -# Create your tests here. 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 index 59063cfe..8137a6f1 100644 --- a/apps/food/urls.py +++ b/apps/food/urls.py @@ -8,14 +8,13 @@ from . import views app_name = 'food' urlpatterns = [ - path('', views.TransformedListView.as_view(), name='food_list'), - path('', views.QRCodeView.as_view(), name='qrcode_view'), - path('detail/', views.FoodView.as_view(), name='food_view'), - - path('/create_qrcode', views.QRCodeCreateView.as_view(), name='qrcode_create'), - path('/create_qrcode/basic', views.QRCodeBasicFoodCreateView.as_view(), name='qrcode_basic_create'), - path('create/transformed', views.TransformedFoodCreateView.as_view(), name='transformed_create'), - path('update/basic/', views.BasicFoodUpdateView.as_view(), name='basic_update'), - path('update/transformed/', views.TransformedFoodUpdateView.as_view(), name='transformed_update'), - path('add/', views.AddIngredientView.as_view(), name='add_ingredient'), + 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 index 8c63530c..96c6b89e 100644 --- a/apps/food/views.py +++ b/apps/food/views.py @@ -1,421 +1,408 @@ # Copyright (C) 2018-2025 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -from django.db import transaction -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpResponseRedirect +from datetime import timedelta + +from api.viewsets import is_regex from django_tables2.views import MultiTableMixin -from django.urls import reverse -from django.utils.translation import gettext_lazy as _ -from django.utils import timezone +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.forms import HiddenInput +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 +from permission.views import ProtectQuerysetMixin, ProtectedCreateView, LoginRequiredMixin -from .forms import AddIngredientForms, BasicFoodForms, QRCodeForms, TransformedFoodForms -from .models import BasicFood, Food, QRCode, TransformedFood -from .tables import TransformedFoodTable +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 AddIngredientView(ProtectQuerysetMixin, UpdateView): +class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView): """ - A view to add an ingredient + Display Food """ model = Food - template_name = 'food/add_ingredient_form.html' - extra_context = {"title": _("Add the ingredient")} - form_class = AddIngredientForms + tables = [FoodTable, FoodTable, FoodTable, ] + extra_context = {"title": _('Food')} + template_name = 'food/food_list.html' - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["pk"] = self.kwargs["pk"] - return context + def get_queryset(self, **kwargs): + return super().get_queryset(**kwargs).distinct() - @transaction.atomic - def form_valid(self, form): - form.instance.creater = self.request.user - food = Food.objects.get(pk=self.kwargs['pk']) - add_ingredient_form = AddIngredientForms(data=self.request.POST) - if food.is_ready: - form.add_error(None, _("The product is already prepared")) - return self.form_invalid(form) - if not add_ingredient_form.is_valid(): - return self.form_invalid(form) + 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())) - # We flip logic ""fully used = not is_active"" - food.is_active = not food.is_active - # Save the aliment and the allergens associed - for transformed_pk in self.request.POST.getlist('ingredient'): - transformed = TransformedFood.objects.get(pk=transformed_pk) - if not transformed.is_ready: - transformed.ingredient.add(food) - transformed.update() - food.save() + 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 - return HttpResponseRedirect(self.get_success_url()) + 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'] - def get_success_url(self, **kwargs): - return reverse('food:food_list') - - -class BasicFoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): - """ - A view to update a basic food - """ - model = BasicFood - form_class = BasicFoodForms - template_name = 'food/basicfood_form.html' - extra_context = {"title": _("Update an aliment")} - - @transaction.atomic - def form_valid(self, form): - form.instance.creater = self.request.user - basic_food_form = BasicFoodForms(data=self.request.POST) - if not basic_food_form.is_valid(): - return self.form_invalid(form) - - ans = super().form_valid(form) - form.instance.update() - return ans - - def get_success_url(self, **kwargs): - self.object.refresh_from_db() - return reverse('food:food_view', kwargs={"pk": self.object.pk}) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - return context - - -class FoodView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): - """ - A view to see a food - """ - model = Food - extra_context = {"title": _("Details of:")} - context_object_name = "food" + # 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).filter( + Q(polymorphic_ctype__model='basicfood', + basicfood__date_type='DLC', + expiry_date__lte=timezone.now(),) + | Q(polymorphic_ctype__model='transformedfood', + expiry_date__lte=timezone.now(), + )) + # 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) - context["can_update"] = PermissionBackend.check_perm(self.request, "food.change_food") - context["can_add_ingredient"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood") - return context - - -class QRCodeBasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView): - ##################################################################### - # TO DO - # - this feature is very pratical for meat or fish, nevertheless we can implement this later - # - fix picture save - # - implement solution crop and convert image (reuse or recode ImageForm from members apps) - ##################################################################### - """ - A view to add a basic food with a qrcode - """ - model = BasicFood - form_class = BasicFoodForms - template_name = 'food/basicfood_form.html' - extra_context = {"title": _("Add a new basic food with QRCode")} - - @transaction.atomic - def form_valid(self, form): - form.instance.creater = self.request.user - basic_food_form = BasicFoodForms(data=self.request.POST) - if not basic_food_form.is_valid(): - return self.form_invalid(form) - - # Save the aliment and the allergens associed - basic_food = form.save(commit=False) - # We assume the date of labeling and the same as the date of arrival - basic_food.arrival_date = timezone.now() - basic_food.is_ready = False - basic_food.is_active = True - basic_food.was_eaten = False - basic_food._force_save = True - basic_food.save() - basic_food.refresh_from_db() - - qrcode = QRCode() - qrcode.qr_code_number = self.kwargs['slug'] - qrcode.food_container = basic_food - qrcode.save() - - return super().form_valid(form) - - def get_success_url(self, **kwargs): - self.object.refresh_from_db() - return reverse('food:qrcode_view', kwargs={"slug": self.kwargs['slug']}) - - def get_sample_object(self): - - # We choose a club which may work or BDE else - owner_id = 1 - for membership in self.request.user.memberships.all(): - club_id = membership.club.id - food = BasicFood(name="", expiry_date=timezone.now(), owner_id=club_id) - if PermissionBackend.check_perm(self.request, "food.add_basicfood", food): - owner_id = club_id - - return BasicFood( - name="", - expiry_date=timezone.now(), - owner_id=owner_id, - ) - - def get_context_data(self, **kwargs): - # Some field are hidden on create - context = super().get_context_data(**kwargs) - - form = context['form'] - form.fields['is_active'].widget = HiddenInput() - form.fields['was_eaten'].widget = HiddenInput() - - copy = self.request.GET.get('copy', None) - if copy is not None: - basic = BasicFood.objects.get(pk=copy) - for field in ['date_type', 'expiry_date', 'name', 'owner']: - form.fields[field].initial = getattr(basic, field) - for field in ['allergens']: - form.fields[field].initial = getattr(basic, field).all() + 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 a new qrcode + A view to add qrcode """ model = QRCode - template_name = 'food/create_qrcode_form.html' + 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: - return HttpResponseRedirect(reverse("food:qrcode_view", kwargs=kwargs)) + 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) - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["slug"] = self.kwargs["slug"] - - context["last_basic"] = BasicFood.objects.order_by('-pk').all()[:10] - - return context - @transaction.atomic def form_valid(self, form): - form.instance.creater = self.request.user qrcode_food_form = QRCodeForms(data=self.request.POST) if not qrcode_food_form.is_valid(): return self.form_invalid(form) - # Save the qrcode qrcode = form.save(commit=False) - qrcode.qr_code_number = self.kwargs["slug"] + qrcode.qr_code_number = self.kwargs['slug'] qrcode._force_save = True qrcode.save() qrcode.refresh_from_db() + return super().form_valid(form) - qrcode.food_container.save() + 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('food:qrcode_view', kwargs={"slug": self.kwargs['slug']}) + return reverse_lazy('food:basicfood_view', kwargs={"pk": self.object.pk}) - def get_sample_object(self): - return QRCode( - qr_code_number=self.kwargs["slug"], - food_container_id=1 - ) + 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) -class QRCodeView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): - """ - A view to see a qrcode - """ - model = QRCode - extra_context = {"title": _("QRCode")} - context_object_name = "qrcode" - slug_field = "qr_code_number" - - def get(self, *args, **kwargs): - qrcode = kwargs["slug"] - if self.model.objects.filter(qr_code_number=qrcode).count() > 0: - return super().get(*args, **kwargs) - else: - return HttpResponseRedirect(reverse("food:qrcode_create", kwargs=kwargs)) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - qr_code_number = self.kwargs['slug'] - qrcode = self.model.objects.get(qr_code_number=qr_code_number) - - model = qrcode.food_container.polymorphic_ctype.model - - if model == "basicfood": - context["can_update_basic"] = PermissionBackend.check_perm(self.request, "food.change_basicfood") - context["can_view_detail"] = PermissionBackend.check_perm(self.request, "food.view_basicfood") - if model == "transformedfood": - context["can_update_transformed"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood") - context["can_view_detail"] = PermissionBackend.check_perm(self.request, "food.view_transformedfood") - context["can_add_ingredient"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood") return context class TransformedFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView): """ - A view to add a tranformed food + A view to add transformedfood """ model = TransformedFood - template_name = 'food/transformedfood_form.html' form_class = TransformedFoodForms - extra_context = {"title": _("Add a new meal")} - - @transaction.atomic - def form_valid(self, form): - form.instance.creater = self.request.user - transformed_food_form = TransformedFoodForms(data=self.request.POST) - if not transformed_food_form.is_valid(): - return self.form_invalid(form) - - # Save the aliment and allergens associated - transformed_food = form.save(commit=False) - transformed_food.expiry_date = transformed_food.creation_date - transformed_food.is_active = True - transformed_food.is_ready = False - transformed_food.was_eaten = False - transformed_food._force_save = True - transformed_food.save() - transformed_food.refresh_from_db() - ans = super().form_valid(form) - transformed_food.update() - return ans - - def get_success_url(self, **kwargs): - self.object.refresh_from_db() - return reverse('food:food_view', kwargs={"pk": self.object.pk}) + extra_context = {"title": _("Add a meal")} + template_name = "food/food_update.html" def get_sample_object(self): - # We choose a club which may work or BDE else - owner_id = 1 - for membership in self.request.user.memberships.all(): - club_id = membership.club.id - food = TransformedFood(name="", - creation_date=timezone.now(), - expiry_date=timezone.now(), - owner_id=club_id) - if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food): - owner_id = club_id - break - return TransformedFood( name="", - owner_id=owner_id, - creation_date=timezone.now(), + owner_id=1, expiry_date=timezone.now(), + is_ready=True, ) - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) + @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) - # Some field are hidden on create - form = context['form'] - form.fields['is_active'].widget = HiddenInput() - form.fields['is_ready'].widget = HiddenInput() - form.fields['was_eaten'].widget = HiddenInput() - form.fields['shelf_life'].widget = HiddenInput() + 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)) -class TransformedFoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): + 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 transformed product + A view to update Food """ - model = TransformedFood - template_name = 'food/transformedfood_form.html' - form_class = TransformedFoodForms - extra_context = {'title': _('Update a meal')} + 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 - transformedfood_form = TransformedFoodForms(data=self.request.POST) - if not transformedfood_form.is_valid(): - return self.form_invalid(form) + 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) - form.instance.update() + 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('food:food_view', kwargs={"pk": self.object.pk}) + 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 TransformedListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView): - """ - Displays ready TransformedFood - """ - model = TransformedFood - tables = [TransformedFoodTable, TransformedFoodTable, TransformedFoodTable] - extra_context = {"title": _("Transformed food")} - - def get_queryset(self, **kwargs): - return super().get_queryset(**kwargs).distinct() - - def get_tables(self): - tables = super().get_tables() - - tables[0].prefix = "all-" - tables[1].prefix = "open-" - tables[2].prefix = "served-" - return tables - - def get_tables_data(self): - # first table = all transformed food, second table = free, third = served - return [ - self.get_queryset().order_by("-creation_date"), - TransformedFood.objects.filter(is_ready=True, is_active=True, was_eaten=False, expiry_date__lt=timezone.now()) - .filter(PermissionBackend.filter_queryset(self.request, TransformedFood, "view")) - .distinct() - .order_by("-creation_date"), - TransformedFood.objects.filter(is_ready=True, is_active=True, was_eaten=False, expiry_date__gte=timezone.now()) - .filter(PermissionBackend.filter_queryset(self.request, TransformedFood, "view")) - .distinct() - .order_by("-creation_date") - ] +class BasicFoodDetailView(FoodDetailView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - - # We choose a club which should work - for membership in self.request.user.memberships.all(): - club_id = membership.club.id - food = TransformedFood( - name="", - owner_id=club_id, - creation_date=timezone.now(), - expiry_date=timezone.now(), - ) - if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food): - context['can_create_meal'] = True - break - - tables = context["tables"] - for name, table in zip(["table", "open", "served"], tables): - context[name] = table + 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..f1f01dcc 100644 --- a/apps/permission/fixtures/initial.json +++ b/apps/permission/fixtures/initial.json @@ -3307,452 +3307,184 @@ } }, { - "model": "permission.permission", - "pk": 211, - "fields": { - "model": [ - "food", - "transformedfood" - ], - "query": "{}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir tout les plats" - } + "model": "permission.permission", + "pk": 211, + "fields": { + "model": [ + "food", + "qrcode" + ], + "query": "{}", + "type": "view", + "mask": 2, + "permanent": false, + "description": "Voir n'importe quel QR-code" + } }, { - "model": "permission.permission", - "pk": 212, - "fields": { - "model": [ - "food", - "transformedfood" - ], - "query": "{\"owner\": [\"club\"]}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir tout les plats de son club" - } + "model": "permission.permission", + "pk": 212, + "fields": { + "model": [ + "food", + "allergen" + ], + "query": "{}", + "type": "view", + "mask": 1, + "permanent": false, + "description": "Voir n'importe quel allergène" + } }, { - "model": "permission.permission", - "pk": 213, - "fields": { - "model": [ - "food", - "transformedfood" - ], - "query": "{\"is_ready\": true, \"is_active\": true, \"was_eaten\": false}", - "type": "view", - "mask": 1, - "field": "", - "permanent": false, - "description": "Voir les plats préparés actifs servis" - } + "model": "permission.permission", + "pk": 213, + "fields": { + "model": [ + "food", + "food" + ], + "query": "{}", + "type": "view", + "mask": 2, + "permanent": false, + "description": "Voir n'importe quelle bouffe" + } }, { - "model": "permission.permission", - "pk": 214, - "fields": { - "model": [ - "food", - "qrcode" - ], - "query": "{}", - "type": "add", - "mask": 3, - "field": "", - "permanent": false, - "description": "Initialiser un QR code de traçabilité" - } + "model": "permission.permission", + "pk": 214, + "fields": { + "model": [ + "food", + "qrcode" + ], + "query": "{}", + "type": "add", + "mask": 2, + "permanent": false, + "description": "Ajouter n'importe quel QR-code" + } }, { - "model": "permission.permission", - "pk": 215, - "fields": { - "model": [ - "food", - "basicfood" - ], - "query": "{\"owner\": [\"club\"]}", - "type": "add", - "mask": 3, - "field": "", - "permanent": false, - "description": "Créer un nouvel ingrédient pour son club" - } + "model": "permission.permission", + "pk": 215, + "fields": { + "model": [ + "food", + "food" + ], + "query": "{}", + "type": "add", + "mask": 2, + "permanent": false, + "description": "Ajouter n'importe quelle bouffe" + } }, { - "model": "permission.permission", - "pk": 216, - "fields": { - "model": [ - "food", - "basicfood" - ], - "query": "{}", - "type": "add", - "mask": 3, - "field": "", - "permanent": false, - "description": "Créer un nouvel ingrédient" - } + "model": "permission.permission", + "pk": 216, + "fields": { + "model": [ + "food", + "food" + ], + "query": "{}", + "type": "change", + "mask": 2, + "permanent": false, + "description": "Modifier n'importe quelle bouffe" + } }, { - "model": "permission.permission", - "pk": 217, - "fields": { - "model": [ - "food", - "basicfood" - ], - "query": "{}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir toute la bouffe" - } + "model": "permission.permission", + "pk": 217, + "fields": { + "model": [ + "food", + "qrcode" + ], + "query": "{\"food_container__owner\": [\"club\"]}", + "type": "view", + "mask": 2, + "permanent": false, + "description": "Voir un QR-code lié à son club" + } }, { - "model": "permission.permission", - "pk": 218, - "fields": { - "model": [ - "food", - "basicfood" - ], - "query": "{\"is_active\": true}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir toute la bouffe active" - } + "model": "permission.permission", + "pk": 218, + "fields": { + "model": [ + "food", + "food" + ], + "query": "{\"owner\": [\"club\"]}", + "type": "view", + "mask": 2, + "permanent": false, + "description": "Voir la bouffe de son club" + } }, { - "model": "permission.permission", - "pk": 219, - "fields": { - "model": [ - "food", - "basicfood" - ], - "query": "{\"is_active\": true, \"owner\": [\"club\"]}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir la bouffe active de son club" - } + "model": "permission.permission", + "pk": 219, + "fields": { + "model": [ + "food", + "qrcode" + ], + "query": "{\"food_container__owner\": [\"club\"]}", + "type": "add", + "mask": 2, + "permanent": false, + "description": "Ajouter un QR-code pour son club" + } }, { - "model": "permission.permission", - "pk": 220, - "fields": { - "model": [ - "food", - "basicfood" - ], - "query": "{}", - "type": "change", - "mask": 3, - "field": "", - "permanent": false, - "description": "Modifier de la bouffe" - } + "model": "permission.permission", + "pk": 220, + "fields": { + "model": [ + "food", + "food" + ], + "query": "{\"owner\": [\"club\"]}", + "type": "add", + "mask": 2, + "permanent": false, + "description": "Ajouter de la bouffe appartenant à son club" + } }, { - "model": "permission.permission", - "pk": 221, - "fields": { - "model": [ - "food", - "basicfood" - ], - "query": "{\"is_active\": true, \"was_eaten\": false}", - "type": "change", - "mask": 3, - "field": "allergens", - "permanent": false, - "description": "Modifier les allergènes de la bouffe existante" - } + "model": "permission.permission", + "pk": 221, + "fields": { + "model": [ + "food", + "food" + ], + "query": "{\"owner\": [\"club\"]}", + "type": "change", + "mask": 2, + "permanent": false, + "description": "Modifier la bouffe appartenant à son club" + } }, { - "model": "permission.permission", - "pk": 222, - "fields": { - "model": [ - "food", - "basicfood" - ], - "query": "{\"is_active\": true, \"was_eaten\": false, \"owner\": [\"club\"]}", - "type": "change", - "mask": 3, - "field": "allergens", - "permanent": false, - "description": "Modifier les allergènes de la bouffe appartenant à son club" - } - }, - { - "model": "permission.permission", - "pk": 223, - "fields": { - "model": [ - "food", - "transformedfood" - ], - "query": "{}", - "type": "add", - "mask": 3, - "field": "", - "permanent": false, - "description": "Créer un plat" - } - }, - { - "model": "permission.permission", - "pk": 224, - "fields": { - "model": [ - "food", - "transformedfood" - ], - "query": "{\"owner\": [\"club\"]}", - "type": "add", - "mask": 3, - "field": "", - "permanent": false, - "description": "Créer un plat pour son club" - } - }, - { - "model": "permission.permission", - "pk": 225, - "fields": { - "model": [ - "food", - "transformedfood" - ], - "query": "{}", - "type": "change", - "mask": 3, - "field": "", - "permanent": false, - "description": "Modifier tout les plats" - } - }, - { - "model": "permission.permission", - "pk": 226, - "fields": { - "model": [ - "food", - "transformedfood" - ], - "query": "{\"is_active\": true}", - "type": "change", - "mask": 3, - "field": "was_eaten", - "permanent": false, - "description": "Indiquer si un plat a été mangé" - } - }, - { - "model": "permission.permission", - "pk": 227, - "fields": { - "model": [ - "food", - "transformedfood" - ], - "query": "{\"is_active\": true, \"owner\": [\"club\"]}", - "type": "change", - "mask": 3, - "field": "is_ready", - "permanent": false, - "description": "Indiquer si un plat de son club est prêt" - } - }, - { - "model": "permission.permission", - "pk": 228, - "fields": { - "model": [ - "food", - "transformedfood" - ], - "query": "{\"is_active\": true}", - "type": "change", - "mask": 3, - "field": "is_active", - "permanent": false, - "description": "Archiver un plat" - } - }, - { - "model": "permission.permission", - "pk": 229, - "fields": { - "model": [ - "food", - "basicfood" - ], - "query": "{\"is_active\": true}", - "type": "change", - "mask": 3, - "field": "is_active", - "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, - "fields": { - "model": [ - "food", - "qrcode" - ], - "query": "{}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir tous les QR codes" - } - }, - { - "model": "permission.permission", - "pk": 232, - "fields": { - "model": [ - "food", - "qrcode" - ], - "query": "{\"food_container__is_active\": true}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir tous les QR codes actifs" - } - }, - { - "model": "permission.permission", - "pk": 233, - "fields": { - "model": [ - "food", - "qrcode" - ], - "query": "{\"food_container__owner\": [\"club\"], \"food_container__is_active\": true}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir tous les QR codes actifs de son club" - } - }, - { - "model": "permission.permission", - "pk" : 234, - "fields": { - "model": [ - "food", - "transformedfood" - ], - "query": "{\"owner\": [\"club\"], \"is_active\": true}", - "type": "change", - "mask": 3, - "field": "ingredients", - "permanent": false, - "description": "Changer les ingrédients d'un plat actif de son club" - } - }, - { - "model": "permission.permission", - "pk": 235, - "fields": { - "model": [ - "food", - "food" - ], - "query": "{}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir bouffe" - } - }, - { - "model": "permission.permission", - "pk": 236, - "fields": { - "model": [ - "food", - "food" - ], - "query": "{\"is_active\": true}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir bouffe active" - } - }, - { - "model": "permission.permission", - "pk": 237, - "fields": { - "model": [ - "food", - "food" - ], - "query": "{\"is_active\": true, \"owner\": [\"club\"]}", - "type": "view", - "mask": 3, - "field": "", - "permanent": false, - "description": "Voir bouffe active de son club" - } - }, - { - "model": "permission.permission", - "pk": 238, - "fields": { - "model": [ - "food", - "food" - ], - "query": "{}", - "type": "change", - "mask": 3, - "field": "", - "permanent": false, - "description": "Modifier bouffe" - } + "model": "permission.permission", + "pk": 222, + "fields": { + "model": [ + "food", + "food" + ], + "query": "{\"end_of_life\": \"\"}", + "type": "view", + "mask": 1, + "permanent": false, + "description": "Voir la bouffe servie" + } }, { "model": "permission.permission", @@ -4359,7 +4091,8 @@ 158, 159, 160, - 213 + 212, + 222 ] } }, @@ -4400,16 +4133,11 @@ 50, 141, 169, - 212, - 214, - 215, - 219, - 222, - 224, - 227, - 233, - 234, - 237, + 217, + 218, + 219, + 220, + 221, 247, 258, 259 @@ -4590,21 +4318,7 @@ 166, 167, 168, - 182, - 212, - 214, - 215, - 218, - 221, - 224, - 226, - 227, - 228, - 229, - 230, - 232, - 234, - 236 + 182 ] } }, @@ -4812,8 +4526,7 @@ 168, 176, 177, - 197, - 211 + 197 ] } }, @@ -4841,15 +4554,11 @@ "permissions": [ 137, 211, - 214, - 216, - 217, - 220, - 223, - 225, - 231, - 235, - 238 + 212, + 213, + 214, + 215, + 216 ] } }, diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index e33ee287..14dc6a1e 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-03-25 11:16+0100\n" +"POT-Creation-Date: 2025-04-24 18:22+0200\n" "PO-Revision-Date: 2022-04-11 22:05+0200\n" "Last-Translator: bleizi \n" "Language-Team: French \n" @@ -25,7 +25,7 @@ msgid "This opener already exists" msgstr "Cette amitié existe déjà" #: apps/activity/apps.py:10 apps/activity/models.py:129 -#: apps/activity/models.py:169 apps/activity/models.py:328 +#: apps/activity/models.py:169 apps/activity/models.py:329 msgid "activity" msgstr "activité" @@ -37,29 +37,29 @@ msgstr "La note du club est inactive." msgid "The end date must be after the start date." msgstr "La date de fin doit être après celle de début." -#: apps/activity/forms.py:83 apps/activity/models.py:276 +#: apps/activity/forms.py:83 apps/activity/models.py:277 msgid "You can't invite someone once the activity is started." msgstr "" "Vous ne pouvez pas inviter quelqu'un une fois que l'activité a démarré." -#: apps/activity/forms.py:86 apps/activity/models.py:279 +#: apps/activity/forms.py:86 apps/activity/models.py:280 msgid "This activity is not validated yet." msgstr "Cette activité n'est pas encore validée." -#: apps/activity/forms.py:96 apps/activity/models.py:287 +#: apps/activity/forms.py:96 apps/activity/models.py:288 msgid "This person has been already invited 5 times this year." msgstr "Cette personne a déjà été invitée 5 fois cette année." -#: apps/activity/forms.py:100 apps/activity/models.py:291 +#: apps/activity/forms.py:100 apps/activity/models.py:292 msgid "This person is already invited." msgstr "Cette personne est déjà invitée." -#: apps/activity/forms.py:104 apps/activity/models.py:295 +#: apps/activity/forms.py:104 apps/activity/models.py:296 msgid "You can't invite more than 3 people to this activity." msgstr "Vous ne pouvez pas inviter plus de 3 personnes à cette activité." -#: apps/activity/models.py:28 apps/activity/models.py:63 apps/food/models.py:42 -#: apps/food/models.py:56 apps/member/models.py:203 +#: apps/activity/models.py:28 apps/activity/models.py:63 apps/food/models.py:18 +#: apps/food/models.py:35 apps/member/models.py:203 #: apps/member/templates/member/includes/club_info.html:4 #: apps/member/templates/member/includes/profile_info.html:4 #: apps/note/models/notes.py:263 apps/note/models/transactions.py:26 @@ -121,7 +121,7 @@ msgid "type" msgstr "type" #: apps/activity/models.py:91 apps/logs/models.py:22 apps/member/models.py:325 -#: apps/note/models/notes.py:148 apps/treasury/models.py:293 +#: apps/note/models/notes.py:148 apps/treasury/models.py:294 #: apps/wei/models.py:171 apps/wei/templates/wei/attribute_bus_1A.html:13 #: apps/wei/templates/wei/survey.html:15 msgid "user" @@ -205,21 +205,21 @@ msgstr "Entrée de la note {note} pour l'activité « {activity} »" msgid "Already entered on " msgstr "Déjà rentré·e le " -#: apps/activity/models.py:204 apps/activity/tables.py:58 +#: apps/activity/models.py:205 apps/activity/tables.py:58 msgid "{:%Y-%m-%d %H:%M:%S}" msgstr "{:%d/%m/%Y %H:%M:%S}" -#: apps/activity/models.py:212 +#: apps/activity/models.py:213 msgid "The balance is negative." msgstr "La note est en négatif." -#: apps/activity/models.py:242 +#: apps/activity/models.py:243 #: apps/treasury/templates/treasury/sogecredit_detail.html:14 #: apps/wei/templates/wei/attribute_bus_1A.html:16 msgid "last name" msgstr "nom de famille" -#: apps/activity/models.py:247 +#: apps/activity/models.py:248 #: apps/member/templates/member/includes/profile_info.html:4 #: apps/registration/templates/registration/future_profile_detail.html:16 #: apps/treasury/templates/treasury/sogecredit_detail.html:17 @@ -228,36 +228,36 @@ msgstr "nom de famille" msgid "first name" msgstr "prénom" -#: apps/activity/models.py:252 +#: apps/activity/models.py:253 msgid "school" msgstr "école" -#: apps/activity/models.py:259 +#: apps/activity/models.py:260 msgid "inviter" msgstr "hôte" -#: apps/activity/models.py:263 +#: apps/activity/models.py:264 msgid "guest" msgstr "invité·e" -#: apps/activity/models.py:264 +#: apps/activity/models.py:265 msgid "guests" msgstr "invité·e·s" -#: apps/activity/models.py:317 +#: apps/activity/models.py:318 msgid "Invitation" msgstr "Invitation" -#: apps/activity/models.py:335 apps/activity/models.py:339 +#: apps/activity/models.py:336 apps/activity/models.py:340 msgid "Opener" msgstr "Ouvreur⋅se" -#: apps/activity/models.py:340 +#: apps/activity/models.py:341 #: apps/activity/templates/activity/activity_detail.html:16 msgid "Openers" msgstr "Ouvreur⋅ses" -#: apps/activity/models.py:344 +#: apps/activity/models.py:345 #, fuzzy, python-brace-format #| msgid "Entry for {note} to the activity {activity}" msgid "{opener} is opener of activity {acivity}" @@ -285,7 +285,7 @@ msgstr "Entré·e le " msgid "remove" msgstr "supprimer" -#: apps/activity/tables.py:84 apps/note/forms.py:69 apps/treasury/models.py:208 +#: apps/activity/tables.py:84 apps/note/forms.py:69 apps/treasury/models.py:209 msgid "Type" msgstr "Type" @@ -382,10 +382,8 @@ msgid "Entry done!" msgstr "Entrée effectuée !" #: apps/activity/templates/activity/activity_form.html:16 -#: apps/food/templates/food/add_ingredient_form.html:16 -#: apps/food/templates/food/basicfood_form.html:16 -#: apps/food/templates/food/create_qrcode_form.html:20 -#: apps/food/templates/food/transformedfood_form.html:16 +#: apps/food/templates/food/food_update.html:17 +#: apps/food/templates/food/qrcode.html:18 #: apps/member/templates/member/add_members.html:46 #: apps/member/templates/member/club_form.html:16 #: apps/note/templates/note/transactiontemplate_form.html:18 @@ -493,257 +491,277 @@ msgstr "Entrées pour l'activité « {} »" msgid "API" msgstr "API" -#: apps/food/apps.py:11 apps/food/models.py:105 +#: apps/food/apps.py:11 msgid "food" msgstr "bouffe" -#: apps/food/forms.py:32 -msgid "Fully used" -msgstr "Entièrement utilisé" - #: apps/food/forms.py:50 msgid "Pasta METRO 5kg" msgstr "Pâtes METRO 5kg" -#: apps/food/forms.py:100 +#: apps/food/forms.py:54 apps/food/forms.py:82 +msgid "Specific order given to GCKs" +msgstr "" + +#: apps/food/forms.py:78 msgid "Lasagna" msgstr "Lasagnes" -#: apps/food/models.py:18 -msgid "QR-code number" -msgstr "numéro de QR-code" +#: apps/food/forms.py:117 +msgid "Shelf life (in hours)" +msgstr "Durée de vie (en heure)" -#: apps/food/models.py:26 -msgid "food container" -msgstr "récipient" +#: apps/food/forms.py:139 +msgid "Fully used" +msgstr "Entièrement utilisé" -#: apps/food/models.py:30 -msgid "QR-code" -msgstr "QR-code" - -#: apps/food/models.py:31 -msgid "QR-codes" -msgstr "QR-codes" - -#: apps/food/models.py:34 -#, python-brace-format -msgid "QR-code number {qr_code_number}" -msgstr "numéro du QR-code {qr_code_number}" - -#: apps/food/models.py:47 +#: apps/food/models.py:23 msgid "Allergen" msgstr "Allergène" -#: apps/food/models.py:48 apps/food/templates/food/basicfood_detail.html:17 -#: apps/food/templates/food/transformedfood_detail.html:20 +#: apps/food/models.py:24 msgid "Allergens" msgstr "Allergènes" -#: apps/food/models.py:64 +#: apps/food/models.py:43 msgid "owner" msgstr "propriétaire" -#: apps/food/models.py:70 -msgid "allergen" -msgstr "allergène" +#: apps/food/models.py:49 +msgid "allergens" +msgstr "allergènes" -#: apps/food/models.py:74 +#: apps/food/models.py:53 msgid "expiry date" msgstr "date de péremption" -#: apps/food/models.py:80 -msgid "was eaten" -msgstr "a été mangé" +#: apps/food/models.py:59 +msgid "end of life" +msgstr "fin de vie" -#: apps/food/models.py:89 +#: apps/food/models.py:64 msgid "is ready" msgstr "est prêt" -#: apps/food/models.py:94 -msgid "is active" -msgstr "est en cours" +#: apps/food/models.py:70 +msgid "order" +msgstr "consigne" -#: apps/food/models.py:106 -msgid "foods" -msgstr "bouffes" +#: apps/food/models.py:107 apps/food/views.py:32 +#: note_kfet/templates/base.html:72 +msgid "Food" +msgstr "Bouffe" -#: apps/food/models.py:122 +#: apps/food/models.py:108 +msgid "Foods" +msgstr "Bouffes" + +#: apps/food/models.py:117 msgid "arrival date" msgstr "date d'arrivée" -#: apps/food/models.py:152 +#: apps/food/models.py:169 msgid "Basic food" msgstr "Aliment basique" -#: apps/food/models.py:153 +#: apps/food/models.py:170 msgid "Basic foods" msgstr "Aliments basiques" -#: apps/food/models.py:161 +#: apps/food/models.py:182 msgid "creation date" msgstr "date de création" -#: apps/food/models.py:169 -msgid "transformed ingredient" -msgstr "ingrédients tranformées" - -#: apps/food/models.py:174 +#: apps/food/models.py:188 msgid "shelf life" msgstr "durée de vie" -#: apps/food/models.py:225 apps/food/views.py:375 +#: apps/food/models.py:196 +msgid "transformed ingredient" +msgstr "ingrédients tranformées" + +#: apps/food/models.py:258 msgid "Transformed food" msgstr "Aliment transformé" -#: apps/food/models.py:226 +#: apps/food/models.py:259 msgid "Transformed foods" msgstr "Aliments transformés" -#: apps/food/templates/food/basicfood_detail.html:14 -#: apps/food/templates/food/create_qrcode_form.html:31 -#: apps/food/templates/food/qrcode_detail.html:15 -#: apps/food/templates/food/transformedfood_detail.html:14 -msgid "Owner" -msgstr "Propriétaire" +#: apps/food/models.py:271 +msgid "qr code number" +msgstr "numéro de QR-code" -#: apps/food/templates/food/basicfood_detail.html:15 -#: apps/food/templates/food/create_qrcode_form.html:34 -msgid "Arrival date" -msgstr "Date d'arrivée" +#: apps/food/models.py:278 +msgid "food container" +msgstr "récipient" -#: apps/food/templates/food/basicfood_detail.html:16 -#: apps/food/templates/food/create_qrcode_form.html:37 -#: apps/food/templates/food/qrcode_detail.html:16 -#: apps/food/templates/food/transformedfood_detail.html:19 -msgid "Expiry date" -msgstr "Date de péremption" +#: apps/food/models.py:282 +msgid "QR-code" +msgstr "QR-code" -#: apps/food/templates/food/basicfood_detail.html:24 -#: apps/food/templates/food/transformedfood_detail.html:36 -msgid "Active" -msgstr "Actif" +#: apps/food/models.py:283 +msgid "QR-codes" +msgstr "QR-codes" -#: apps/food/templates/food/basicfood_detail.html:25 -#: apps/food/templates/food/transformedfood_detail.html:37 -msgid "Eaten" -msgstr "Mangé" +#: apps/food/models.py:286 +msgid "QR-code number" +msgstr "numéro de QR-code" -#: apps/food/templates/food/basicfood_detail.html:28 -#: apps/food/templates/food/qrcode_detail.html:20 -#: apps/food/templates/food/qrcode_detail.html:24 -#: apps/food/templates/food/transformedfood_detail.html:41 +#: apps/food/templates/food/food_detail.html:19 +msgid "Contained in" +msgstr "Contenu dans" + +#: apps/food/templates/food/food_detail.html:26 +msgid "Contain" +msgstr "Contient" + +#: apps/food/templates/food/food_detail.html:35 msgid "Update" msgstr "Modifier" -#: apps/food/templates/food/basicfood_detail.html:32 -#: apps/food/templates/food/qrcode_detail.html:34 -#: apps/food/templates/food/transformedfood_detail.html:46 +#: apps/food/templates/food/food_detail.html:40 msgid "Add to a meal" msgstr "Ajouter à un plat" -#: apps/food/templates/food/create_qrcode_form.html:15 -msgid "New basic food" -msgstr "Nouvel aliment basique" +#: apps/food/templates/food/food_detail.html:44 +msgid "Return to the food list" +msgstr "Retour à la liste de nourriture" -#: apps/food/templates/food/create_qrcode_form.html:23 -msgid "Copy constructor" -msgstr "Constructeur de copie" - -#: apps/food/templates/food/create_qrcode_form.html:28 -#: apps/food/templates/food/qrcode_detail.html:14 -#: apps/note/templates/note/transaction_form.html:132 -#: apps/treasury/models.py:60 -msgid "Name" -msgstr "Nom" - -#: apps/food/templates/food/qrcode_detail.html:10 -msgid "number" -msgstr "numéro" - -#: apps/food/templates/food/qrcode_detail.html:29 -msgid "View details" -msgstr "Voir plus" - -#: apps/food/templates/food/transformedfood_detail.html:16 -#: apps/food/templates/food/transformedfood_detail.html:35 -msgid "Ready" -msgstr "Prêt" - -#: apps/food/templates/food/transformedfood_detail.html:18 -msgid "Creation date" -msgstr "Date de création" - -#: apps/food/templates/food/transformedfood_detail.html:27 -msgid "Ingredients" -msgstr "Ingrédients" - -#: apps/food/templates/food/transformedfood_detail.html:34 -msgid "Shelf life" -msgstr "Durée de vie" - -#: apps/food/templates/food/transformedfood_list.html:11 +#: apps/food/templates/food/food_list.html:14 msgid "Meal served" msgstr "Plat servis" -#: apps/food/templates/food/transformedfood_list.html:16 +#: apps/food/templates/food/food_list.html:19 msgid "New meal" msgstr "Nouveau plat" -#: apps/food/templates/food/transformedfood_list.html:25 +#: apps/food/templates/food/food_list.html:28 msgid "There is no meal served." msgstr "Il n'y a pas de plat servi." -#: apps/food/templates/food/transformedfood_list.html:33 -msgid "Open" +#: apps/food/templates/food/food_list.html:35 +msgid "Free food" msgstr "Open" -#: apps/food/templates/food/transformedfood_list.html:40 -msgid "There is no free meal." -msgstr "Il n'y a pas de plat en open" +#: apps/food/templates/food/food_list.html:42 +msgid "There is no free food." +msgstr "Il n'y a pas de bouffe en open" -#: apps/food/templates/food/transformedfood_list.html:48 -msgid "All meals" -msgstr "Tout les plats" +#: apps/food/templates/food/food_list.html:50 +msgid "Food of your clubs" +msgstr "Bouffe de tes clubs" -#: apps/food/templates/food/transformedfood_list.html:55 -msgid "There is no meal." -msgstr "Il n'y a pas de plat" +#: apps/food/templates/food/food_list.html:56 +msgid "Food of club" +msgstr "Bouffe du club" -#: apps/food/views.py:28 -msgid "Add the ingredient" -msgstr "Ajouter un ingrédient" +#: apps/food/templates/food/food_list.html:63 +msgid "Yours club has not food yet." +msgstr "Ton club n'a pas de bouffe pour l'instant" -#: apps/food/views.py:42 -msgid "The product is already prepared" -msgstr "Le produit est déjà prêt" +#: apps/food/templates/food/qrcode.html:22 +msgid "Copy constructor" +msgstr "Constructeur de copie" -#: apps/food/views.py:70 -msgid "Update an aliment" -msgstr "Modifier un aliment" +#: apps/food/templates/food/qrcode.html:23 +msgid "New food" +msgstr "Nouvel aliment" -#: apps/food/views.py:97 -msgid "Details of:" -msgstr "Détails de:" +#: 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/views.py:121 -msgid "Add a new basic food with QRCode" -msgstr "Ajouter un nouvel ingrédient avec un QR-code" +#: apps/food/templates/food/qrcode.html:32 +msgid "Owner" +msgstr "Propriétaire" -#: apps/food/views.py:194 +#: apps/food/templates/food/qrcode.html:35 +msgid "Expiry date" +msgstr "Date de péremption" + +#: apps/food/utils.py:6 +msgid "second" +msgstr "seconde" + +#: apps/food/utils.py:6 +msgid "seconds" +msgstr "secondes" + +#: apps/food/utils.py:7 +msgid "minute" +msgstr "minute" + +#: apps/food/utils.py:7 +msgid "minutes" +msgstr "minutes" + +#: apps/food/utils.py:8 +msgid "hour" +msgstr "heure" + +#: apps/food/utils.py:8 +msgid "hours" +msgstr "heures" + +#: apps/food/utils.py:9 +msgid "day" +msgstr "jour" + +#: apps/food/utils.py:9 apps/member/templates/member/includes/club_info.html:27 +msgid "days" +msgstr "jours" + +#: apps/food/utils.py:10 +msgid "week" +msgstr "semaine" + +#: apps/food/utils.py:10 +msgid "weeks" +msgstr "semaines" + +#: apps/food/utils.py:53 +msgid "and" +msgstr "et" + +#: apps/food/views.py:116 msgid "Add a new QRCode" msgstr "Ajouter un nouveau QR-code" -#: apps/food/views.py:245 -msgid "QRCode" -msgstr "QR-code" +#: apps/food/views.py:165 +msgid "Add an aliment" +msgstr "Ajouter un nouvel aliment" -#: apps/food/views.py:281 -msgid "Add a new meal" -msgstr "Ajouter un nouveau plat" +#: apps/food/views.py:224 +msgid "Add a meal" +msgstr "Ajouter un plat" -#: apps/food/views.py:347 -msgid "Update a meal" -msgstr "Modifier le plat" +#: apps/food/views.py:251 +msgid "Add the ingredient:" +msgstr "Ajouter l'ingrédient" + +#: apps/food/views.py:275 +#, python-brace-format +msgid "Food fully used in : {meal.name}" +msgstr "Aliment entièrement utilisé dans : {meal.name}" + +#: apps/food/views.py:294 +msgid "Update an aliment" +msgstr "Modifier un aliment" + +#: apps/food/views.py:342 +msgid "Details of:" +msgstr "Détails de :" + +#: apps/food/views.py:352 apps/treasury/tables.py:149 +msgid "Yes" +msgstr "Oui" + +#: apps/food/views.py:354 apps/member/models.py:99 apps/treasury/tables.py:149 +msgid "No" +msgstr "Non" #: apps/logs/apps.py:11 msgid "Logs" @@ -1044,10 +1062,6 @@ msgstr "payé⋅e" msgid "Tells if the user receive a salary." msgstr "Indique si l'utilisateur⋅rice perçoit un salaire." -#: apps/member/models.py:99 apps/treasury/tables.py:149 -msgid "No" -msgstr "Non" - #: apps/member/models.py:100 msgid "Yes (receive them in french)" msgstr "Oui (les recevoir en français)" @@ -1357,10 +1371,6 @@ msgstr "Il n'y a pas d'adhésion trouvée avec cette entrée." msgid "Club Parent" msgstr "Club parent" -#: apps/member/templates/member/includes/club_info.html:27 -msgid "days" -msgstr "jours" - #: apps/member/templates/member/includes/club_info.html:31 #: apps/wei/templates/wei/base.html:40 msgid "membership fee" @@ -1456,11 +1466,11 @@ msgstr "Introspection :" msgid "Show my applications" msgstr "Voir mes applications" -#: apps/member/templates/member/picture_update.html:38 +#: apps/member/templates/member/picture_update.html:40 msgid "Nevermind" msgstr "Annuler" -#: apps/member/templates/member/picture_update.html:39 +#: apps/member/templates/member/picture_update.html:41 msgid "Crop and upload" msgstr "Recadrer et envoyer" @@ -1899,7 +1909,7 @@ msgstr "Ce champ est requis." msgid "membership transaction" msgstr "transaction d'adhésion" -#: apps/note/models/transactions.py:381 apps/treasury/models.py:300 +#: apps/note/models/transactions.py:381 apps/treasury/models.py:301 msgid "membership transactions" msgstr "transactions d'adhésion" @@ -2512,7 +2522,7 @@ msgstr "Invalider l'inscription" msgid "Treasury" msgstr "Trésorerie" -#: apps/treasury/forms.py:26 apps/treasury/models.py:112 +#: apps/treasury/forms.py:26 apps/treasury/models.py:113 #: apps/treasury/templates/treasury/invoice_form.html:22 msgid "This invoice is locked and can no longer be edited." msgstr "Cette facture est verrouillée et ne peut plus être éditée." @@ -2525,7 +2535,7 @@ msgstr "La remise est déjà fermée." msgid "You can't change the type of the remittance." msgstr "Vous ne pouvez pas changer le type de la remise." -#: apps/treasury/forms.py:125 apps/treasury/models.py:275 +#: apps/treasury/forms.py:125 apps/treasury/models.py:276 #: apps/treasury/tables.py:99 apps/treasury/tables.py:108 #: apps/treasury/templates/treasury/invoice_list.html:16 #: apps/treasury/templates/treasury/remittance_list.html:16 @@ -2541,139 +2551,139 @@ msgstr "Pas de remise associée" msgid "Invoice identifier" msgstr "Numéro de facture" -#: apps/treasury/models.py:42 apps/wrapped/models.py:28 +#: apps/treasury/models.py:43 apps/wrapped/models.py:28 #: apps/wrapped/models.py:29 msgid "BDE" msgstr "BDE" -#: apps/treasury/models.py:46 +#: apps/treasury/models.py:47 msgid "Quotation" msgstr "Devis" -#: apps/treasury/models.py:51 +#: apps/treasury/models.py:52 msgid "Object" msgstr "Objet" -#: apps/treasury/models.py:55 +#: apps/treasury/models.py:56 msgid "Description" msgstr "Description" -#: apps/treasury/models.py:64 +#: apps/treasury/models.py:65 msgid "Address" msgstr "Adresse" -#: apps/treasury/models.py:69 apps/treasury/models.py:202 +#: apps/treasury/models.py:70 apps/treasury/models.py:203 msgid "Date" msgstr "Date" -#: apps/treasury/models.py:75 +#: apps/treasury/models.py:76 msgid "Payment date" msgstr "Date de paiement" -#: apps/treasury/models.py:79 +#: apps/treasury/models.py:80 msgid "Acquitted" msgstr "Acquittée" -#: apps/treasury/models.py:84 +#: apps/treasury/models.py:85 msgid "Locked" msgstr "Verrouillée" -#: apps/treasury/models.py:85 +#: apps/treasury/models.py:86 msgid "An invoice can't be edited when it is locked." msgstr "Une facture ne peut plus être modifiée si elle est verrouillée." -#: apps/treasury/models.py:91 +#: apps/treasury/models.py:92 msgid "tex source" msgstr "fichier TeX source" -#: apps/treasury/models.py:95 apps/treasury/models.py:140 +#: apps/treasury/models.py:96 apps/treasury/models.py:141 msgid "invoice" msgstr "facture" -#: apps/treasury/models.py:96 +#: apps/treasury/models.py:97 msgid "invoices" msgstr "factures" -#: apps/treasury/models.py:99 +#: apps/treasury/models.py:100 #, python-brace-format msgid "Invoice #{id}" msgstr "Facture n°{id}" -#: apps/treasury/models.py:145 +#: apps/treasury/models.py:146 msgid "Designation" msgstr "Désignation" -#: apps/treasury/models.py:151 +#: apps/treasury/models.py:152 msgid "Quantity" msgstr "Quantité" -#: apps/treasury/models.py:156 +#: apps/treasury/models.py:157 msgid "Unit price" msgstr "Prix unitaire" -#: apps/treasury/models.py:160 +#: apps/treasury/models.py:161 msgid "product" msgstr "produit" -#: apps/treasury/models.py:161 +#: apps/treasury/models.py:162 msgid "products" msgstr "produits" -#: apps/treasury/models.py:189 +#: apps/treasury/models.py:190 msgid "remittance type" msgstr "type de remise" -#: apps/treasury/models.py:190 +#: apps/treasury/models.py:191 msgid "remittance types" msgstr "types de remises" -#: apps/treasury/models.py:213 +#: apps/treasury/models.py:214 msgid "Comment" msgstr "Commentaire" -#: apps/treasury/models.py:218 +#: apps/treasury/models.py:219 msgid "Closed" msgstr "Fermée" -#: apps/treasury/models.py:222 +#: apps/treasury/models.py:223 msgid "remittance" msgstr "remise" -#: apps/treasury/models.py:223 +#: apps/treasury/models.py:224 msgid "remittances" msgstr "remises" -#: apps/treasury/models.py:226 +#: apps/treasury/models.py:227 msgid "Remittance #{:d}: {}" msgstr "Remise n°{:d} : {}" -#: apps/treasury/models.py:279 +#: apps/treasury/models.py:280 msgid "special transaction proxy" msgstr "proxy de transaction spéciale" -#: apps/treasury/models.py:280 +#: apps/treasury/models.py:281 msgid "special transaction proxies" msgstr "proxys de transactions spéciales" -#: apps/treasury/models.py:306 +#: apps/treasury/models.py:307 msgid "credit transaction" msgstr "transaction de crédit" -#: apps/treasury/models.py:311 +#: apps/treasury/models.py:312 #: apps/treasury/templates/treasury/sogecredit_detail.html:10 msgid "Credit from the Société générale" msgstr "Crédit de la Société générale" -#: apps/treasury/models.py:312 +#: apps/treasury/models.py:313 msgid "Credits from the Société générale" msgstr "Crédits de la Société générale" -#: apps/treasury/models.py:315 +#: apps/treasury/models.py:316 #, python-brace-format msgid "Soge credit for {user}" msgstr "Crédit de la société générale pour l'utilisateur·rice {user}" -#: apps/treasury/models.py:445 +#: apps/treasury/models.py:446 msgid "" "This user doesn't have enough money to pay the memberships with its note. " "Please ask her/him to credit the note before invalidating this credit." @@ -2702,10 +2712,6 @@ msgstr "Nombre de transactions" msgid "View" msgstr "Voir" -#: apps/treasury/tables.py:149 -msgid "Yes" -msgstr "Oui" - #: apps/treasury/templates/treasury/invoice_confirm_delete.html:10 #: apps/treasury/views.py:174 msgid "Delete invoice" @@ -3812,10 +3818,6 @@ msgstr "" msgid "Reset" msgstr "Réinitialiser" -#: note_kfet/templates/base.html:72 -msgid "Food" -msgstr "Bouffe" - #: note_kfet/templates/base.html:84 msgid "Users" msgstr "Utilisateur·rices" @@ -4125,6 +4127,67 @@ msgstr "" "d'adhésion. Vous devez également valider votre adresse email en suivant le " "lien que vous avez reçu." +#, python-brace-format +#~ msgid "QR-code number {qr_code_number}" +#~ msgstr "numéro du QR-code {qr_code_number}" + +#~ msgid "was eaten" +#~ msgstr "a été mangé" + +#~ msgid "is active" +#~ msgstr "est en cours" + +#~ msgid "foods" +#~ msgstr "bouffes" + +#~ msgid "Arrival date" +#~ msgstr "Date d'arrivée" + +#~ msgid "Active" +#~ msgstr "Actif" + +#~ msgid "Eaten" +#~ msgstr "Mangé" + +#~ msgid "number" +#~ msgstr "numéro" + +#~ msgid "View details" +#~ msgstr "Voir plus" + +#~ msgid "Ready" +#~ msgstr "Prêt" + +#~ msgid "Creation date" +#~ msgstr "Date de création" + +#~ msgid "Ingredients" +#~ msgstr "Ingrédients" + +#~ msgid "Open" +#~ msgstr "Open" + +#~ msgid "All meals" +#~ msgstr "Tout les plats" + +#~ msgid "There is no meal." +#~ msgstr "Il n'y a pas de plat" + +#~ msgid "The product is already prepared" +#~ msgstr "Le produit est déjà prêt" + +#~ msgid "Add a new basic food with QRCode" +#~ msgstr "Ajouter un nouvel ingrédient avec un QR-code" + +#~ msgid "QRCode" +#~ msgstr "QR-code" + +#~ msgid "Add a new meal" +#~ msgstr "Ajouter un nouveau plat" + +#~ msgid "Update a meal" +#~ msgstr "Modifier le plat" + #, fuzzy #~| msgid "invalidate" #~ msgid "Enter a valid color." @@ -4180,11 +4243,6 @@ msgstr "" #~ msgid "Enter a number." #~ msgstr "numéro de téléphone" -#, fuzzy -#~| msgid "add" -#~ msgid "and" -#~ msgstr "ajouter" - #, fuzzy, python-format #~| msgid "A template with this name already exist" #~ msgid "%(model_name)s with this %(field_labels)s already exists." @@ -4446,9 +4504,6 @@ msgstr "" #~ msgid "Free" #~ msgstr "Open" -#~ msgid "Add a new aliment" -#~ msgstr "Ajouter un nouvel aliment" - #, fuzzy #~| msgid "Transformed food" #~ msgid "New transformed food"