diff --git a/counter/views/admin.py b/counter/views/admin.py new file mode 100644 index 00000000..414e66ae --- /dev/null +++ b/counter/views/admin.py @@ -0,0 +1,287 @@ +# +# 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 itertools +from datetime import timedelta +from operator import itemgetter + +from django.conf import settings +from django.core.exceptions import PermissionDenied +from django.db.models import F +from django.forms import CheckboxSelectMultiple +from django.forms.models import modelform_factory +from django.shortcuts import get_object_or_404 +from django.urls import reverse, reverse_lazy +from django.utils import timezone +from django.views.generic import DetailView, ListView +from django.views.generic.edit import CreateView, DeleteView, UpdateView + +from core.utils import get_semester_code, get_start_of_semester +from core.views import CanEditMixin, CanViewMixin +from counter.forms import CounterEditForm, ProductEditForm +from counter.models import Counter, Product, ProductType, Refilling, Selling +from counter.utils import is_logged_in_counter + + +class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView): + """A list view for the admins.""" + + model = Counter + template_name = "counter/counter_list.jinja" + current_tab = "counters" + + +class CounterEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): + """Edit a counter's main informations (for the counter's manager).""" + + model = Counter + form_class = CounterEditForm + pk_url_kwarg = "counter_id" + template_name = "core/edit.jinja" + current_tab = "counters" + + def dispatch(self, request, *args, **kwargs): + obj = self.get_object() + self.edit_club.append(obj.club) + return super().dispatch(request, *args, **kwargs) + + def get_success_url(self): + return reverse_lazy("counter:admin", kwargs={"counter_id": self.object.id}) + + +class CounterEditPropView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): + """Edit a counter's main informations (for the counter's admin).""" + + model = Counter + form_class = modelform_factory(Counter, fields=["name", "club", "type"]) + pk_url_kwarg = "counter_id" + template_name = "core/edit.jinja" + current_tab = "counters" + + +class CounterCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView): + """Create a counter (for the admins).""" + + model = Counter + form_class = modelform_factory( + Counter, + fields=["name", "club", "type", "products"], + widgets={"products": CheckboxSelectMultiple}, + ) + template_name = "core/create.jinja" + current_tab = "counters" + + +class CounterDeleteView(CounterAdminTabsMixin, CounterAdminMixin, DeleteView): + """Delete a counter (for the admins).""" + + model = Counter + pk_url_kwarg = "counter_id" + template_name = "core/delete_confirm.jinja" + success_url = reverse_lazy("counter:admin_list") + current_tab = "counters" + + +# Product management + + +class ProductTypeListView(CounterAdminTabsMixin, CounterAdminMixin, ListView): + """A list view for the admins.""" + + model = ProductType + template_name = "counter/producttype_list.jinja" + current_tab = "product_types" + + +class ProductTypeCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView): + """A create view for the admins.""" + + model = ProductType + fields = ["name", "description", "comment", "icon", "priority"] + template_name = "core/create.jinja" + current_tab = "products" + + +class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): + """An edit view for the admins.""" + + model = ProductType + template_name = "core/edit.jinja" + fields = ["name", "description", "comment", "icon", "priority"] + pk_url_kwarg = "type_id" + current_tab = "products" + + +class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView): + model = Product + queryset = Product.objects.values("id", "name", "code", "product_type__name") + template_name = "counter/product_list.jinja" + ordering = [ + F("product_type__priority").desc(nulls_last=True), + "product_type", + "name", + ] + + def get_context_data(self, **kwargs): + res = super().get_context_data(**kwargs) + res["object_list"] = itertools.groupby( + res["object_list"], key=itemgetter("product_type__name") + ) + return res + + +class ArchivedProductListView(ProductListView): + """A list view for the admins.""" + + current_tab = "archive" + + def get_queryset(self): + return super().get_queryset().filter(archived=True) + + +class ActiveProductListView(ProductListView): + """A list view for the admins.""" + + current_tab = "products" + + def get_queryset(self): + return super().get_queryset().filter(archived=False) + + +class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView): + """A create view for the admins.""" + + model = Product + form_class = ProductEditForm + template_name = "core/create.jinja" + current_tab = "products" + + +class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): + """An edit view for the admins.""" + + model = Product + form_class = ProductEditForm + pk_url_kwarg = "product_id" + template_name = "core/edit.jinja" + current_tab = "products" + + +class RefillingDeleteView(DeleteView): + """Delete a refilling (for the admins).""" + + model = Refilling + pk_url_kwarg = "refilling_id" + template_name = "core/delete_confirm.jinja" + + def dispatch(self, request, *args, **kwargs): + """We have here a very particular right handling, we can't inherit from CanEditPropMixin.""" + self.object = self.get_object() + if timezone.now() - self.object.date <= timedelta( + minutes=settings.SITH_LAST_OPERATIONS_LIMIT + ) and is_logged_in_counter(request): + self.success_url = reverse( + "counter:details", kwargs={"counter_id": self.object.counter.id} + ) + return super().dispatch(request, *args, **kwargs) + elif self.object.is_owned_by(request.user): + self.success_url = reverse( + "core:user_account", kwargs={"user_id": self.object.customer.user.id} + ) + return super().dispatch(request, *args, **kwargs) + raise PermissionDenied + + +class SellingDeleteView(DeleteView): + """Delete a selling (for the admins).""" + + model = Selling + pk_url_kwarg = "selling_id" + template_name = "core/delete_confirm.jinja" + + def dispatch(self, request, *args, **kwargs): + """We have here a very particular right handling, we can't inherit from CanEditPropMixin.""" + self.object = self.get_object() + if timezone.now() - self.object.date <= timedelta( + minutes=settings.SITH_LAST_OPERATIONS_LIMIT + ) and is_logged_in_counter(request): + self.success_url = reverse( + "counter:details", kwargs={"counter_id": self.object.counter.id} + ) + return super().dispatch(request, *args, **kwargs) + elif self.object.is_owned_by(request.user): + self.success_url = reverse( + "core:user_account", kwargs={"user_id": self.object.customer.user.id} + ) + return super().dispatch(request, *args, **kwargs) + raise PermissionDenied + + +class CounterStatView(DetailView, CounterAdminMixin): + """Show the bar stats.""" + + model = Counter + pk_url_kwarg = "counter_id" + template_name = "counter/stats.jinja" + + def get_context_data(self, **kwargs): + """Add stats to the context.""" + counter: Counter = self.object + semester_start = get_start_of_semester() + office_hours = counter.get_top_barmen() + kwargs = super().get_context_data(**kwargs) + kwargs.update( + { + "counter": counter, + "current_semester": get_semester_code(), + "total_sellings": counter.get_total_sales(since=semester_start), + "top_customers": counter.get_top_customers(since=semester_start)[:100], + "top_barman": office_hours[:100], + "top_barman_semester": ( + office_hours.filter(start__gt=semester_start)[:100] + ), + } + ) + return kwargs + + def dispatch(self, request, *args, **kwargs): + try: + return super().dispatch(request, *args, **kwargs) + except PermissionDenied: + if ( + request.user.is_root + or request.user.is_board_member + or self.get_object().is_owned_by(request.user) + ): + return super(CanEditMixin, self).dispatch(request, *args, **kwargs) + raise PermissionDenied + + +class CounterRefillingListView(CounterAdminTabsMixin, CounterAdminMixin, ListView): + """List of refillings on a counter.""" + + model = Refilling + template_name = "counter/refilling_list.jinja" + current_tab = "counters" + paginate_by = 30 + + def dispatch(self, request, *args, **kwargs): + self.counter = get_object_or_404(Counter, pk=kwargs["counter_id"]) + self.queryset = Refilling.objects.filter(counter__id=self.counter.id) + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + kwargs = super().get_context_data(**kwargs) + kwargs["counter"] = self.counter + return kwargs diff --git a/counter/views/click.py b/counter/views/click.py new file mode 100644 index 00000000..fe948c91 --- /dev/null +++ b/counter/views/click.py @@ -0,0 +1,439 @@ +# +# 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 + +from core.views import CanViewMixin +from counter.forms import NFCCardForm, RefillForm +from counter.models import Counter, Customer, Product, Selling, StudentCard + +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 + self.refill_form = None + 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() + self.refill_form = None + 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 + request.session["not_valid_student_card_uid"] = False + if self.object.type != "BAR": + self.operator = request.user + elif self.customer_is_barman(): + 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 == "add_student_card": + self.add_student_card(request) + elif action == "del_product": + self.del_product(request) + elif action == "refill": + self.refill(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 customer_is_barman(self) -> bool: + barmen = self.object.barmen_list + return self.object.type == "BAR" and self.customer.user in barmen + + 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.customer_is_barman(): + 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 add_student_card(self, request): + """Add a new student card on the customer account.""" + uid = str(request.POST["student_card_uid"]) + if not StudentCard.is_valid(uid): + request.session["not_valid_student_card_uid"] = True + return False + + if not ( + self.object.type == "BAR" + and "counter_token" in request.session + and request.session["counter_token"] == self.object.token + and self.object.is_open + ): + raise PermissionDenied + StudentCard(customer=self.customer, uid=uid).save() + 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.customer_is_barman(): + 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 refill(self, request): + """Refill the customer's account.""" + if not self.object.can_refill(): + raise PermissionDenied + form = RefillForm(request.POST) + if form.is_valid(): + form.instance.counter = self.object + form.instance.operator = self.operator + form.instance.customer = self.customer + form.instance.save() + else: + self.refill_form = form + + 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.customer_is_barman(): + 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["student_cards"] = self.customer.student_cards.all() + kwargs["student_card_input"] = NFCCardForm() + kwargs["basket_total"] = self.sum_basket(self.request) + kwargs["refill_form"] = self.refill_form or RefillForm() + kwargs["student_card_max_uid_size"] = StudentCard.UID_SIZE + kwargs["barmens_can_refill"] = self.object.can_refill() + return kwargs diff --git a/counter/views/eticket.py b/counter/views/eticket.py new file mode 100644 index 00000000..56757fc2 --- /dev/null +++ b/counter/views/eticket.py @@ -0,0 +1,140 @@ +# +# 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" +# +# + +from django.http import Http404, HttpResponse +from django.utils.translation import gettext_lazy as _ +from django.views.generic import DetailView, ListView +from django.views.generic.edit import CreateView, UpdateView + +from core.views import CanViewMixin +from counter.forms import EticketForm +from counter.models import Eticket, Selling + + +class EticketListView(CounterAdminTabsMixin, CounterAdminMixin, ListView): + """A list view for the admins.""" + + model = Eticket + template_name = "counter/eticket_list.jinja" + ordering = ["id"] + current_tab = "etickets" + + +class EticketCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView): + """Create an eticket.""" + + model = Eticket + template_name = "core/create.jinja" + form_class = EticketForm + current_tab = "etickets" + + +class EticketEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): + """Edit an eticket.""" + + model = Eticket + template_name = "core/edit.jinja" + form_class = EticketForm + pk_url_kwarg = "eticket_id" + current_tab = "etickets" + + +class EticketPDFView(CanViewMixin, DetailView): + """Display the PDF of an eticket.""" + + model = Selling + pk_url_kwarg = "selling_id" + + def get(self, request, *args, **kwargs): + from reportlab.graphics import renderPDF + from reportlab.graphics.barcode.qr import QrCodeWidget + from reportlab.graphics.shapes import Drawing + from reportlab.lib.units import cm + from reportlab.lib.utils import ImageReader + from reportlab.pdfgen import canvas + + if not ( + hasattr(self.object, "product") and hasattr(self.object.product, "eticket") + ): + raise Http404 + + eticket = self.object.product.eticket + user = self.object.customer.user + code = "%s %s %s %s" % ( + self.object.customer.user.id, + self.object.product.id, + self.object.id, + self.object.quantity, + ) + code += " " + eticket.get_hash(code)[:8].upper() + response = HttpResponse(content_type="application/pdf") + response["Content-Disposition"] = 'filename="eticket.pdf"' + p = canvas.Canvas(response) + p.setTitle("Eticket") + im = ImageReader("core/static/core/img/eticket.jpg") + width, height = im.getSize() + size = max(width, height) + width = 8 * cm * width / size + height = 8 * cm * height / size + p.drawImage(im, 10 * cm, 25 * cm, width, height) + if eticket.banner: + im = ImageReader(eticket.banner) + width, height = im.getSize() + size = max(width, height) + width = 6 * cm * width / size + height = 6 * cm * height / size + p.drawImage(im, 1 * cm, 25 * cm, width, height) + if user.profile_pict: + im = ImageReader(user.profile_pict.file) + width, height = im.getSize() + size = max(width, height) + width = 150 * width / size + height = 150 * height / size + p.drawImage(im, 10.5 * cm - width / 2, 16 * cm, width, height) + if eticket.event_title: + p.setFont("Helvetica-Bold", 20) + p.drawCentredString(10.5 * cm, 23.6 * cm, eticket.event_title) + if eticket.event_date: + p.setFont("Helvetica-Bold", 16) + p.drawCentredString( + 10.5 * cm, 22.6 * cm, eticket.event_date.strftime("%d %b %Y") + ) # FIXME with a locale + p.setFont("Helvetica-Bold", 14) + p.drawCentredString( + 10.5 * cm, + 15 * cm, + "%s : %d %s" + % (user.get_display_name(), self.object.quantity, str(_("people(s)"))), + ) + p.setFont("Courier-Bold", 14) + qrcode = QrCodeWidget(code) + bounds = qrcode.getBounds() + width = bounds[2] - bounds[0] + height = bounds[3] - bounds[1] + d = Drawing(260, 260, transform=[260.0 / width, 0, 0, 260.0 / height, 0, 0]) + d.add(qrcode) + renderPDF.draw(d, p, 10.5 * cm - 130, 6.1 * cm) + p.drawCentredString(10.5 * cm, 6 * cm, code) + + partners = ImageReader("core/static/core/img/partners.png") + width, height = partners.getSize() + size = max(width, height) + width = width * 2 / 3 + height = height * 2 / 3 + p.drawImage(partners, 0 * cm, 0 * cm, width, height) + + p.showPage() + p.save() + return response diff --git a/counter/views/invoice.py b/counter/views/invoice.py new file mode 100644 index 00000000..19c94c8b --- /dev/null +++ b/counter/views/invoice.py @@ -0,0 +1,88 @@ +# +# 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" +# +# +from datetime import datetime, timedelta +from datetime import timezone as tz + +from django.db.models import F +from django.utils import timezone +from django.views.generic import TemplateView + +from accounting.models import CurrencyField +from counter.models import Refilling, Selling + + +class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView): + template_name = "counter/invoices_call.jinja" + current_tab = "invoices_call" + + def get_context_data(self, **kwargs): + """Add sums to the context.""" + kwargs = super().get_context_data(**kwargs) + kwargs["months"] = Selling.objects.datetimes("date", "month", order="DESC") + if "month" in self.request.GET: + start_date = datetime.strptime(self.request.GET["month"], "%Y-%m") + else: + start_date = datetime( + year=timezone.now().year, + month=(timezone.now().month + 10) % 12 + 1, + day=1, + ) + start_date = start_date.replace(tzinfo=tz.utc) + end_date = (start_date + timedelta(days=32)).replace( + day=1, hour=0, minute=0, microsecond=0 + ) + from django.db.models import Case, Sum, When + + kwargs["sum_cb"] = sum( + [ + r.amount + for r in Refilling.objects.filter( + payment_method="CARD", + is_validated=True, + date__gte=start_date, + date__lte=end_date, + ) + ] + ) + kwargs["sum_cb"] += sum( + [ + s.quantity * s.unit_price + for s in Selling.objects.filter( + payment_method="CARD", + is_validated=True, + date__gte=start_date, + date__lte=end_date, + ) + ] + ) + kwargs["start_date"] = start_date + kwargs["sums"] = ( + Selling.objects.values("club__name") + .annotate( + selling_sum=Sum( + Case( + When( + date__gte=start_date, + date__lt=end_date, + then=F("unit_price") * F("quantity"), + ), + output_field=CurrencyField(), + ) + ) + ) + .exclude(selling_sum=None) + .order_by("-selling_sum") + ) + return kwargs diff --git a/counter/views/main.py b/counter/views/main.py new file mode 100644 index 00000000..0f58eeda --- /dev/null +++ b/counter/views/main.py @@ -0,0 +1,272 @@ +# +# 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 itertools +import re +from datetime import datetime, timedelta +from datetime import timezone as tz +from http import HTTPStatus +from operator import itemgetter +from typing import TYPE_CHECKING +from urllib.parse import parse_qs + +from django import forms +from django.conf import settings +from django.core.exceptions import PermissionDenied +from django.db import DataError, transaction +from django.db.models import F +from django.forms import CheckboxSelectMultiple +from django.forms.models import modelform_factory +from django.http import ( + Http404, + HttpRequest, + HttpResponse, + HttpResponseRedirect, + JsonResponse, +) +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse, reverse_lazy +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django.views.decorators.http import require_POST +from django.views.generic import DetailView, ListView, TemplateView +from django.views.generic.base import View +from django.views.generic.edit import ( + CreateView, + DeleteView, + FormMixin, + FormView, + ProcessFormView, + UpdateView, +) + +from accounting.models import CurrencyField +from core.utils import get_semester_code, get_start_of_semester +from core.views import CanEditMixin, CanViewMixin, TabedViewMixin +from core.views.forms import LoginForm +from counter.forms import ( + CashSummaryFormBase, + CounterEditForm, + EticketForm, + GetUserForm, + NFCCardForm, + ProductEditForm, + RefillForm, + StudentCardForm, +) +from counter.models import ( + CashRegisterSummary, + CashRegisterSummaryItem, + Counter, + Customer, + Eticket, + Permanency, + Product, + ProductType, + Refilling, + Selling, + StudentCard, +) +from counter.utils import is_logged_in_counter + +if TYPE_CHECKING: + from core.models import User + + +class StudentCardDeleteView(DeleteView, CanEditMixin): + """View used to delete a card from a user.""" + + model = StudentCard + template_name = "core/delete_confirm.jinja" + pk_url_kwarg = "card_id" + + def dispatch(self, request, *args, **kwargs): + self.customer = get_object_or_404(Customer, pk=kwargs["customer_id"]) + return super().dispatch(request, *args, **kwargs) + + def get_success_url(self, **kwargs): + return reverse_lazy( + "core:user_prefs", kwargs={"user_id": self.customer.user.pk} + ) + + +class CounterMain( + CounterTabsMixin, CanViewMixin, DetailView, ProcessFormView, FormMixin +): + """The public (barman) view.""" + + model = Counter + template_name = "counter/counter_main.jinja" + pk_url_kwarg = "counter_id" + form_class = ( + GetUserForm # Form to enter a client code and get the corresponding user id + ) + current_tab = "counter" + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + if self.object.type == "BAR" and not ( + "counter_token" in self.request.session + and self.request.session["counter_token"] == self.object.token + ): # 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" + ) + return super().post(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + """We handle here the login form for the barman.""" + if self.request.method == "POST": + self.object = self.get_object() + self.object.update_activity() + kwargs = super().get_context_data(**kwargs) + kwargs["login_form"] = LoginForm() + kwargs["login_form"].fields["username"].widget.attrs["autofocus"] = True + kwargs[ + "login_form" + ].cleaned_data = {} # add_error fails if there are no cleaned_data + if "credentials" in self.request.GET: + kwargs["login_form"].add_error(None, _("Bad credentials")) + if "sellers" in self.request.GET: + kwargs["login_form"].add_error(None, _("User is not barman")) + kwargs["form"] = self.get_form() + kwargs["form"].cleaned_data = {} # same as above + if "bad_location" in self.request.GET: + kwargs["form"].add_error( + None, _("Bad location, someone is already logged in somewhere else") + ) + if self.object.type == "BAR": + kwargs["barmen"] = self.object.barmen_list + elif self.request.user.is_authenticated: + kwargs["barmen"] = [self.request.user] + if "last_basket" in self.request.session: + kwargs["last_basket"] = self.request.session.pop("last_basket") + kwargs["last_customer"] = self.request.session.pop("last_customer") + kwargs["last_total"] = self.request.session.pop("last_total") + kwargs["new_customer_amount"] = self.request.session.pop( + "new_customer_amount" + ) + return kwargs + + def form_valid(self, form): + """We handle here the redirection, passing the user id of the asked customer.""" + self.kwargs["user_id"] = form.cleaned_data["user_id"] + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy("counter:click", args=self.args, kwargs=self.kwargs) + + +@require_POST +def counter_login(request: HttpRequest, counter_id: int) -> HttpResponseRedirect: + """Log a user in a counter. + + A successful login will result in the beginning of a counter duty + for the user. + """ + counter = get_object_or_404(Counter, pk=counter_id) + form = LoginForm(request, data=request.POST) + if not form.is_valid(): + return redirect(counter.get_absolute_url() + "?credentials") + user = form.get_user() + if not counter.sellers.contains(user) or user in counter.barmen_list: + return redirect(counter.get_absolute_url() + "?sellers") + if len(counter.barmen_list) == 0: + counter.gen_token() + request.session["counter_token"] = counter.token + counter.permanencies.create(user=user, start=timezone.now()) + return redirect(counter) + + +@require_POST +def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect: + """End the permanency of a user in this counter.""" + Permanency.objects.filter(counter=counter_id, user=request.POST["user_id"]).update( + end=F("activity") + ) + return redirect("counter:details", counter_id=counter_id) + + +class CounterLastOperationsView(CounterTabsMixin, CanViewMixin, DetailView): + """Provide the last operations to allow barmen to delete them.""" + + model = Counter + pk_url_kwarg = "counter_id" + template_name = "counter/last_ops.jinja" + current_tab = "last_ops" + + def dispatch(self, request, *args, **kwargs): + """We have here again a very particular right handling.""" + self.object = self.get_object() + if is_logged_in_counter(request) and self.object.barmen_list: + return super().dispatch(request, *args, **kwargs) + return HttpResponseRedirect( + reverse("counter:details", kwargs={"counter_id": self.object.id}) + + "?bad_location" + ) + + def get_context_data(self, **kwargs): + """Add form to the context.""" + kwargs = super().get_context_data(**kwargs) + threshold = timezone.now() - timedelta( + minutes=settings.SITH_LAST_OPERATIONS_LIMIT + ) + kwargs["last_refillings"] = ( + self.object.refillings.filter(date__gte=threshold) + .select_related("operator", "customer__user") + .order_by("-id")[:20] + ) + kwargs["last_sellings"] = ( + self.object.sellings.filter(date__gte=threshold) + .select_related("seller", "customer__user") + .order_by("-id")[:20] + ) + return kwargs + + +class CounterActivityView(DetailView): + """Show the bar activity.""" + + model = Counter + pk_url_kwarg = "counter_id" + template_name = "counter/activity.jinja" + + +class StudentCardFormView(FormView): + """Add a new student card.""" + + form_class = StudentCardForm + template_name = "core/create.jinja" + + def dispatch(self, request, *args, **kwargs): + self.customer = get_object_or_404(Customer, pk=kwargs["customer_id"]) + if not StudentCard.can_create(self.customer, request.user): + raise PermissionDenied + return super().dispatch(request, *args, **kwargs) + + def form_valid(self, form): + data = form.clean() + res = super(FormView, self).form_valid(form) + StudentCard(customer=self.customer, uid=data["uid"]).save() + return res + + def get_success_url(self, **kwargs): + return reverse_lazy( + "core:user_prefs", kwargs={"user_id": self.customer.user.pk} + ) diff --git a/counter/views/mixins.py b/counter/views/mixins.py new file mode 100644 index 00000000..4a07d848 --- /dev/null +++ b/counter/views/mixins.py @@ -0,0 +1,121 @@ +# +# 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" +# +# + +from django.conf import settings +from django.core.exceptions import PermissionDenied +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ +from django.views.generic.base import View + +from core.views import TabedViewMixin + + +class CounterAdminMixin(View): + """Protect counter admin section.""" + + edit_group = [settings.SITH_GROUP_COUNTER_ADMIN_ID] + edit_club = [] + + def _test_group(self, user): + return any(user.is_in_group(pk=grp_id) for grp_id in self.edit_group) + + def _test_club(self, user): + return any(c.can_be_edited_by(user) for c in self.edit_club) + + def dispatch(self, request, *args, **kwargs): + if not ( + request.user.is_root + or self._test_group(request.user) + or self._test_club(request.user) + ): + raise PermissionDenied + return super().dispatch(request, *args, **kwargs) + + +class CounterTabsMixin(TabedViewMixin): + def get_tabs_title(self): + return self.object + + def get_list_of_tabs(self): + tab_list = [ + { + "url": reverse_lazy( + "counter:details", kwargs={"counter_id": self.object.id} + ), + "slug": "counter", + "name": _("Counter"), + } + ] + if self.object.type == "BAR": + tab_list.append( + { + "url": reverse_lazy( + "counter:cash_summary", kwargs={"counter_id": self.object.id} + ), + "slug": "cash_summary", + "name": _("Cash summary"), + } + ) + tab_list.append( + { + "url": reverse_lazy( + "counter:last_ops", kwargs={"counter_id": self.object.id} + ), + "slug": "last_ops", + "name": _("Last operations"), + } + ) + return tab_list + + +class CounterAdminTabsMixin(TabedViewMixin): + tabs_title = _("Counter administration") + list_of_tabs = [ + { + "url": reverse_lazy("counter:admin_list"), + "slug": "counters", + "name": _("Counters"), + }, + { + "url": reverse_lazy("counter:product_list"), + "slug": "products", + "name": _("Products"), + }, + { + "url": reverse_lazy("counter:product_list_archived"), + "slug": "archive", + "name": _("Archived products"), + }, + { + "url": reverse_lazy("counter:producttype_list"), + "slug": "product_types", + "name": _("Product types"), + }, + { + "url": reverse_lazy("counter:cash_summary_list"), + "slug": "cash_summary", + "name": _("Cash register summaries"), + }, + { + "url": reverse_lazy("counter:invoices_call"), + "slug": "invoices_call", + "name": _("Invoices call"), + }, + { + "url": reverse_lazy("counter:eticket_list"), + "slug": "etickets", + "name": _("Etickets"), + }, + ]