# # Copyright 2023 © AE UTBM # ae@utbm.fr / ae.info@utbm.fr # # This file is part of the website of the UTBM Student Association (AE UTBM), # https://ae.utbm.fr. # # You can find the source code of the website at https://github.com/ae-utbm/sith # # LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3) # SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE # OR WITHIN THE LOCAL FILE "LICENSE" # # import re from http import HTTPStatus from typing import TYPE_CHECKING from urllib.parse import parse_qs from django.core.exceptions import PermissionDenied from django.db import DataError, transaction from django.db.models import F from django.http import Http404, HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ from django.views.generic import DetailView, FormView from core.utils import FormFragmentTemplateData from core.views import CanViewMixin from counter.forms import RefillForm from counter.models import Counter, Customer, Product, Selling from counter.utils import is_logged_in_counter from counter.views.mixins import CounterTabsMixin from counter.views.student_card import StudentCardFormView if TYPE_CHECKING: from core.models import User class CounterClick(CounterTabsMixin, CanViewMixin, DetailView): """The click view This is a detail view not to have to worry about loading the counter Everything is made by hand in the post method. """ model = Counter queryset = Counter.objects.annotate_is_open() template_name = "counter/counter_click.jinja" pk_url_kwarg = "counter_id" current_tab = "counter" def render_to_response(self, *args, **kwargs): if self.is_ajax(self.request): response = {"errors": []} status = HTTPStatus.OK if self.request.session["too_young"]: response["errors"].append(_("Too young for that product")) status = HTTPStatus.UNAVAILABLE_FOR_LEGAL_REASONS if self.request.session["not_allowed"]: response["errors"].append(_("Not allowed for that product")) status = HTTPStatus.FORBIDDEN if self.request.session["no_age"]: response["errors"].append(_("No date of birth provided")) status = HTTPStatus.UNAVAILABLE_FOR_LEGAL_REASONS if self.request.session["not_enough"]: response["errors"].append(_("Not enough money")) status = HTTPStatus.PAYMENT_REQUIRED if len(response["errors"]) > 1: status = HTTPStatus.BAD_REQUEST response["basket"] = self.request.session["basket"] return JsonResponse(response, status=status) else: # Standard HTML page return super().render_to_response(*args, **kwargs) def dispatch(self, request, *args, **kwargs): self.customer = get_object_or_404(Customer, user__id=self.kwargs["user_id"]) obj: Counter = self.get_object() if not self.customer.can_buy: raise Http404 if obj.type != "BAR" and not request.user.is_authenticated: raise PermissionDenied if obj.type == "BAR" and ( "counter_token" not in request.session or request.session["counter_token"] != obj.token or len(obj.barmen_list) == 0 ): return redirect(obj) return super().dispatch(request, *args, **kwargs) def get(self, request, *args, **kwargs): """Simple get view.""" if "basket" not in request.session: # Init the basket session entry request.session["basket"] = {} request.session["basket_total"] = 0 request.session["not_enough"] = False # Reset every variable request.session["too_young"] = False request.session["not_allowed"] = False request.session["no_age"] = False ret = super().get(request, *args, **kwargs) if (self.object.type != "BAR" and not request.user.is_authenticated) or ( self.object.type == "BAR" and len(self.object.barmen_list) == 0 ): # Check that at least one barman is logged in ret = self.cancel(request) # Otherwise, go to main view return ret def post(self, request, *args, **kwargs): """Handle the many possibilities of the post request.""" self.object = self.get_object() if (self.object.type != "BAR" and not request.user.is_authenticated) or ( self.object.type == "BAR" and len(self.object.barmen_list) < 1 ): # Check that at least one barman is logged in return self.cancel(request) if self.object.type == "BAR" and not ( "counter_token" in self.request.session and self.request.session["counter_token"] == self.object.token ): # Also check the token to avoid the bar to be stolen return HttpResponseRedirect( reverse_lazy( "counter:details", args=self.args, kwargs={"counter_id": self.object.id}, ) + "?bad_location" ) if "basket" not in request.session: request.session["basket"] = {} request.session["basket_total"] = 0 request.session["not_enough"] = False # Reset every variable request.session["too_young"] = False request.session["not_allowed"] = False request.session["no_age"] = False if self.object.type != "BAR": self.operator = request.user elif self.object.customer_is_barman(self.customer): self.operator = self.customer.user else: self.operator = self.object.get_random_barman() action = self.request.POST.get("action", None) if action is None: action = parse_qs(request.body.decode()).get("action", [""])[0] if action == "add_product": self.add_product(request) elif action == "del_product": self.del_product(request) elif action == "code": return self.parse_code(request) elif action == "cancel": return self.cancel(request) elif action == "finish": return self.finish(request) context = self.get_context_data(object=self.object) return self.render_to_response(context) def get_product(self, pid): return Product.objects.filter(pk=int(pid)).first() def get_price(self, pid): p = self.get_product(pid) if self.object.customer_is_barman(self.customer): price = p.special_selling_price else: price = p.selling_price return price def sum_basket(self, request): total = 0 for infos in request.session["basket"].values(): total += infos["price"] * infos["qty"] return total / 100 def get_total_quantity_for_pid(self, request, pid): pid = str(pid) if pid not in request.session["basket"]: return 0 return ( request.session["basket"][pid]["qty"] + request.session["basket"][pid]["bonus_qty"] ) def compute_record_product(self, request, product=None): recorded = 0 basket = request.session["basket"] if product: if product.is_record_product: recorded -= 1 elif product.is_unrecord_product: recorded += 1 for p in basket: bproduct = self.get_product(str(p)) if bproduct.is_record_product: recorded -= basket[p]["qty"] elif bproduct.is_unrecord_product: recorded += basket[p]["qty"] return recorded def is_record_product_ok(self, request, product): return self.customer.can_record_more( self.compute_record_product(request, product) ) @staticmethod def is_ajax(request): # when using the fetch API, the django request.POST dict is empty # this is but a wretched contrivance which strive to replace # the deprecated django is_ajax() method # and which must be replaced as soon as possible # by a proper separation between the api endpoints of the counter return len(request.POST) == 0 and len(request.body) != 0 def add_product(self, request, q=1, p=None): """Add a product to the basket q is the quantity passed as integer p is the product id, passed as an integer. """ pid = p or parse_qs(request.body.decode())["product_id"][0] pid = str(pid) price = self.get_price(pid) total = self.sum_basket(request) product: Product = self.get_product(pid) user: User = self.customer.user buying_groups = list(product.buying_groups.values_list("pk", flat=True)) can_buy = len(buying_groups) == 0 or any( user.is_in_group(pk=group_id) for group_id in buying_groups ) if not can_buy: request.session["not_allowed"] = True return False bq = 0 # Bonus quantity, for trays if ( product.tray ): # Handle the tray to adjust the quantity q to add and the bonus quantity bq total_qty_mod_6 = self.get_total_quantity_for_pid(request, pid) % 6 bq = int((total_qty_mod_6 + q) / 6) # Integer division q -= bq if self.customer.amount < ( total + round(q * float(price), 2) ): # Check for enough money request.session["not_enough"] = True return False if product.is_unrecord_product and not self.is_record_product_ok( request, product ): request.session["not_allowed"] = True return False if product.limit_age >= 18 and not user.date_of_birth: request.session["no_age"] = True return False if product.limit_age >= 18 and user.is_banned_alcohol: request.session["not_allowed"] = True return False if user.is_banned_counter: request.session["not_allowed"] = True return False if ( user.date_of_birth and self.customer.user.get_age() < product.limit_age ): # Check if affordable request.session["too_young"] = True return False if pid in request.session["basket"]: # Add if already in basket request.session["basket"][pid]["qty"] += q request.session["basket"][pid]["bonus_qty"] += bq else: # or create if not request.session["basket"][pid] = { "qty": q, "price": int(price * 100), "bonus_qty": bq, } request.session.modified = True return True def del_product(self, request): """Delete a product from the basket.""" pid = parse_qs(request.body.decode())["product_id"][0] product = self.get_product(pid) if pid in request.session["basket"]: if ( product.tray and (self.get_total_quantity_for_pid(request, pid) % 6 == 0) and request.session["basket"][pid]["bonus_qty"] ): request.session["basket"][pid]["bonus_qty"] -= 1 else: request.session["basket"][pid]["qty"] -= 1 if request.session["basket"][pid]["qty"] <= 0: del request.session["basket"][pid] request.session.modified = True def parse_code(self, request): """Parse the string entered by the barman. This can be of two forms : - ``, where the string is the code of the product - `X`, where the integer is the quantity and str the code. """ string = parse_qs(request.body.decode()).get("code", [""])[0].upper() if string == "FIN": return self.finish(request) elif string == "ANN": return self.cancel(request) regex = re.compile(r"^((?P[0-9]+)X)?(?P[A-Z0-9]+)$") m = regex.match(string) if m is not None: nb = m.group("nb") code = m.group("code") nb = int(nb) if nb is not None else 1 p = self.object.products.filter(code=code).first() if p is not None: self.add_product(request, nb, p.id) context = self.get_context_data(object=self.object) return self.render_to_response(context) def finish(self, request): """Finish the click session, and validate the basket.""" with transaction.atomic(): request.session["last_basket"] = [] if self.sum_basket(request) > self.customer.amount: raise DataError(_("You have not enough money to buy all the basket")) for pid, infos in request.session["basket"].items(): # This duplicates code for DB optimization (prevent to load many times the same object) p = Product.objects.filter(pk=pid).first() if self.object.customer_is_barman(self.customer): uprice = p.special_selling_price else: uprice = p.selling_price request.session["last_basket"].append( "%d x %s" % (infos["qty"] + infos["bonus_qty"], p.name) ) s = Selling( label=p.name, product=p, club=p.club, counter=self.object, unit_price=uprice, quantity=infos["qty"], seller=self.operator, customer=self.customer, ) s.save() if infos["bonus_qty"]: s = Selling( label=p.name + " (Plateau)", product=p, club=p.club, counter=self.object, unit_price=0, quantity=infos["bonus_qty"], seller=self.operator, customer=self.customer, ) s.save() self.customer.recorded_products -= self.compute_record_product(request) self.customer.save() request.session["last_customer"] = self.customer.user.get_display_name() request.session["last_total"] = "%0.2f" % self.sum_basket(request) request.session["new_customer_amount"] = str(self.customer.amount) del request.session["basket"] request.session.modified = True kwargs = {"counter_id": self.object.id} return HttpResponseRedirect( reverse_lazy("counter:details", args=self.args, kwargs=kwargs) ) def cancel(self, request): """Cancel the click session.""" kwargs = {"counter_id": self.object.id} request.session.pop("basket", None) return HttpResponseRedirect( reverse_lazy("counter:details", args=self.args, kwargs=kwargs) ) def get_context_data(self, **kwargs): """Add customer to the context.""" kwargs = super().get_context_data(**kwargs) products = self.object.products.select_related("product_type") if self.object.customer_is_barman(self.customer): products = products.annotate(price=F("special_selling_price")) else: products = products.annotate(price=F("selling_price")) kwargs["products"] = products kwargs["categories"] = {} for product in kwargs["products"]: if product.product_type: kwargs["categories"].setdefault(product.product_type, []).append( product ) kwargs["customer"] = self.customer kwargs["basket_total"] = self.sum_basket(self.request) if self.object.type == "BAR": kwargs["student_card_fragment"] = StudentCardFormView.get_template_data( self.customer ).render(self.request) if self.object.can_refill(): kwargs["refilling_fragment"] = RefillingCreateView.get_template_data( self.customer ).render(self.request) return kwargs class RefillingCreateView(FormView): """This is a fragment only view which integrates with counter_click.jinja""" form_class = RefillForm template_name = "counter/fragments/create_refill.jinja" @classmethod def get_template_data( cls, customer: Customer, *, form_instance: form_class | None = None ) -> FormFragmentTemplateData[form_class]: return FormFragmentTemplateData( form=form_instance if form_instance else cls.form_class(), template=cls.template_name, context={ "action": reverse_lazy( "counter:refilling_create", kwargs={"customer_id": customer.pk} ), }, ) def dispatch(self, request, *args, **kwargs): self.customer: Customer = get_object_or_404(Customer, pk=kwargs["customer_id"]) if not self.customer.can_buy: raise Http404 if not is_logged_in_counter(request): raise PermissionDenied self.counter: Counter = get_object_or_404( Counter, token=request.session["counter_token"] ) if not self.counter.can_refill(): raise PermissionDenied if self.counter.customer_is_barman(self.customer): self.operator = self.customer.user else: self.operator = self.counter.get_random_barman() return super().dispatch(request, *args, **kwargs) def form_valid(self, form): res = super().form_valid(form) form.clean() form.instance.counter = self.counter form.instance.operator = self.operator form.instance.customer = self.customer form.instance.save() return res def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) data = self.get_template_data(self.customer, form_instance=context["form"]) context.update(data.context) return context def get_success_url(self, **kwargs): return self.request.path