diff --git a/apps/activity/fixtures/initial.json b/apps/activity/fixtures/initial.json index 7961c17f..d42e6f9d 100644 --- a/apps/activity/fixtures/initial.json +++ b/apps/activity/fixtures/initial.json @@ -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 + } } -] +] \ No newline at end of file diff --git a/apps/activity/templates/activity/includes/activity_info.html b/apps/activity/templates/activity/includes/activity_info.html index 4565a086..f9d6116a 100644 --- a/apps/activity/templates/activity/includes/activity_info.html +++ b/apps/activity/templates/activity/includes/activity_info.html @@ -66,6 +66,10 @@ SPDX-License-Identifier: GPL-3.0-or-later {% trans "Entry page" %} {% endif %} + {% if activity.activity_type.name == "Perm bouffe" %} + {% trans "Dish page" %} + {% endif %} + {% if request.path_info == activity_detail_url %} {% if activity.valid and ".change__open"|has_perm:activity %} {% if activity.open %}{% trans "close"|capfirst %}{% else %}{% trans "open"|capfirst %}{% endif %} diff --git a/apps/food/api/serializers.py b/apps/food/api/serializers.py index eb1621b6..cda17170 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, 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__' diff --git a/apps/food/api/urls.py b/apps/food/api/urls.py index 8fa6995d..bda0f52e 100644 --- a/apps/food/api/urls.py +++ b/apps/food/api/urls.py @@ -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) diff --git a/apps/food/api/views.py b/apps/food/api/views.py index 0aead0de..17d34494 100644 --- a/apps/food/api/views.py +++ b/apps/food/api/views.py @@ -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', ] diff --git a/apps/food/forms.py b/apps/food/forms.py index 13c5cba3..dbfd2da3 100644 --- a/apps/food/forms.py +++ b/apps/food/forms.py @@ -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") diff --git a/apps/food/migrations/0002_alter_food_end_of_life_alter_food_order.py b/apps/food/migrations/0002_alter_food_end_of_life_alter_food_order.py new file mode 100644 index 00000000..8c6a119d --- /dev/null +++ b/apps/food/migrations/0002_alter_food_end_of_life_alter_food_order.py @@ -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'), + ), + ] diff --git a/apps/food/migrations/0003_dish_order_foodtransaction_supplement_and_more.py b/apps/food/migrations/0003_dish_order_foodtransaction_supplement_and_more.py new file mode 100644 index 00000000..8a5364d6 --- /dev/null +++ b/apps/food/migrations/0003_dish_order_foodtransaction_supplement_and_more.py @@ -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')}, + ), + ] diff --git a/apps/food/models.py b/apps/food/models.py index c0b25078..abbea83d 100644 --- a/apps/food/models.py +++ b/apps/food/models.py @@ -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") diff --git a/apps/food/static/food/js/order.js b/apps/food/static/food/js/order.js new file mode 100644 index 00000000..b7df89bf --- /dev/null +++ b/apps/food/static/food/js/order.js @@ -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); + }); +} \ No newline at end of file diff --git a/apps/food/tables.py b/apps/food/tables.py index 5b854e64..5deb8ca0 100644 --- a/apps/food/tables.py +++ b/apps/food/tables.py @@ -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 = """ + +""" + + +SERVE_TEMPLATE = """ + +""" + + +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', + } diff --git a/apps/food/templates/food/dish_confirm_delete.html b/apps/food/templates/food/dish_confirm_delete.html new file mode 100644 index 00000000..885721f0 --- /dev/null +++ b/apps/food/templates/food/dish_confirm_delete.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% comment %} +SPDX-License-Identifier: GPL-3.0-or-later +{% endcomment %} +{% load i18n crispy_forms_tags %} + +{% block content %} +