# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later from datetime import timedelta from api.viewsets import is_regex from 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, Order, Dish from .forms import QRCodeForms, BasicFoodForms, TransformedFoodForms, \ ManageIngredientsForm, ManageIngredientsFormSet, AddIngredientForms, \ BasicFoodUpdateForms, TransformedFoodUpdateForms, \ DishForm, SupplementFormSet, SupplementFormSetHelper, OrderForm from .tables import FoodTable, DishTable, OrderTable from .utils import pretty_duration class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView): """ Display Food """ model = Food tables = [FoodTable, FoodTable, FoodTable, ] extra_context = {"title": _('Food')} template_name = 'food/food_list.html' def get_queryset(self, **kwargs): return super().get_queryset(**kwargs).distinct() def get_tables(self): bureau_role_pk = 4 clubs = Club.objects.filter(membership__in=Membership.objects.filter( user=self.request.user, roles=bureau_role_pk).filter( date_end__gte=timezone.now())) tables = [FoodTable] * (clubs.count() + 3) self.tables = tables tables = super().get_tables() tables[0].prefix = 'search-' tables[1].prefix = 'open-' tables[2].prefix = 'served-' for i in range(clubs.count()): tables[i + 3].prefix = clubs[i].name return tables def get_tables_data(self): # table search qs = self.get_queryset().order_by('name') if "search" in self.request.GET and self.request.GET['search']: pattern = self.request.GET['search'] # check regex valid_regex = is_regex(pattern) suffix = '__iregex' if valid_regex else '__istartswith' prefix = '^' if valid_regex else '' qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern}) | Q(**{f'owner__name{suffix}': prefix + pattern}) | Q(**{f'owner__note__alias__name{suffix}': prefix + pattern})) else: qs = qs.none() if "stock" not in self.request.GET or not self.request.GET["stock"] == '1': qs = qs.filter(end_of_life='') search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Food, 'view')) # table open open_table = self.get_queryset().order_by('expiry_date').filter( Q(polymorphic_ctype__model='transformedfood') | Q(polymorphic_ctype__model='basicfood', basicfood__date_type='DLC')).filter( expiry_date__lt=timezone.now(), end_of_life='').filter( PermissionBackend.filter_queryset(self.request, Food, 'view')) # table served served_table = self.get_queryset().order_by('-pk').filter( end_of_life='', is_ready=True).exclude( Q(polymorphic_ctype__model='basicfood', basicfood__date_type='DLC', expiry_date__lte=timezone.now(),) | Q(polymorphic_ctype__model='transformedfood', expiry_date__lte=timezone.now(), )) # tables club bureau_role_pk = 4 clubs = Club.objects.filter(membership__in=Membership.objects.filter( user=self.request.user, roles=bureau_role_pk).filter( date_end__gte=timezone.now())) club_table = [] for club in clubs: club_table.append(self.get_queryset().order_by('expiry_date').filter( owner=club, end_of_life='').filter( PermissionBackend.filter_queryset(self.request, Food, 'view') )) return [search_table, open_table, served_table] + club_table def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) tables = context['tables'] # for extends base_search.html we need to name 'search_table' in 'table' for name, table in zip(['table', 'open', 'served'], tables): context[name] = table context['club_tables'] = tables[3:] context['can_add_meal'] = PermissionBackend.check_perm(self.request, 'food.transformedfood_add') context["open_activities"] = Activity.objects.filter(activity_type__name="Perm bouffe", open=True) return context class QRCodeCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView): """ A view to add qrcode """ model = QRCode template_name = 'food/qrcode.html' form_class = QRCodeForms extra_context = {"title": _("Add a new QRCode")} def get(self, *args, **kwargs): qrcode = kwargs["slug"] if self.model.objects.filter(qr_code_number=qrcode).count() > 0: pk = self.model.objects.get(qr_code_number=qrcode).food_container.pk return HttpResponseRedirect(reverse_lazy("food:food_view", kwargs={"pk": pk})) else: return super().get(*args, **kwargs) @transaction.atomic def form_valid(self, form): qrcode_food_form = QRCodeForms(data=self.request.POST) if not qrcode_food_form.is_valid(): return self.form_invalid(form) qrcode = form.save(commit=False) qrcode.qr_code_number = self.kwargs['slug'] qrcode._force_save = True qrcode.save() qrcode.refresh_from_db() return super().form_valid(form) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['slug'] = self.kwargs['slug'] # get last 10 BasicFood objects with distincts 'name' ordered by '-pk' # we can't use .distinct and .order_by with differents columns hence the generator context['last_items'] = [food for food in BasicFood.get_lastests_objects(10, 'name', '-pk')] return context def get_success_url(self, **kwargs): self.object.refresh_from_db() return reverse_lazy('food:food_view', kwargs={'pk': self.object.food_container.pk}) def get_sample_object(self): return QRCode( qr_code_number=self.kwargs['slug'], food_container_id=1, ) class BasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView): """ A view to add basicfood """ model = BasicFood form_class = BasicFoodForms extra_context = {"title": _("Add an aliment")} template_name = "food/food_update.html" def get_sample_object(self): # We choose a club which may work or BDE else food = BasicFood( name="", owner_id=1, expiry_date=timezone.now(), is_ready=True, arrival_date=timezone.now(), date_type='DLC', ) for membership in self.request.user.memberships.all(): club_id = membership.club.id food.owner_id = club_id if PermissionBackend.check_perm(self.request, "food.add_basicfood", food): return food return food @transaction.atomic def form_valid(self, form): if QRCode.objects.filter(qr_code_number=self.kwargs['slug']).count() > 0: return HttpResponseRedirect(reverse_lazy('food:qrcode_create', kwargs={'slug': self.kwargs['slug']})) food_form = BasicFoodForms(data=self.request.POST) if not food_form.is_valid(): return self.form_invalid(form) food = form.save(commit=False) food.is_ready = False food.save() food.refresh_from_db() qrcode = QRCode() qrcode.qr_code_number = self.kwargs['slug'] qrcode.food_container = food qrcode.save() return super().form_valid(form) def get_success_url(self, **kwargs): self.object.refresh_from_db() return reverse_lazy('food:basicfood_view', kwargs={"pk": self.object.pk}) def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) copy = self.request.GET.get('copy', None) if copy is not None: food = BasicFood.objects.get(pk=copy) for field in context['form'].fields: if field == 'allergens': context['form'].fields[field].initial = getattr(food, field).all() else: context['form'].fields[field].initial = getattr(food, field) return context class TransformedFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView): """ A view to add transformedfood """ model = TransformedFood form_class = TransformedFoodForms extra_context = {"title": _("Add a meal")} template_name = "food/food_update.html" def get_sample_object(self): # We choose a club which may work or BDE else food = TransformedFood( name="", owner_id=1, expiry_date=timezone.now(), is_ready=True, ) for membership in self.request.user.memberships.all(): club_id = membership.club.id food.owner_id = club_id if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food): return food return food @transaction.atomic def form_valid(self, form): form.instance.expiry_date = timezone.now() + timedelta(days=3) form.instance.is_ready = False return super().form_valid(form) def get_success_url(self, **kwargs): self.object.refresh_from_db() return reverse_lazy('food:transformedfood_view', kwargs={"pk": self.object.pk}) MAX_FORMS = 100 class ManageIngredientsView(LoginRequiredMixin, UpdateView): """ A view to manage ingredient for a transformed food """ model = TransformedFood fields = ['ingredients'] extra_context = {"title": _("Manage ingredients of:")} template_name = 'food/manage_ingredients.html' @transaction.atomic def form_valid(self, form): old_ingredients = list(self.object.ingredients.all()).copy() old_allergens = list(self.object.allergens.all()).copy() self.object.ingredients.clear() for i in range(self.object.ingredients.all().count() + 1 + MAX_FORMS): prefix = 'form-' + str(i) + '-' if form.data[prefix + 'qrcode'] not in ['0', '']: ingredient = QRCode.objects.get(pk=form.data[prefix + 'qrcode']).food_container self.object.ingredients.add(ingredient) if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on': ingredient.end_of_life = _('Fully used in {meal}'.format( meal=self.object.name)) ingredient.save() elif form.data[prefix + 'name'] != '': ingredient = Food.objects.get(pk=form.data[prefix + 'name']) self.object.ingredients.add(ingredient) if (prefix + 'fully_used') in form.data and form.data[prefix + 'fully_used'] == 'on': ingredient.end_of_life = _('Fully used in {meal}'.format( meal=self.object.name)) ingredient.save() # We recalculate new expiry date and allergens self.object.expiry_date = self.object.creation_date + self.object.shelf_life self.object.allergens.clear() for ingredient in self.object.ingredients.iterator(): if not (ingredient.polymorphic_ctype.model == 'basicfood' and ingredient.date_type == 'DDM'): self.object.expiry_date = min(self.object.expiry_date, ingredient.expiry_date) self.object.allergens.set(self.object.allergens.union(ingredient.allergens.all())) self.object.save(old_ingredients=old_ingredients, old_allergens=old_allergens) return HttpResponseRedirect(self.get_success_url()) def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) context['title'] += ' ' + self.object.name formset = ManageIngredientsFormSet() ingredients = self.object.ingredients.all() formset.extra += ingredients.count() + MAX_FORMS context['form'] = ManageIngredientsForm() context['ingredients_count'] = ingredients.count() display = [True] * (1 + ingredients.count()) + [False] * (formset.extra - ingredients.count() - 1) context['formset'] = zip(display, formset) context['ingredients'] = [] for ingredient in ingredients: qr = QRCode.objects.filter(food_container=ingredient) context['ingredients'].append({ 'food_pk': ingredient.pk, 'food_name': ingredient.name, 'qr_pk': '' if qr.count() == 0 else qr[0].pk, 'qr_number': '' if qr.count() == 0 else qr[0].qr_code_number, 'fully_used': 'true' if ingredient.end_of_life else '', }) return context def get_success_url(self, **kwargs): return reverse_lazy('food:transformedfood_view', kwargs={"pk": self.object.pk}) class AddIngredientView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): """ A view to add ingredient to a meal """ model = Food extra_context = {"title": _("Add the ingredient:")} form_class = AddIngredientForms template_name = 'food/food_update.html' def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) context['title'] += ' ' + self.object.name return context @transaction.atomic def form_valid(self, form): meals = TransformedFood.objects.filter(pk__in=form.data.getlist('ingredients')).all() if not meals: return HttpResponseRedirect(reverse_lazy('food:food_view', kwargs={"pk": self.object.pk})) for meal in meals: old_ingredients = list(meal.ingredients.all()).copy() old_allergens = list(meal.allergens.all()).copy() meal.ingredients.add(self.object.pk) # update allergen and expiry date if necessary if not (self.object.polymorphic_ctype.model == 'basicfood' and self.object.date_type == 'DDM'): meal.expiry_date = min(meal.expiry_date, self.object.expiry_date) meal.allergens.set(meal.allergens.union(self.object.allergens.all())) meal.save(old_ingredients=old_ingredients, old_allergens=old_allergens) if 'fully_used' in form.data: if not self.object.end_of_life: self.object.end_of_life = _(f'Food fully used in : {meal.name}') else: self.object.end_of_life += ', ' + meal.name if 'fully_used' in form.data: self.object.is_ready = False self.object.save() # We redirect only the first parent parent_pk = meals[0].pk return HttpResponseRedirect(self.get_success_url(parent_pk=parent_pk)) def get_success_url(self, **kwargs): return reverse_lazy('food:transformedfood_view', kwargs={"pk": kwargs['parent_pk']}) class FoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): """ A view to update Food """ model = Food extra_context = {"title": _("Update an aliment")} template_name = 'food/food_update.html' @transaction.atomic def form_valid(self, form): form.instance.creater = self.request.user food = Food.objects.get(pk=self.kwargs['pk']) old_allergens = list(food.allergens.all()).copy() if food.polymorphic_ctype.model == 'transformedfood': old_ingredients = food.ingredients.all() form.instance.shelf_life = timedelta( seconds=int(form.data['shelf_life']) * 60 * 60) food_form = self.get_form_class()(data=self.request.POST) if not food_form.is_valid(): return self.form_invalid(form) ans = super().form_valid(form) if food.polymorphic_ctype.model == 'transformedfood': form.instance.save(old_ingredients=old_ingredients) else: form.instance.save(old_allergens=old_allergens) return ans def get_form_class(self, **kwargs): food = Food.objects.get(pk=self.kwargs['pk']) if food.polymorphic_ctype.model == 'basicfood': return BasicFoodUpdateForms else: return TransformedFoodUpdateForms def get_form(self, **kwargs): form = super().get_form(**kwargs) if 'shelf_life' in form.initial: hours = form.initial['shelf_life'].days * 24 + form.initial['shelf_life'].seconds // 3600 form.initial['shelf_life'] = hours return form def get_success_url(self, **kwargs): self.object.refresh_from_db() return reverse_lazy('food:food_view', kwargs={"pk": self.object.pk}) class FoodDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): """ A view to see a food """ model = Food extra_context = {"title": _('Details of:')} context_object_name = "food" template_name = "food/food_detail.html" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) fields = ["name", "owner", "expiry_date", "allergens", "is_ready", "end_of_life", "order"] fields = dict([(field, getattr(self.object, field)) for field in fields]) if fields["is_ready"]: fields["is_ready"] = _("Yes") else: fields["is_ready"] = _("No") fields["allergens"] = ", ".join( allergen.name for allergen in fields["allergens"].all()) context["fields"] = [( Food._meta.get_field(field).verbose_name.capitalize(), value) for field, value in fields.items()] if self.object.QR_code.exists(): context["QR_code"] = self.object.QR_code.first() context["meals"] = self.object.transformed_ingredient_inv.all() context["update"] = PermissionBackend.check_perm(self.request, "food.change_food") context["add_ingredient"] = (self.object.end_of_life == '' and PermissionBackend.check_perm(self.request, "food.change_transformedfood")) return context def get(self, *args, **kwargs): if Food.objects.filter(pk=kwargs['pk']).count() != 1: return Http404 model = Food.objects.get(pk=kwargs['pk']).polymorphic_ctype.model if 'stop_redirect' in kwargs and kwargs['stop_redirect']: return super().get(*args, **kwargs) kwargs = {'pk': kwargs['pk']} if model == 'basicfood': return HttpResponseRedirect(reverse_lazy("food:basicfood_view", kwargs=kwargs)) return HttpResponseRedirect(reverse_lazy("food:transformedfood_view", kwargs=kwargs)) class BasicFoodDetailView(FoodDetailView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) fields = ['arrival_date', 'date_type'] for field in fields: context["fields"].append(( BasicFood._meta.get_field(field).verbose_name.capitalize(), getattr(self.object, field) )) return context def get(self, *args, **kwargs): if Food.objects.filter(pk=kwargs['pk']).count() == 1: kwargs['stop_redirect'] = (Food.objects.get(pk=kwargs['pk']).polymorphic_ctype.model == 'basicfood') return super().get(*args, **kwargs) class TransformedFoodDetailView(FoodDetailView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["fields"].append(( TransformedFood._meta.get_field("creation_date").verbose_name.capitalize(), self.object.creation_date )) context["fields"].append(( TransformedFood._meta.get_field("shelf_life").verbose_name.capitalize(), pretty_duration(self.object.shelf_life) )) context["foods"] = self.object.ingredients.all() context["manage_ingredients"] = True return context def get(self, *args, **kwargs): if Food.objects.filter(pk=kwargs['pk']).count() == 1: kwargs['stop_redirect'] = (Food.objects.get(pk=kwargs['pk']).polymorphic_ctype.model == 'transformedfood') return super().get(*args, **kwargs) class QRCodeRedirectView(RedirectView): """ Redirects to the QR code creation page from Food List """ def get_redirect_url(self, *args, **kwargs): slug = self.request.GET.get('slug') 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"]})