mirror of
				https://gitlab.crans.org/bde/nk20
				synced 2025-10-31 15:50:03 +01:00 
			
		
		
		
	Compare commits
	
		
			3 Commits
		
	
	
		
			small_feat
			...
			note_sheet
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 6bf21b103f | ||
|  | d4cb464169 | ||
|  | cb3b34f874 | 
| @@ -48,5 +48,15 @@ | ||||
|             "can_invite": true, | ||||
|             "guest_entry_fee": 0 | ||||
|         } | ||||
|     }, | ||||
|     { | ||||
|         "model": "activity.activitytype", | ||||
|         "pk": 8, | ||||
|         "fields": { | ||||
|             "name": "Perm bouffe", | ||||
|             "manage_entries": false, | ||||
|             "can_invite": false, | ||||
|             "guest_entry_fee": 0 | ||||
|         } | ||||
|     } | ||||
| ] | ||||
| ] | ||||
| @@ -66,6 +66,10 @@ SPDX-License-Identifier: GPL-3.0-or-later | ||||
|             <a class="btn btn-warning btn-sm my-1" href="{% url 'activity:activity_entry' pk=activity.pk %}"> {% trans "Entry page" %}</a> | ||||
|         {% endif %} | ||||
|  | ||||
|         {% if activity.activity_type.name == "Perm bouffe" %} | ||||
|             <a class="btn btn-warning btn-sm my-1" href="{% url 'food:dish_list' activity_pk=activity.pk %}"> {% trans "Dish page" %}</a> | ||||
|         {% endif %} | ||||
|  | ||||
|         {% if request.path_info == activity_detail_url %} | ||||
|             {% if activity.valid and ".change__open"|has_perm:activity %} | ||||
|                 <a class="btn btn-warning btn-sm my-1" id="open_activity"> {% if activity.open %}{% trans "close"|capfirst %}{% else %}{% trans "open"|capfirst %}{% endif %}</a> | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
|  | ||||
| from rest_framework import serializers | ||||
|  | ||||
| from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode | ||||
| from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order, FoodTransaction | ||||
|  | ||||
|  | ||||
| class AllergenSerializer(serializers.ModelSerializer): | ||||
| @@ -54,3 +54,43 @@ class QRCodeSerializer(serializers.ModelSerializer): | ||||
|     class Meta: | ||||
|         model = QRCode | ||||
|         fields = '__all__' | ||||
|  | ||||
|  | ||||
| class DishSerializer(serializers.ModelSerializer): | ||||
|     """ | ||||
|     REST API Serializer for Dish. | ||||
|     The djangorestframework plugin will analyse the model `Dish` and parse all fields in the API. | ||||
|     """ | ||||
|     class Meta: | ||||
|         model = Dish | ||||
|         fields = '__all__' | ||||
|  | ||||
|  | ||||
| class SupplementSerializer(serializers.ModelSerializer): | ||||
|     """ | ||||
|     REST API Serializer for Supplement. | ||||
|     The djangorestframework plugin will analyse the model `Supplement` and parse all fields in the API. | ||||
|     """ | ||||
|     class Meta: | ||||
|         model = Supplement | ||||
|         fields = '__all__' | ||||
|  | ||||
|  | ||||
| class OrderSerializer(serializers.ModelSerializer): | ||||
|     """ | ||||
|     REST API Serializer for Order. | ||||
|     The djangorestframework plugin will analyse the model `Order` and parse all fields in the API. | ||||
|     """ | ||||
|     class Meta: | ||||
|         model = Order | ||||
|         fields = '__all__' | ||||
|  | ||||
|  | ||||
| class FoodTransactionSerializer(serializers.ModelSerializer): | ||||
|     """ | ||||
|     REST API Serializer for FoodTransaction. | ||||
|     The djangorestframework plugin will analyse the model `FoodTransaction` and parse all fields in the API. | ||||
|     """ | ||||
|     class Meta: | ||||
|         model = FoodTransaction | ||||
|         fields = '__all__' | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| # Copyright (C) 2018-2025 by BDE ENS Paris-Saclay | ||||
| # SPDX-License-Identifier: GPL-3.0-or-later | ||||
|  | ||||
| from .views import AllergenViewSet, FoodViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet | ||||
| from .views import AllergenViewSet, FoodViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet, \ | ||||
|     DishViewSet, SupplementViewSet, OrderViewSet, FoodTransactionViewSet | ||||
|  | ||||
|  | ||||
| def register_food_urls(router, path): | ||||
| @@ -13,3 +14,7 @@ def register_food_urls(router, path): | ||||
|     router.register(path + '/basicfood', BasicFoodViewSet) | ||||
|     router.register(path + '/transformedfood', TransformedFoodViewSet) | ||||
|     router.register(path + '/qrcode', QRCodeViewSet) | ||||
|     router.register(path + '/dish', DishViewSet) | ||||
|     router.register(path + '/supplement', SupplementViewSet) | ||||
|     router.register(path + '/order', OrderViewSet) | ||||
|     router.register(path + '/foodtransaction', FoodTransactionViewSet) | ||||
|   | ||||
| @@ -3,10 +3,12 @@ | ||||
|  | ||||
| from api.viewsets import ReadProtectedModelViewSet | ||||
| from django_filters.rest_framework import DjangoFilterBackend | ||||
| from django.utils import timezone | ||||
| from rest_framework.filters import SearchFilter | ||||
|  | ||||
| from .serializers import AllergenSerializer, FoodSerializer, BasicFoodSerializer, TransformedFoodSerializer, QRCodeSerializer | ||||
| from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode | ||||
| from .serializers import AllergenSerializer, FoodSerializer, BasicFoodSerializer, TransformedFoodSerializer, QRCodeSerializer, \ | ||||
|     DishSerializer, SupplementSerializer, OrderSerializer, FoodTransactionSerializer | ||||
| from ..models import Allergen, Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order, FoodTransaction | ||||
|  | ||||
|  | ||||
| class AllergenViewSet(ReadProtectedModelViewSet): | ||||
| @@ -72,3 +74,61 @@ class QRCodeViewSet(ReadProtectedModelViewSet): | ||||
|     filter_backends = [DjangoFilterBackend, SearchFilter] | ||||
|     filterset_fields = ['qr_code_number', ] | ||||
|     search_fields = ['$qr_code_number', ] | ||||
|  | ||||
|  | ||||
| class DishViewSet(ReadProtectedModelViewSet): | ||||
|     """ | ||||
|     REST API View set. | ||||
|     The djangorestframework plugin will get all `Dish` objects, serialize it to JSON with the given serializer, | ||||
|     then render it on /api/food/dish/ | ||||
|     """ | ||||
|     queryset = Dish.objects.order_by('id') | ||||
|     serializer_class = DishSerializer | ||||
|     filter_backends = [DjangoFilterBackend, SearchFilter] | ||||
|     filterset_fields = ['main__name', 'activity', ] | ||||
|     search_fields = ['$main__name', '$activity', ] | ||||
|  | ||||
|  | ||||
| class SupplementViewSet(ReadProtectedModelViewSet): | ||||
|     """ | ||||
|     REST API View set. | ||||
|     The djangorestframework plugin will get all `Supplement` objects, serialize it to JSON with the given serializer, | ||||
|     then render it on /api/food/supplement/ | ||||
|     """ | ||||
|     queryset = Supplement.objects.order_by('id') | ||||
|     serializer_class = SupplementSerializer | ||||
|     filter_backends = [DjangoFilterBackend, SearchFilter] | ||||
|     filterset_fields = ['food__name', 'dish__activity', ] | ||||
|     search_fields = ['$food__name', '$dish__activity', ] | ||||
|  | ||||
|  | ||||
| class OrderViewSet(ReadProtectedModelViewSet): | ||||
|     """ | ||||
|     REST API View set. | ||||
|     The djangorestframework plugin will get all `Order` objects, serialize it to JSON with the given serializer, | ||||
|     then render it on /api/food/order/ | ||||
|     """ | ||||
|     queryset = Order.objects.order_by('id') | ||||
|     serializer_class = OrderSerializer | ||||
|     filter_backends = [DjangoFilterBackend, SearchFilter] | ||||
|     filterset_fields = ['user', 'activity', 'dish', 'supplements', 'number', ] | ||||
|     search_fields = ['$user', '$activity', '$dish', '$supplements', '$number', ] | ||||
|  | ||||
|     def perform_update(self, serializer): | ||||
|         instance = serializer.save() | ||||
|         if instance.served and not instance.served_at: | ||||
|             instance.served_at = timezone.now() | ||||
|             instance.save() | ||||
|  | ||||
|  | ||||
| class FoodTransactionViewSet(ReadProtectedModelViewSet): | ||||
|     """ | ||||
|     REST API View set. | ||||
|     The djangorestframework plugin will get all `FoodTransaction` objects, serialize it to JSON with the given serializer, | ||||
|     then render it on /api/food/foodtransaction/ | ||||
|     """ | ||||
|     queryset = FoodTransaction.objects.order_by('id') | ||||
|     serializer_class = FoodTransactionSerializer | ||||
|     filter_backends = [DjangoFilterBackend, SearchFilter] | ||||
|     filterset_fields = ['order', ] | ||||
|     search_fields = ['$order', ] | ||||
|   | ||||
| @@ -4,15 +4,16 @@ | ||||
| from random import shuffle | ||||
|  | ||||
| from bootstrap_datepicker_plus.widgets import DateTimePickerInput | ||||
| from crispy_forms.helper import FormHelper | ||||
| 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.inputs import Autocomplete, AmountInput | ||||
| from note_kfet.middlewares import get_current_request | ||||
| from permission.backends import PermissionBackend | ||||
|  | ||||
| from .models import Food, BasicFood, TransformedFood, QRCode | ||||
| from .models import Food, BasicFood, TransformedFood, QRCode, Dish, Supplement, Order | ||||
|  | ||||
|  | ||||
| class QRCodeForms(forms.ModelForm): | ||||
| @@ -185,3 +186,60 @@ ManageIngredientsFormSet = forms.formset_factory( | ||||
|     ManageIngredientsForm, | ||||
|     extra=1, | ||||
| ) | ||||
|  | ||||
|  | ||||
| class DishForm(forms.ModelForm): | ||||
|     """ | ||||
|     Form to create a dish | ||||
|     """ | ||||
|     class Meta: | ||||
|         model = Dish | ||||
|         fields = ('main', 'price', 'available') | ||||
|         widgets = { | ||||
|             "price": AmountInput(), | ||||
|         } | ||||
|  | ||||
|  | ||||
| class SupplementForm(forms.ModelForm): | ||||
|     """ | ||||
|     Form to create a dish | ||||
|     """ | ||||
|     class Meta: | ||||
|         model = Supplement | ||||
|         fields = '__all__' | ||||
|         widgets = { | ||||
|             "price": AmountInput(), | ||||
|         } | ||||
|  | ||||
|  | ||||
| # The 2 following classes are copied from treasury app | ||||
| # Add a subform per supplement in the dish form, and manage correctly the link between the dish and | ||||
| # its supplements. The FormSet will search automatically the ForeignKey in the Supplement model. | ||||
| SupplementFormSet = forms.inlineformset_factory( | ||||
|     Dish, | ||||
|     Supplement, | ||||
|     form=SupplementForm, | ||||
|     extra=0, | ||||
| ) | ||||
|  | ||||
|  | ||||
| class SupplementFormSetHelper(FormHelper): | ||||
|     """ | ||||
|     Specify some template information for the supplement form | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, form=None): | ||||
|         super().__init__(form) | ||||
|         self.form_tag = False | ||||
|         self.form_method = 'POST' | ||||
|         self.form_class = 'form-inline' | ||||
|         self.template = 'bootstrap4/table_inline_formset.html' | ||||
|  | ||||
|  | ||||
| class OrderForm(forms.ModelForm): | ||||
|     """ | ||||
|     Form to order food | ||||
|     """ | ||||
|     class Meta: | ||||
|         model = Order | ||||
|         exclude = ("activity", "number", "ordered_at", "served", "served_at") | ||||
|   | ||||
| @@ -0,0 +1,23 @@ | ||||
| # Generated by Django 5.2.4 on 2025-08-30 00:16 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('food', '0001_initial'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name='food', | ||||
|             name='end_of_life', | ||||
|             field=models.CharField(blank=True, max_length=255, verbose_name='end of life'), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='food', | ||||
|             name='order', | ||||
|             field=models.CharField(blank=True, max_length=255, verbose_name='order'), | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,86 @@ | ||||
| # Generated by Django 5.2.6 on 2025-10-30 22:46 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| import django.utils.timezone | ||||
| from django.conf import settings | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('activity', '0007_alter_guest_activity'), | ||||
|         ('food', '0002_alter_food_end_of_life_alter_food_order'), | ||||
|         ('note', '0007_alter_note_polymorphic_ctype_and_more'), | ||||
|         migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name='Dish', | ||||
|             fields=[ | ||||
|                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('price', models.PositiveIntegerField(verbose_name='price')), | ||||
|                 ('available', models.BooleanField(default=True, verbose_name='available')), | ||||
|                 ('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dishes', to='activity.activity', verbose_name='activity')), | ||||
|                 ('main', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='dishes_as_main', to='food.transformedfood', verbose_name='main food')), | ||||
|             ], | ||||
|             options={ | ||||
|                 'verbose_name': 'Dish', | ||||
|                 'verbose_name_plural': 'Dishes', | ||||
|                 'unique_together': {('main', 'activity')}, | ||||
|             }, | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name='Order', | ||||
|             fields=[ | ||||
|                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('request', models.TextField(blank=True, help_text='A specific request (to remove an ingredient for example)', verbose_name='request')), | ||||
|                 ('number', models.PositiveIntegerField(default=1, verbose_name='number')), | ||||
|                 ('ordered_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='order date')), | ||||
|                 ('served', models.BooleanField(default=False, verbose_name='served')), | ||||
|                 ('served_at', models.DateTimeField(blank=True, null=True, verbose_name='served date')), | ||||
|                 ('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='food_orders', to='activity.activity', verbose_name='activity')), | ||||
|                 ('dish', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='food.dish', verbose_name='dish')), | ||||
|                 ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='food_orders', to=settings.AUTH_USER_MODEL, verbose_name='user')), | ||||
|             ], | ||||
|             options={ | ||||
|                 'verbose_name': 'Order', | ||||
|                 'verbose_name_plural': 'Orders', | ||||
|             }, | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name='FoodTransaction', | ||||
|             fields=[ | ||||
|                 ('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='note.transaction')), | ||||
|                 ('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transaction', to='food.order', verbose_name='order')), | ||||
|             ], | ||||
|             options={ | ||||
|                 'verbose_name': 'food transaction', | ||||
|                 'verbose_name_plural': 'food transactions', | ||||
|             }, | ||||
|             bases=('note.transaction',), | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name='Supplement', | ||||
|             fields=[ | ||||
|                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('price', models.PositiveIntegerField(verbose_name='price')), | ||||
|                 ('dish', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='supplements', to='food.dish', verbose_name='dish')), | ||||
|                 ('food', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='supplements', to='food.food', verbose_name='food')), | ||||
|             ], | ||||
|             options={ | ||||
|                 'verbose_name': 'Supplement', | ||||
|                 'verbose_name_plural': 'Supplements', | ||||
|             }, | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='order', | ||||
|             name='supplements', | ||||
|             field=models.ManyToManyField(blank=True, related_name='orders', to='food.supplement', verbose_name='supplements'), | ||||
|         ), | ||||
|         migrations.AlterUniqueTogether( | ||||
|             name='order', | ||||
|             unique_together={('activity', 'number')}, | ||||
|         ), | ||||
|     ] | ||||
| @@ -4,10 +4,14 @@ | ||||
| from datetime import timedelta | ||||
|  | ||||
| from django.db import models, transaction | ||||
| from django.core.exceptions import ValidationError | ||||
| from django.utils import timezone | ||||
| from django.contrib.auth.models import User | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from polymorphic.models import PolymorphicModel | ||||
| from member.models import Club | ||||
| from activity.models import Activity | ||||
| from note.models import Transaction | ||||
|  | ||||
|  | ||||
| class Allergen(models.Model): | ||||
| @@ -284,3 +288,199 @@ class QRCode(models.Model): | ||||
|  | ||||
|     def __str__(self): | ||||
|         return _('QR-code number') + ' ' + str(self.qr_code_number) | ||||
|  | ||||
|  | ||||
| class Dish(models.Model): | ||||
|     """ | ||||
|     A dish is a food proposed during a meal | ||||
|     """ | ||||
|     main = models.ForeignKey( | ||||
|         TransformedFood, | ||||
|         on_delete=models.PROTECT, | ||||
|         related_name='dishes_as_main', | ||||
|         verbose_name=_('main food'), | ||||
|     ) | ||||
|  | ||||
|     price = models.PositiveIntegerField( | ||||
|         verbose_name=_('price') | ||||
|     ) | ||||
|  | ||||
|     activity = models.ForeignKey( | ||||
|         Activity, | ||||
|         on_delete=models.CASCADE, | ||||
|         related_name='dishes', | ||||
|         verbose_name=_('activity'), | ||||
|     ) | ||||
|  | ||||
|     available = models.BooleanField( | ||||
|         default=True, | ||||
|         verbose_name=_('available'), | ||||
|     ) | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _('Dish') | ||||
|         verbose_name_plural = _('Dishes') | ||||
|         unique_together = ('main', 'activity') | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.main.name + ' (' + str(self.activity) + ')' | ||||
|  | ||||
|     def save(self, *args, **kwargs): | ||||
|         "Check the type of activity" | ||||
|         if self.activity.activity_type.name != 'Perm bouffe': | ||||
|             raise ValidationError(_('(You cannot select this type of activity.')) | ||||
|  | ||||
|         return super().save(*args, **kwargs) | ||||
|  | ||||
|  | ||||
| class Supplement(models.Model): | ||||
|     """ | ||||
|     A supplement is a food added to a dish | ||||
|     """ | ||||
|     dish = models.ForeignKey( | ||||
|         Dish, | ||||
|         on_delete=models.CASCADE, | ||||
|         related_name='supplements', | ||||
|         verbose_name=_('dish'), | ||||
|     ) | ||||
|  | ||||
|     food = models.ForeignKey( | ||||
|         Food, | ||||
|         on_delete=models.PROTECT, | ||||
|         related_name='supplements', | ||||
|         verbose_name=_('food'), | ||||
|     ) | ||||
|  | ||||
|     price = models.PositiveIntegerField( | ||||
|         verbose_name=_('price') | ||||
|     ) | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _('Supplement') | ||||
|         verbose_name_plural = _('Supplements') | ||||
|  | ||||
|     def __str__(self): | ||||
|         return _("Supplement {food} for {dish}").format( | ||||
|             food=str(self.food), dish=str(self.dish)) | ||||
|  | ||||
|  | ||||
| class Order(models.Model): | ||||
|     """ | ||||
|     An order is a dish ordered by a member during an activity | ||||
|     """ | ||||
|     user = models.ForeignKey( | ||||
|         User, | ||||
|         on_delete=models.CASCADE, | ||||
|         related_name='food_orders', | ||||
|         verbose_name=_('user'), | ||||
|     ) | ||||
|  | ||||
|     activity = models.ForeignKey( | ||||
|         Activity, | ||||
|         on_delete=models.CASCADE, | ||||
|         related_name='food_orders', | ||||
|         verbose_name=_('activity'), | ||||
|     ) | ||||
|  | ||||
|     dish = models.ForeignKey( | ||||
|         Dish, | ||||
|         on_delete=models.CASCADE, | ||||
|         related_name='orders', | ||||
|         verbose_name=_('dish'), | ||||
|     ) | ||||
|  | ||||
|     supplements = models.ManyToManyField( | ||||
|         Supplement, | ||||
|         related_name='orders', | ||||
|         verbose_name=_('supplements'), | ||||
|         blank=True, | ||||
|     ) | ||||
|  | ||||
|     request = models.TextField( | ||||
|         blank=True, | ||||
|         verbose_name=_('request'), | ||||
|         help_text=_('A specific request (to remove an ingredient for example)') | ||||
|     ) | ||||
|  | ||||
|     number = models.PositiveIntegerField( | ||||
|         verbose_name=_('number'), | ||||
|         default=1, | ||||
|     ) | ||||
|  | ||||
|     ordered_at = models.DateTimeField( | ||||
|         default=timezone.now, | ||||
|         verbose_name=_('order date'), | ||||
|     ) | ||||
|  | ||||
|     served = models.BooleanField( | ||||
|         default=False, | ||||
|         verbose_name=_('served'), | ||||
|     ) | ||||
|  | ||||
|     served_at = models.DateTimeField( | ||||
|         null=True, | ||||
|         blank=True, | ||||
|         verbose_name=_('served date'), | ||||
|     ) | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _('Order') | ||||
|         verbose_name_plural = _('Orders') | ||||
|         unique_together = ('activity', 'number', ) | ||||
|  | ||||
|     @property | ||||
|     def amount(self): | ||||
|         return self.dish.price + sum(s.price for s in self.supplements.all()) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return _("Order of {dish} by {user}").format( | ||||
|             dish=str(self.dish), | ||||
|             user=str(self.user)) | ||||
|  | ||||
|     def save(self, *args, **kwargs): | ||||
|         created = self.pk is None | ||||
|         if created: | ||||
|             last_order = Order.objects.filter(activity=self.activity).last() | ||||
|             if last_order is None: | ||||
|                 self.number = 1 | ||||
|             else: | ||||
|                 self.number = last_order.number + 1 | ||||
|  | ||||
|         elif self.served: | ||||
|             if FoodTransaction.objects.filter(order=self).exists(): | ||||
|                 transaction = FoodTransaction.objects.get(order=self) | ||||
|                 transaction.valid = True | ||||
|                 transaction.save() | ||||
|             else: | ||||
|                 transaction = FoodTransaction( | ||||
|                     source=self.user.note, | ||||
|                     destination=self.activity.organizer.note, | ||||
|                     amount=self.amount, | ||||
|                     quantity=1, | ||||
|                     valid=True, | ||||
|                     order=self, | ||||
|                 ) | ||||
|                 transaction.save() | ||||
|         else: | ||||
|             if FoodTransaction.objects.filter(order=self).exists(): | ||||
|                 transaction = FoodTransaction.objects.get(order=self) | ||||
|                 transaction.valid = False | ||||
|                 transaction.save() | ||||
|  | ||||
|         return super().save(*args, **kwargs) | ||||
|  | ||||
|  | ||||
| class FoodTransaction(Transaction): | ||||
|     """ | ||||
|     Special type of :model:`note.Transaction` associated to a :model:`food.Order`. | ||||
|     """ | ||||
|     order = models.ForeignKey( | ||||
|         Order, | ||||
|         on_delete=models.PROTECT, | ||||
|         related_name='transaction', | ||||
|         verbose_name=_('order') | ||||
|     ) | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _("food transaction") | ||||
|         verbose_name_plural = _("food transactions") | ||||
|   | ||||
							
								
								
									
										46
									
								
								apps/food/static/food/js/order.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								apps/food/static/food/js/order.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| /** | ||||
|  * On click of "delete", delete the order | ||||
|  * @param button_id:Integer Order id to remove | ||||
|  * @param table_id: Id of the table to reload | ||||
|  */ | ||||
| function delete_button (button_id, table_id) { | ||||
|   $.ajax({ | ||||
|     url: '/api/food/order/' + button_id + '/', | ||||
|     method: 'DELETE', | ||||
|     headers: { 'X-CSRFTOKEN': CSRF_TOKEN } | ||||
|   }).done(function () { | ||||
|     $('#' + table_id).load(location.pathname + ' #' + table_id + ' > *') | ||||
|   }).fail(function (xhr, _textStatus, _error) { | ||||
|     errMsg(xhr.responseJSON, 10000) | ||||
|   }) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * On click of "Serve", mark the order as served | ||||
|  * @param button_id: Order id | ||||
|  * @param table_id:  Id of the table to reload | ||||
|  */ | ||||
| function serve_button(button_id, table_id, current_state) { | ||||
|     console.log("update") | ||||
|   const new_state = !current_state; | ||||
|   $.ajax({ | ||||
|     url: '/api/food/order/' + button_id + '/', | ||||
|     method: 'PATCH', | ||||
|     headers: { 'X-CSRFTOKEN': CSRF_TOKEN }, | ||||
|     contentType: 'application/json', | ||||
|     data: JSON.stringify({ | ||||
|       served: new_state | ||||
|     }) | ||||
|   }) | ||||
|   .done(function () { | ||||
|     if (current_state) { | ||||
|     $('table').load(location.pathname + ' table') | ||||
|     } | ||||
|     else { | ||||
|     $('#' + table_id).load(location.pathname + ' #' + table_id + ' > *'); | ||||
|     } | ||||
|   }) | ||||
|   .fail(function (xhr) { | ||||
|     errMsg(xhr.responseJSON, 10000); | ||||
|   }); | ||||
| } | ||||
| @@ -3,8 +3,11 @@ | ||||
|  | ||||
| import django_tables2 as tables | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from note_kfet.middlewares import get_current_request | ||||
| from note.templatetags.pretty_money import pretty_money | ||||
| from permission.backends import PermissionBackend | ||||
|  | ||||
| from .models import Food | ||||
| from .models import Food, Dish, Order | ||||
|  | ||||
|  | ||||
| class FoodTable(tables.Table): | ||||
| @@ -35,3 +38,84 @@ class FoodTable(tables.Table): | ||||
|             'data-href': lambda record: 'detail/' + str(record.pk), | ||||
|             'style': 'cursor:pointer', | ||||
|         } | ||||
|  | ||||
|  | ||||
| class DishTable(tables.Table): | ||||
|     """ | ||||
|     List dishes | ||||
|     """ | ||||
|     supplements = tables.Column(empty_values=(), verbose_name=_('Available supplements'), orderable=False) | ||||
|  | ||||
|     def render_supplements(self, record): | ||||
|         return ", ".join(str(q.food) for q in record.supplements.all()) | ||||
|  | ||||
|     def render_price(self, value): | ||||
|         return pretty_money(value) | ||||
|  | ||||
|     class Meta: | ||||
|         model = Dish | ||||
|         template_name = 'django_tables2/bootstrap4.html' | ||||
|         fields = ('main', 'supplements', 'price', 'available') | ||||
|         row_attrs = { | ||||
|             'class': 'table-row', | ||||
|             'data-href': lambda record: str(record.pk), | ||||
|             'style': 'cursor:pointer', | ||||
|         } | ||||
|  | ||||
|  | ||||
| DELETE_TEMPLATE = """ | ||||
| <button id="{{ record.pk }}" | ||||
|         class="btn btn-danger btn-sm" | ||||
|         onclick="delete_button(this.id, 'orders_table_{{ table.prefix }}')"> | ||||
|     {{ delete_trans }} | ||||
| </button> | ||||
| """ | ||||
|  | ||||
|  | ||||
| SERVE_TEMPLATE = """ | ||||
| <button id="{{ record.pk }}" | ||||
|         class="btn btn-sm {% if record.served %}btn-secondary{% else %}btn-success{% endif %}" | ||||
|         onclick="serve_button(this.id, 'orders_table_{{ table.prefix }}', {{ record.served|yesno:'true,false' }})"> | ||||
|     {% if record.served %} | ||||
|         {{ record.served_at|date:"d/m/Y H:i" }} | ||||
|     {% else %}""" + _('Serve') + """ | ||||
|     {% endif %} | ||||
| </button> | ||||
| """ | ||||
|  | ||||
|  | ||||
| class OrderTable(tables.Table): | ||||
|     """ | ||||
|     Lis all orders. | ||||
|     """ | ||||
|     delete = tables.TemplateColumn( | ||||
|         template_code=DELETE_TEMPLATE, | ||||
|         extra_context={"delete_trans": _('Delete')}, | ||||
|         orderable=False, | ||||
|         attrs={'td': {'class': lambda record: 'col-sm-1' + ( | ||||
|             ' d-none' if not PermissionBackend.check_perm( | ||||
|                 get_current_request(), "food.delete_order", | ||||
|                 record) else '')}}, verbose_name=_("Delete"), ) | ||||
|  | ||||
|     serve = tables.TemplateColumn( | ||||
|         template_code=SERVE_TEMPLATE, | ||||
|         extra_context={"serve_trans": _('Serve')}, | ||||
|         orderable=False, | ||||
|         attrs={'td': {'class': lambda record: 'col-sm-1' + ( | ||||
|             ' d-none' if not PermissionBackend.check_perm( | ||||
|                 get_current_request(), "food.change_order_saved", | ||||
|                 record) else '')}}, verbose_name=_("Serve"), ) | ||||
|  | ||||
|     request = tables.Column( | ||||
|         orderable=False | ||||
|     ) | ||||
|  | ||||
|     class Meta: | ||||
|         model = Order | ||||
|         template_name = 'django_tables2/bootstrap4.html' | ||||
|         fields = ('ordered_at', 'user', 'dish', 'supplements', 'request', 'serve', 'delete') | ||||
|         order_by = ('ordered_at', ) | ||||
|         row_attrs = { | ||||
|             'class': 'table-row', | ||||
|             'style': 'cursor:pointer', | ||||
|         } | ||||
|   | ||||
							
								
								
									
										25
									
								
								apps/food/templates/food/dish_confirm_delete.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								apps/food/templates/food/dish_confirm_delete.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| {% extends "base.html" %} | ||||
| {% comment %} | ||||
| SPDX-License-Identifier: GPL-3.0-or-later | ||||
| {% endcomment %} | ||||
| {% load i18n crispy_forms_tags %} | ||||
|  | ||||
| {% block content %} | ||||
|     <div class="card bg-light"> | ||||
|         <div class="card-header text-center"> | ||||
|             <h4>{% trans "Delete dish" %}</h4> | ||||
|         </div> | ||||
|         <div class="card-body"> | ||||
|             <div class="alert alert-warning"> | ||||
|                 {% blocktrans %}Are you sure you want to delete this dish? This action can't be undone.{% endblocktrans %} | ||||
|             </div> | ||||
|         </div> | ||||
|         <div class="card-footer text-center"> | ||||
|             <form method="post"> | ||||
|                 {% csrf_token %} | ||||
|                 <a class="btn btn-primary" href="{% url 'food:dish_detail' activity_pk=object.activity.pk pk=object.pk%}">{% trans "Return to dish detail" %}</a> | ||||
|                 <button class="btn btn-danger" type="submit">{% trans "Delete" %}</button> | ||||
|             </form> | ||||
|         </div> | ||||
|     </div> | ||||
| {% endblock %} | ||||
							
								
								
									
										41
									
								
								apps/food/templates/food/dish_detail.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								apps/food/templates/food/dish_detail.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | ||||
| {% extends "base.html" %} | ||||
| {% comment %} | ||||
| Copyright (C) 2018-2025 by BDE ENS Paris-Saclay | ||||
| SPDX-License-Identifier: GPL-3.0-or-later | ||||
| {% endcomment %} | ||||
| {% load render_table from django_tables2 %} | ||||
| {% load i18n pretty_money %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="card bg-white mb-3"> | ||||
|   <h3 class="card-header text-center"> | ||||
|     {{ title }} {{ food.name }} | ||||
|   </h3> | ||||
|   <div class="card-body"> | ||||
|     <ul> | ||||
|       <li> {% trans "Associated food" %} :  | ||||
|         <a href="{% url "food:transformedfood_view" pk=food.pk %}"> | ||||
|           {{ food.name }} | ||||
|         </a> | ||||
|       </li> | ||||
|       <li> {% trans "Sell price" %} : {{ dish.price|pretty_money }}</li> | ||||
|       <li> {% trans "Available" %} : {{ dish.available|yesno }}</li> | ||||
|       <li> {% trans "Possible supplements" %} :  | ||||
|         {% for supp in supplements %} | ||||
|           <a href="{% url "food:food_view" pk=supp.food.pk %}">{{ supp.food.name }} ({{ supp.price|pretty_money }})</a>{% if not forloop.last %},{% endif %} | ||||
|         {% endfor %} | ||||
|       </li> | ||||
|     </ul> | ||||
|     {% if update %} | ||||
|         <a class="btn btn-sm btn-secondary" href="{% url "food:dish_update" activity_pk=dish.activity.pk pk=dish.pk %}"> | ||||
|           {% trans "Update" %} | ||||
|         </a> | ||||
|     {% endif %} | ||||
|     {% if delete %} | ||||
|     <a class="btn btn-sm btn-danger" href="{% url "food:dish_delete" activity_pk=dish.activity.pk pk=dish.pk %}"> | ||||
|           {% trans "Delete" %} | ||||
|         </a> | ||||
|     {% endif %} | ||||
|   </div> | ||||
| </div> | ||||
| {% endblock %} | ||||
							
								
								
									
										94
									
								
								apps/food/templates/food/dish_form.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								apps/food/templates/food/dish_form.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,94 @@ | ||||
| {% extends "base.html" %} | ||||
| {% comment %} | ||||
| Copyright (C) 2018-2025 by BDE ENS Paris-Saclay | ||||
| SPDX-License-Identifier: GPL-3.0-or-later | ||||
| {% endcomment %} | ||||
| {% load i18n crispy_forms_tags %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="card bg-white mb-3"> | ||||
|   <h3 class="card-header text-center"> | ||||
|     {{ title }} | ||||
|   </h3> | ||||
|   <form method="post" action=""> | ||||
|     {% csrf_token %} | ||||
|     <div class="card-body"> | ||||
|       {% crispy form %} | ||||
|     </div> | ||||
|   <h3 class="card-header text-center"> | ||||
|     {% trans "Ajouter des suppléments (optionnel)" %} | ||||
|   </h3> | ||||
|   {{ formset.management_form }} | ||||
|   <table class="table table-condensed table-striped"> | ||||
|     {% for form in formset %} | ||||
|     {% if forloop.first %} | ||||
|     <thead> | ||||
|       <tr> | ||||
|         <th>{{ form.food.label }}<span class="asteriskField">*</span></th> | ||||
|         <th>{{ form.price.label }}<span class="asteriskField">*</span></th> | ||||
|       </tr> | ||||
|     </thead> | ||||
|     <tbody id="form_body"> | ||||
|       {% endif %} | ||||
|       <tr class="row-formset"> | ||||
|         <td>{{ form.food }}</td> | ||||
|         <td>{{ form.price }}</td> | ||||
|         {# These fields are hidden but handled by the formset to link the id and the invoice id #} | ||||
|         {{ form.dish }} | ||||
|         {{ form.id }} | ||||
|       </tr> | ||||
|       {% endfor %} | ||||
|     </tbody> | ||||
|   </table> | ||||
|  | ||||
|   {# Display buttons to add and remove supplements #} | ||||
|     <div class="card-body"> | ||||
|         <div class="btn-group btn-block" role="group"> | ||||
|             <button type="button" id="add_more" class="btn btn-success">{% trans "Add supplement" %}</button> | ||||
|             <button type="button" id="remove_one" class="btn btn-danger">{% trans "Remove supplement" %}</button> | ||||
|         </div> | ||||
|         <button type="submit" class="btn btn-block btn-primary">{% trans "Submit" %}</button> | ||||
|     </div> | ||||
|   </form> | ||||
| </div> | ||||
|  | ||||
| {# Hidden div that store an empty supplement form, to be copied into new forms #} | ||||
| <div id="empty_form" style="display: none;"> | ||||
|     <table class='no_error'> | ||||
|         <tbody id="for_real"> | ||||
|             <tr class="row-formset"> | ||||
|                 <td>{{ formset.empty_form.food }}</td> | ||||
|                 <td>{{ formset.empty_form.price }} </td> | ||||
|                 {{ formset.empty_form.dish }} | ||||
|                 {{ formset.empty_form.id }} | ||||
|             </tr> | ||||
|         </tbody> | ||||
|     </table> | ||||
| </div> | ||||
|  | ||||
| {% endblock %} | ||||
|  | ||||
| {% block extrajavascript %} | ||||
| <script> | ||||
|     /* script that handles add and remove lines */ | ||||
|     IDS = {}; | ||||
|  | ||||
|     $("#id_supplements-TOTAL_FORMS").val($(".row-formset").length - 1); | ||||
|  | ||||
|     $('#add_more').click(function () { | ||||
|         let form_idx = $('#id_supplements-TOTAL_FORMS').val(); | ||||
|         $('#form_body').append($('#for_real').html().replace(/__prefix__/g, form_idx)); | ||||
|         $('#id_supplements-TOTAL_FORMS').val(parseInt(form_idx) + 1); | ||||
|         $('#id_supplements-' + parseInt(form_idx) + '-id').val(IDS[parseInt(form_idx)]); | ||||
|     }); | ||||
|  | ||||
|     $('#remove_one').click(function () { | ||||
|         let form_idx = $('#id_supplements-TOTAL_FORMS').val(); | ||||
|         if (form_idx > 0) { | ||||
|             IDS[parseInt(form_idx) - 1] = $('#id_supplements-' + (parseInt(form_idx) - 1) + '-id').val(); | ||||
|             $('#form_body tr:last-child').remove(); | ||||
|             $('#id_supplements-TOTAL_FORMS').val(parseInt(form_idx) - 1); | ||||
|         } | ||||
|     }); | ||||
| </script> | ||||
| {% endblock %} | ||||
							
								
								
									
										33
									
								
								apps/food/templates/food/dish_list.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								apps/food/templates/food/dish_list.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| {% extends "base.html" %} | ||||
| {% comment %} | ||||
| Copyright (C) 2018-2025 by BDE ENS Paris-Saclay | ||||
| SPDX-License-Identifier: GPL-3.0-or-later | ||||
| {% endcomment %} | ||||
| {% load render_table from django_tables2 %} | ||||
| {% load i18n %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="card bg-white mb-3"> | ||||
|     <h3 class="card-header text-center"> | ||||
|         {{ title }} {{activity.name}} | ||||
|     </h3> | ||||
|     {% render_table table %} | ||||
|     <div class="card-footer"> | ||||
|         {% if can_add_dish %} | ||||
|         <a class="btn btn-sm btn-success" href="{% url 'food:dish_create' activity_pk=activity.pk %}">{% trans "New dish" %}</a> | ||||
|         {% endif %} | ||||
|         <a class="btn btn-sm btn-secondary" href="{% url 'activity:activity_detail' pk=activity.pk %}">{% trans "Activity page" %}</a> | ||||
|         <a class="btn btn-sm btn-primary" href="{% url "food:food_list" %}"> | ||||
|           {% trans "Return to the food list" %} | ||||
|         </a> | ||||
|     </div> | ||||
| </div> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block extrajavascript %} | ||||
| <script type="text/javascript"> | ||||
|     $(".table-row").click(function () { | ||||
|             window.document.location = $(this).data("href"); | ||||
|         }); | ||||
| </script> | ||||
| {% endblock %} | ||||
| @@ -64,13 +64,19 @@ SPDX-License-Identifier: GPL-3.0-or-later | ||||
|   <h3 class="card-header text-center"> | ||||
|     {% trans "Meal served" %} | ||||
|   </h3> | ||||
|   {% if can_add_meal %} | ||||
|   <div class="card-footer"> | ||||
|     {% if can_add_meal %} | ||||
|     <a class="btn btn-sm btn-primary" href="{% url 'food:transformedfood_create' %}"> | ||||
|       {% trans "New meal" %} | ||||
|     </a> | ||||
|     {% endif %} | ||||
|     {% for activity in open_activities %} | ||||
|       <a class="btn btn-sm btn-secondary" href="{% url 'food:dish_list' activity_pk=activity.pk %}"> | ||||
|       {% trans "View" %} {{ activity.name }} | ||||
|     </a> | ||||
|     {% endfor %} | ||||
|   </div> | ||||
|   {% endif %} | ||||
|  | ||||
|   {% if served.data %} | ||||
|   {% render_table served %} | ||||
|   {% else %} | ||||
|   | ||||
							
								
								
									
										25
									
								
								apps/food/templates/food/order_confirm_delete.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								apps/food/templates/food/order_confirm_delete.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| {% extends "base.html" %} | ||||
| {% comment %} | ||||
| SPDX-License-Identifier: GPL-3.0-or-later | ||||
| {% endcomment %} | ||||
| {% load i18n crispy_forms_tags %} | ||||
|  | ||||
| {% block content %} | ||||
|     <div class="card bg-light"> | ||||
|         <div class="card-header text-center"> | ||||
|             <h4>{% trans "Delete order" %}</h4> | ||||
|         </div> | ||||
|         <div class="card-body"> | ||||
|             <div class="alert alert-warning"> | ||||
|                 {% blocktrans %}Are you sure you want to delete this order? This action can't be undone.{% endblocktrans %} | ||||
|             </div> | ||||
|         </div> | ||||
|         <div class="card-footer text-center"> | ||||
|             <form method="post"> | ||||
|                 {% csrf_token %} | ||||
|                 <a class="btn btn-primary" href="{% url 'food:order_list' activity_pk=object.activity.pk%}">{% trans "Return to order list" %}</a> | ||||
|                 <button class="btn btn-danger" type="submit">{% trans "Delete" %}</button> | ||||
|             </form> | ||||
|         </div> | ||||
|     </div> | ||||
| {% endblock %} | ||||
							
								
								
									
										21
									
								
								apps/food/templates/food/order_form.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								apps/food/templates/food/order_form.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| {% extends "base.html" %} | ||||
| {% comment %} | ||||
| Copyright (C) 2018-2025 by BDE ENS Paris-Saclay | ||||
| SPDX-License-Identifier: GPL-3.0-or-later | ||||
| {% endcomment %} | ||||
| {% load i18n crispy_forms_tags %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="card bg-white mb-3"> | ||||
|   <h3 class="card-header text-center"> | ||||
|     {{ title }} | ||||
|   </h3> | ||||
|   <div class="card-body" id="form"> | ||||
|     <form method="post"> | ||||
|       {% csrf_token %} | ||||
|       {{ form | crispy }} | ||||
|       <button class="btn btn-primary" type="submit">{% trans "Submit"%}</button> | ||||
|     </form> | ||||
|   </div> | ||||
| </div> | ||||
| {% endblock %} | ||||
							
								
								
									
										30
									
								
								apps/food/templates/food/order_list.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								apps/food/templates/food/order_list.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| {% extends "base.html" %} | ||||
| {% comment %} | ||||
| Copyright (C) 2018-2025 by BDE ENS Paris-Saclay | ||||
| SPDX-License-Identifier: GPL-3.0-or-later | ||||
| {% endcomment %} | ||||
| {% load render_table from django_tables2 %} | ||||
| {% load static i18n %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="card bg-white mb-3"> | ||||
|     <h3 class="card-header text-center"> | ||||
|         {{ title }} | ||||
|     </h3> | ||||
|     <a class="btn btn-primary" href="{% url 'food:served_order_list' activity_pk=activity.pk %}">{% trans "View served orders" %}</a> | ||||
|       {% for table in tables %} | ||||
|         <div class="card bg-light mb-3" id="orders_table_{{ table.prefix }}"> | ||||
|         <h3 class="card-header text-center"> | ||||
|             {% trans "Orders of " %} {{ table.prefix }}  | ||||
|         </h3> | ||||
|         {% if table.data %} | ||||
|             {% render_table table %} | ||||
|         {% endif %} | ||||
|         </div> | ||||
|       {% endfor %} | ||||
| </div> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block extrajavascript %} | ||||
| <script src="{% static "food/js/order.js" %}"></script> | ||||
| {% endblock%} | ||||
							
								
								
									
										21
									
								
								apps/food/templates/food/served_order_list.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								apps/food/templates/food/served_order_list.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| {% extends "base.html" %} | ||||
| {% comment %} | ||||
| Copyright (C) 2018-2025 by BDE ENS Paris-Saclay | ||||
| SPDX-License-Identifier: GPL-3.0-or-later | ||||
| {% endcomment %} | ||||
| {% load render_table from django_tables2 %} | ||||
| {% load static i18n %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="card bg-white mb-3"> | ||||
|     <h3 class="card-header text-center"> | ||||
|         {{ title }} {{activity.name}} | ||||
|     </h3> | ||||
|     <a class="btn btn-primary" href="{% url 'food:order_list' activity_pk=activity.pk %}">{% trans "View unserved orders" %}</a> | ||||
|     {% render_table table %} | ||||
| </div> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block extrajavascript %} | ||||
| <script src="{% static "food/js/order.js" %}"></script> | ||||
| {% endblock%} | ||||
							
								
								
									
										17
									
								
								apps/food/templates/food/supplement_detail.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								apps/food/templates/food/supplement_detail.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| {% extends "base.html" %} | ||||
| {% comment %} | ||||
| Copyright (C) 2018-2025 by BDE ENS Paris-Saclay | ||||
| SPDX-License-Identifier: GPL-3.0-or-later | ||||
| {% endcomment %} | ||||
| {% load render_table from django_tables2 %} | ||||
| {% load i18n pretty_money %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="card bg-white mb-3"> | ||||
|   <h3 class="card-header text-center"> | ||||
|     {{ title }} {{ supplement.name }} | ||||
|   </h3> | ||||
|   <div class="card-body"> | ||||
|   </div> | ||||
| </div> | ||||
| {% endblock %} | ||||
| @@ -19,4 +19,14 @@ urlpatterns = [ | ||||
|     path('detail/transformed/<int:pk>', views.TransformedFoodDetailView.as_view(), name='transformedfood_view'), | ||||
|     path('add/ingredient/<int:pk>', views.AddIngredientView.as_view(), name='add_ingredient'), | ||||
|     path('redirect/', views.QRCodeRedirectView.as_view(), name='redirect_view'), | ||||
|     # TODO not always store activity_pk in url | ||||
|     path('activity/<int:activity_pk>/dishes/add/', views.DishCreateView.as_view(), name='dish_create'), | ||||
|     path('activity/<int:activity_pk>/dishes/', views.DishListView.as_view(), name='dish_list'), | ||||
|     path('activity/<int:activity_pk>/dishes/<int:pk>/', views.DishDetailView.as_view(), name='dish_detail'), | ||||
|     path('activity/<int:activity_pk>/dishes/<int:pk>/update/', views.DishUpdateView.as_view(), name='dish_update'), | ||||
|     path('activity/<int:activity_pk>/dishes/<int:pk>/delete/', views.DishDeleteView.as_view(), name='dish_delete'), | ||||
|     path('activity/<int:activity_pk>/order/', views.OrderCreateView.as_view(), name='order_create'), | ||||
|     path('activity/<int:activity_pk>/orders/', views.OrderListView.as_view(), name='order_list'), | ||||
|     path('activity/<int:activity_pk>/orders/served', views.ServedOrderListView.as_view(), name='served_order_list'), | ||||
|     path('activity/orders/<int:pk>/delete/', views.OrderDeleteView.as_view(), name='order_delete'), | ||||
| ] | ||||
|   | ||||
| @@ -4,25 +4,30 @@ | ||||
| from datetime import timedelta | ||||
|  | ||||
| from api.viewsets import is_regex | ||||
| from django_tables2.views import MultiTableMixin | ||||
| from crispy_forms.helper import FormHelper | ||||
| from django_tables2.views import SingleTableView, MultiTableMixin | ||||
| from django.core.exceptions import PermissionDenied | ||||
| from django.db import transaction | ||||
| from django.db.models import Q | ||||
| from django.http import HttpResponseRedirect, Http404 | ||||
| from django.views.generic import DetailView, UpdateView, CreateView | ||||
| from django.views.generic.list import ListView | ||||
| from django.views.generic.base import RedirectView | ||||
| from django.views.generic.edit import DeleteView | ||||
| 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 activity.models import Activity | ||||
| from permission.backends import PermissionBackend | ||||
| from permission.views import ProtectQuerysetMixin, ProtectedCreateView, LoginRequiredMixin | ||||
|  | ||||
| from .models import Food, BasicFood, TransformedFood, QRCode | ||||
| from .models import Food, BasicFood, TransformedFood, QRCode, Order, Dish | ||||
| from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \ | ||||
|     ManageIngredientsForm, ManageIngredientsFormSet, AddIngredientForms, \ | ||||
|     BasicFoodUpdateForms, TransformedFoodUpdateForms | ||||
| from .tables import FoodTable | ||||
|     BasicFoodUpdateForms, TransformedFoodUpdateForms, \ | ||||
|     DishForm, SupplementFormSet, SupplementFormSetHelper, OrderForm | ||||
| from .tables import FoodTable, DishTable, OrderTable | ||||
| from .utils import pretty_duration | ||||
|  | ||||
|  | ||||
| @@ -112,6 +117,9 @@ class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, Li | ||||
|         context['club_tables'] = tables[3:] | ||||
|  | ||||
|         context['can_add_meal'] = PermissionBackend.check_perm(self.request, 'food.transformedfood_add') | ||||
|  | ||||
|         context["open_activities"] = Activity.objects.filter(activity_type__name="Perm bouffe", open=True) | ||||
|  | ||||
|         return context | ||||
|  | ||||
|  | ||||
| @@ -526,3 +534,270 @@ class QRCodeRedirectView(RedirectView): | ||||
|         if slug: | ||||
|             return reverse_lazy('food:qrcode_create', kwargs={'slug': slug}) | ||||
|         return reverse_lazy('food:list') | ||||
|  | ||||
|  | ||||
| class DishCreateView(ProtectQuerysetMixin, ProtectedCreateView): | ||||
|     """ | ||||
|     Create a dish | ||||
|     """ | ||||
|     model = Dish | ||||
|     form_class = DishForm | ||||
|     extra_context = {"title": _('Create dish')} | ||||
|  | ||||
|     def get_sample_object(self): | ||||
|         activity = Activity.objects.get(pk=self.kwargs["activity_pk"]) | ||||
|         sample_food = TransformedFood( | ||||
|             name="Sample food", | ||||
|             owner=activity.organizer, | ||||
|             expiry_date=timezone.now() + timedelta(days=7), | ||||
|             is_ready=True, | ||||
|         ) | ||||
|         sample_dish = Dish( | ||||
|             main=sample_food, | ||||
|             price=100, | ||||
|             activity=activity, | ||||
|         ) | ||||
|         return sample_dish | ||||
|  | ||||
|     def get_context_data(self, **kwargs): | ||||
|         context = super().get_context_data(**kwargs) | ||||
|  | ||||
|         form = context['form'] | ||||
|         form.helper = FormHelper() | ||||
|         # Remove form tag on the generation of the form in the template (already present on the template) | ||||
|         form.helper.form_tag = False | ||||
|         # The formset handles the set of the supplements | ||||
|         form_set = SupplementFormSet(instance=form.instance) | ||||
|         context['formset'] = form_set | ||||
|         context['helper'] = SupplementFormSetHelper() | ||||
|  | ||||
|         return context | ||||
|  | ||||
|     def get_form(self, form_class=None): | ||||
|         form = super().get_form(form_class) | ||||
|         if "available" in form.fields: | ||||
|             del form.fields["available"] | ||||
|         return form | ||||
|  | ||||
|     @transaction.atomic | ||||
|     def form_valid(self, form): | ||||
|         activity = Activity.objects.get(pk=self.kwargs["activity_pk"]) | ||||
|  | ||||
|         form.instance.activity = activity | ||||
|  | ||||
|         ret = super().form_valid(form) | ||||
|  | ||||
|         # For each supplement, we save it | ||||
|         formset = SupplementFormSet(self.request.POST, instance=form.instance) | ||||
|         if formset.is_valid(): | ||||
|             for f in formset: | ||||
|                 if f.is_valid(): | ||||
|                     f.save() | ||||
|                     f.instance.save() | ||||
|                 else: | ||||
|                     f.instance = None | ||||
|  | ||||
|         return ret | ||||
|  | ||||
|     def get_success_url(self): | ||||
|         return reverse_lazy('food:dish_list', kwargs={"activity_pk": self.kwargs["activity_pk"]}) | ||||
|  | ||||
|  | ||||
| class DishListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): | ||||
|     """ | ||||
|     List dishes for this activity | ||||
|     """ | ||||
|     model = Dish | ||||
|     table_class = DishTable | ||||
|     extra_context = {"title": _('Dishes served during')} | ||||
|     template_name = 'food/dish_list.html' | ||||
|  | ||||
|     def get_queryset(self): | ||||
|         return super().get_queryset().filter(activity__pk=self.kwargs["activity_pk"]) | ||||
|  | ||||
|     def get_context_data(self, **kwargs): | ||||
|         context = super().get_context_data(**kwargs) | ||||
|  | ||||
|         activity = Activity.objects.get(pk=self.kwargs["activity_pk"]) | ||||
|         context["activity"] = activity | ||||
|  | ||||
|         context["can_add_dish"] = PermissionBackend.check_perm(self.request, 'food.dish_add') | ||||
|  | ||||
|         return context | ||||
|  | ||||
|  | ||||
| class DishDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): | ||||
|     """ | ||||
|     View a dish for this activity | ||||
|     """ | ||||
|     model = Dish | ||||
|     extra_context = {"title": _('Details of:')} | ||||
|     context_oject_name = "dish" | ||||
|  | ||||
|     def get_context_data(self, **kwargs): | ||||
|         context = super().get_context_data(**kwargs) | ||||
|  | ||||
|         context["food"] = self.object.main | ||||
|  | ||||
|         context["supplements"] = self.object.supplements.all() | ||||
|  | ||||
|         context["update"] = PermissionBackend.check_perm(self.request, "food.change_dish") | ||||
|  | ||||
|         context["delete"] = not Order.objects.filter(dish=self.get_object()).exists() and PermissionBackend.check_perm(self.request, "food.delete_dish") | ||||
|  | ||||
|         return context | ||||
|  | ||||
|  | ||||
| class DishUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): | ||||
|     """ | ||||
|     A view to update a dish | ||||
|     """ | ||||
|     model = Dish | ||||
|     form_class = DishForm | ||||
|     extra_context = {"title": _("Update a dish")} | ||||
|  | ||||
|     def get_form(self, **kwargs): | ||||
|         form = super().get_form(**kwargs) | ||||
|         if 'main' in form.fields: | ||||
|             del form.fields["main"] | ||||
|         return form | ||||
|  | ||||
|  | ||||
| class DishDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView): | ||||
|     """ | ||||
|     Delete a dish with no order yet | ||||
|     """ | ||||
|     model = Dish | ||||
|     extra_context = {"title": _('Delete dish')} | ||||
|  | ||||
|     def delete(self, request, *args, **kwargs): | ||||
|         if Order.objects.filter(dish=self.get_object()).exists(): | ||||
|             raise PermissionDenied(_("This dish cannot be deleted because it has already been ordered")) | ||||
|         return super().delete(request, *args, **kwargs) | ||||
|  | ||||
|     def get_success_url(self): | ||||
|         return reverse_lazy('food:dish_list', kwargs={"activity_pk": self.kwargs["activity_pk"]}) | ||||
|  | ||||
|  | ||||
| class OrderCreateView(ProtectQuerysetMixin, ProtectedCreateView): | ||||
|     """ | ||||
|     Order a meal | ||||
|     """ | ||||
|     model = Order | ||||
|     form_class = OrderForm | ||||
|     extra_context = {"title": _('Order food')} | ||||
|  | ||||
|     def get_sample_object(self): | ||||
|         activity = Activity.objects.get(pk=self.kwargs["activity_pk"]) | ||||
|         sample_order = Order( | ||||
|             user=self.request.user, | ||||
|             activity=activity, | ||||
|             dish=Dish.objects.filter(activity=activity).last(), | ||||
|         ) | ||||
|         return sample_order | ||||
|  | ||||
|     def get_form(self): | ||||
|         form = super().get_form() | ||||
|  | ||||
|         form.fields["user"].initial = self.request.user | ||||
|         form.fields["user"].disabled = True | ||||
|  | ||||
|         return form | ||||
|  | ||||
|     def form_valid(self, form): | ||||
|         activity = Activity.objects.get(pk=self.kwargs["activity_pk"]) | ||||
|  | ||||
|         form.instance.activity = activity | ||||
|  | ||||
|         return super().form_valid(form) | ||||
|  | ||||
|     def get_success_url(self): | ||||
|         return reverse_lazy('food:food_list') | ||||
|  | ||||
|  | ||||
| class OrderListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView): | ||||
|     """ | ||||
|     List existing Families | ||||
|     """ | ||||
|     model = Order | ||||
|     table_class = OrderTable | ||||
|     extra_context = {"title": _('Order list')} | ||||
|     paginate_by = 10 | ||||
|  | ||||
|     def get_queryset(self, **kwargs): | ||||
|         activity = Activity.objects.get(pk=self.kwargs["activity_pk"]) | ||||
|         return Order.objects.filter(activity=activity) | ||||
|  | ||||
|     def get_tables(self): | ||||
|         activity = Activity.objects.get(pk=self.kwargs["activity_pk"]) | ||||
|         dishes = Dish.objects.filter(activity=activity) | ||||
|  | ||||
|         tables = [OrderTable] * dishes.count() | ||||
|         self.tables = tables | ||||
|         tables = super().get_tables() | ||||
|         for i in range(dishes.count()): | ||||
|             tables[i].prefix = dishes[i].main.name | ||||
|         return tables | ||||
|  | ||||
|     def get_tables_data(self): | ||||
|         activity = Activity.objects.get(pk=self.kwargs["activity_pk"]) | ||||
|         dishes = Dish.objects.filter(activity=activity) | ||||
|  | ||||
|         tables = [] | ||||
|  | ||||
|         for dish in dishes: | ||||
|             tables.append(self.get_queryset().order_by('ordered_at').filter( | ||||
|                 dish=dish, served=False).filter( | ||||
|                     PermissionBackend.filter_queryset(self.request, Order, 'view') | ||||
|             )) | ||||
|  | ||||
|         return tables | ||||
|  | ||||
|     def get_context_data(self, **kwargs): | ||||
|         context = super().get_context_data(**kwargs) | ||||
|  | ||||
|         context["activity"] = Activity.objects.get(pk=self.kwargs["activity_pk"]) | ||||
|  | ||||
|         return context | ||||
|  | ||||
|  | ||||
| class ServedOrderListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): | ||||
|     """ | ||||
|     View served orders | ||||
|     """ | ||||
|     model = Order | ||||
|     template_name = 'food/served_order_list.html' | ||||
|     table_class = OrderTable | ||||
|  | ||||
|     def get_queryset(self): | ||||
|         return super().get_queryset().filter(activity__pk=self.kwargs["activity_pk"], served=True).order_by('-served_at') | ||||
|  | ||||
|     def get_table(self, **kwargs): | ||||
|         table = super().get_table(**kwargs) | ||||
|  | ||||
|         table.columns.hide("delete") | ||||
|  | ||||
|         return table | ||||
|  | ||||
|     def get_context_data(self, **kwargs): | ||||
|         context = super().get_context_data(**kwargs) | ||||
|  | ||||
|         context["activity"] = Activity.objects.get(pk=self.kwargs["activity_pk"]) | ||||
|  | ||||
|         return context | ||||
|  | ||||
|  | ||||
| class OrderDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView): | ||||
|     """ | ||||
|     Delete an order | ||||
|     """ | ||||
|     model = Order | ||||
|     extra_context = {"title": _('Delete dish')} | ||||
|  | ||||
|     def delete(self, request, *args, **kwargs): | ||||
|         if self.get_object().served: | ||||
|             raise PermissionDenied(_("This order cannot be deleted because it has already been served")) | ||||
|         return super().delete(request, *args, **kwargs) | ||||
|  | ||||
|     def get_success_url(self): | ||||
|         return reverse_lazy('food:order_list', kwargs={"activity_pk": self.kwargs["activity_pk"]}) | ||||
|   | ||||
| @@ -74,6 +74,7 @@ class InvoiceCreateView(ProtectQuerysetMixin, ProtectedCreateView): | ||||
|  | ||||
|         # For each product, we save it | ||||
|         formset = ProductFormSet(self.request.POST, instance=form.instance) | ||||
|         print(formset) | ||||
|         if formset.is_valid(): | ||||
|             for f in formset: | ||||
|                 # We don't save the product if the designation is not entered, ie. if the line is empty | ||||
|   | ||||
		Reference in New Issue
	
	Block a user