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 %} +
+
+

{% trans "Delete dish" %}

+
+
+
+ {% blocktrans %}Are you sure you want to delete this dish? This action can't be undone.{% endblocktrans %} +
+
+ +
+{% endblock %} diff --git a/apps/food/templates/food/dish_detail.html b/apps/food/templates/food/dish_detail.html new file mode 100644 index 00000000..6ac5339f --- /dev/null +++ b/apps/food/templates/food/dish_detail.html @@ -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 %} +
+

+ {{ title }} {{ food.name }} +

+
+ + {% if update %} + + {% trans "Update" %} + + {% endif %} + {% if delete %} + + {% trans "Delete" %} + + {% endif %} +
+
+{% endblock %} diff --git a/apps/food/templates/food/dish_form.html b/apps/food/templates/food/dish_form.html new file mode 100644 index 00000000..5480847f --- /dev/null +++ b/apps/food/templates/food/dish_form.html @@ -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 %} +
+

+ {{ title }} +

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

+ {% trans "Ajouter des suppléments (optionnel)" %} +

+ {{ formset.management_form }} + + {% for form in formset %} + {% if forloop.first %} + + + + + + + + {% endif %} + + + + {# These fields are hidden but handled by the formset to link the id and the invoice id #} + {{ form.dish }} + {{ form.id }} + + {% endfor %} + +
{{ form.food.label }}*{{ form.price.label }}*
{{ form.food }}{{ form.price }}
+ + {# Display buttons to add and remove supplements #} +
+
+ + +
+ +
+
+
+ +{# Hidden div that store an empty supplement form, to be copied into new forms #} + + +{% endblock %} + +{% block extrajavascript %} + +{% endblock %} \ No newline at end of file diff --git a/apps/food/templates/food/dish_list.html b/apps/food/templates/food/dish_list.html new file mode 100644 index 00000000..62acfb9b --- /dev/null +++ b/apps/food/templates/food/dish_list.html @@ -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 %} +
+

+ {{ title }} {{activity.name}} +

+ {% render_table table %} + +
+{% endblock %} + +{% block extrajavascript %} + +{% endblock %} \ No newline at end of file diff --git a/apps/food/templates/food/food_list.html b/apps/food/templates/food/food_list.html index 8e52a00a..b77474bc 100644 --- a/apps/food/templates/food/food_list.html +++ b/apps/food/templates/food/food_list.html @@ -64,13 +64,19 @@ SPDX-License-Identifier: GPL-3.0-or-later

{% trans "Meal served" %}

- {% if can_add_meal %} - {% endif %} + {% if served.data %} {% render_table served %} {% else %} diff --git a/apps/food/templates/food/order_confirm_delete.html b/apps/food/templates/food/order_confirm_delete.html new file mode 100644 index 00000000..7cc7e408 --- /dev/null +++ b/apps/food/templates/food/order_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 %} +
+
+

{% trans "Delete order" %}

+
+
+
+ {% blocktrans %}Are you sure you want to delete this order? This action can't be undone.{% endblocktrans %} +
+
+ +
+{% endblock %} diff --git a/apps/food/templates/food/order_form.html b/apps/food/templates/food/order_form.html new file mode 100644 index 00000000..7b37be84 --- /dev/null +++ b/apps/food/templates/food/order_form.html @@ -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 %} +
+

+ {{ title }} +

+
+
+ {% csrf_token %} + {{ form | crispy }} + +
+
+
+{% endblock %} diff --git a/apps/food/templates/food/order_list.html b/apps/food/templates/food/order_list.html new file mode 100644 index 00000000..352f78c9 --- /dev/null +++ b/apps/food/templates/food/order_list.html @@ -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 %} +
+

+ {{ title }} +

+ {% trans "View served orders" %} + {% for table in tables %} +
+

+ {% trans "Orders of " %} {{ table.prefix }} +

+ {% if table.data %} + {% render_table table %} + {% endif %} +
+ {% endfor %} +
+{% endblock %} + +{% block extrajavascript %} + +{% endblock%} \ No newline at end of file diff --git a/apps/food/templates/food/served_order_list.html b/apps/food/templates/food/served_order_list.html new file mode 100644 index 00000000..8e50eb5e --- /dev/null +++ b/apps/food/templates/food/served_order_list.html @@ -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 %} +
+

+ {{ title }} {{activity.name}} +

+ {% trans "View unserved orders" %} + {% render_table table %} +
+{% endblock %} + +{% block extrajavascript %} + +{% endblock%} \ No newline at end of file diff --git a/apps/food/templates/food/supplement_detail.html b/apps/food/templates/food/supplement_detail.html new file mode 100644 index 00000000..1786d85a --- /dev/null +++ b/apps/food/templates/food/supplement_detail.html @@ -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 %} +
+

+ {{ title }} {{ supplement.name }} +

+
+
+
+{% endblock %} diff --git a/apps/food/urls.py b/apps/food/urls.py index 82a7f22e..4c4ffe2d 100644 --- a/apps/food/urls.py +++ b/apps/food/urls.py @@ -19,4 +19,14 @@ urlpatterns = [ path('detail/transformed/', views.TransformedFoodDetailView.as_view(), name='transformedfood_view'), path('add/ingredient/', 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//dishes/add/', views.DishCreateView.as_view(), name='dish_create'), + path('activity//dishes/', views.DishListView.as_view(), name='dish_list'), + path('activity//dishes//', views.DishDetailView.as_view(), name='dish_detail'), + path('activity//dishes//update/', views.DishUpdateView.as_view(), name='dish_update'), + path('activity//dishes//delete/', views.DishDeleteView.as_view(), name='dish_delete'), + path('activity//order/', views.OrderCreateView.as_view(), name='order_create'), + path('activity//orders/', views.OrderListView.as_view(), name='order_list'), + path('activity//orders/served', views.ServedOrderListView.as_view(), name='served_order_list'), + path('activity/orders//delete/', views.OrderDeleteView.as_view(), name='order_delete'), ] diff --git a/apps/food/views.py b/apps/food/views.py index 4af4020b..2ffbbd4b 100644 --- a/apps/food/views.py +++ b/apps/food/views.py @@ -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"]}) diff --git a/apps/treasury/views.py b/apps/treasury/views.py index eb2fd0d7..b3bfaf80 100644 --- a/apps/treasury/views.py +++ b/apps/treasury/views.py @@ -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