diff --git a/counter/admin.py b/counter/admin.py index de1d9d0b..ed24dd23 100644 --- a/counter/admin.py +++ b/counter/admin.py @@ -22,6 +22,7 @@ from counter.models import ( Counter, Customer, Eticket, + InvoiceCall, Permanency, Product, ProductType, @@ -160,3 +161,11 @@ class CashRegisterSummaryAdmin(SearchModelAdmin): class EticketAdmin(SearchModelAdmin): list_display = ("product", "event_date", "event_title") search_fields = ("product__name", "event_title") + + +@admin.register(InvoiceCall) +class InvoiceCallAdmin(SearchModelAdmin): + list_display = ("club", "month", "is_validated") + search_fields = ("club__name",) + list_filter = (("club", admin.RelatedOnlyFieldListFilter),) + date_hierarchy = "month" diff --git a/counter/forms.py b/counter/forms.py index b2e9a678..5e144d44 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -1,9 +1,11 @@ import json import math import uuid +from datetime import date +from dateutil.relativedelta import relativedelta from django import forms -from django.db.models import Q +from django.db.models import Exists, OuterRef, Q from django.forms import BaseModelFormSet from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ @@ -35,6 +37,7 @@ from counter.models import ( Refilling, ReturnableProduct, ScheduledProductAction, + Selling, StudentCard, get_product_actions, ) @@ -483,36 +486,45 @@ BasketForm = forms.formset_factory( class InvoiceCallForm(forms.Form): - def __init__(self, *args, month, clubs: list[Club] | None = None, **kwargs): + def __init__(self, *args, month: date, **kwargs): super().__init__(*args, **kwargs) self.month = month - self.clubs = clubs - - if self.clubs is None: - self.clubs = [] - - invoices = { - i["club_id"]: i["is_validated"] - for i in InvoiceCall.objects.filter( - club__in=self.clubs, month=self.month - ).values("club_id", "is_validated") + self.clubs = list( + Club.objects.filter( + Exists( + Selling.objects.filter( + club=OuterRef("pk"), + date__gte=month, + date__lte=month + relativedelta(months=1), + ) + ) + ).annotate( + validated_invoice=Exists( + InvoiceCall.objects.filter( + club=OuterRef("pk"), month=month, is_validated=True + ) + ) + ) + ) + self.fields = { + str(club.id): forms.BooleanField( + required=False, initial=club.validated_invoice + ) + for club in self.clubs } - for club in self.clubs: - is_validated = invoices.get(club.id, False) - - self.fields[f"club_{club.id}"] = forms.BooleanField( - required=False, initial=is_validated - ) - def save(self): - for club in self.clubs: - field_name = f"club_{club.id}" - is_validated = self.cleaned_data.get(field_name, False) - - InvoiceCall.objects.update_or_create( - month=self.month, club=club, defaults={"is_validated": is_validated} + invoice_calls = [ + InvoiceCall( + month=self.month, + club_id=club.id, + is_validated=self.cleaned_data.get(str(club.id), False), ) - - def get_club_name(self, club_id): - return f"club_{club_id}" + for club in self.clubs + ] + InvoiceCall.objects.bulk_create( + invoice_calls, + update_conflicts=True, + update_fields=["is_validated"], + unique_fields=["month", "club"], + ) diff --git a/counter/migrations/0032_invoicecall.py b/counter/migrations/0033_invoicecall.py similarity index 78% rename from counter/migrations/0032_invoicecall.py rename to counter/migrations/0033_invoicecall.py index b02edf20..e9fb0d49 100644 --- a/counter/migrations/0032_invoicecall.py +++ b/counter/migrations/0033_invoicecall.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.3 on 2025-09-09 10:24 +# Generated by Django 5.2.3 on 2025-10-15 21:54 import django.db.models.deletion from django.db import migrations, models @@ -9,7 +9,7 @@ import counter.models class Migration(migrations.Migration): dependencies = [ ("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"), - ("counter", "0031_alter_counter_options"), + ("counter", "0032_scheduledproductaction"), ] operations = [ @@ -40,6 +40,12 @@ class Migration(migrations.Migration): options={ "verbose_name": "Invoice call", "verbose_name_plural": "Invoice calls", + "constraints": [ + models.UniqueConstraint( + fields=("club", "month"), + name="counter_invoicecall_unique_club_month", + ) + ], }, ), ] diff --git a/counter/models.py b/counter/models.py index 6e5bdba4..e08e3905 100644 --- a/counter/models.py +++ b/counter/models.py @@ -15,6 +15,7 @@ from __future__ import annotations import base64 +import contextlib import os import random import string @@ -1411,19 +1412,18 @@ class MonthField(models.DateField): } def to_python(self, value): - if isinstance(value, date): - return value.replace(day=1) - if isinstance(value, str): - try: - year, month = map(int, value.split("-")) - return date(year, month, 1) - except (ValueError, TypeError) as err: - raise ValueError( - self.error_messages["invalid"] % {"value": value} - ) from err - - return super().to_python(value) + with contextlib.suppress(ValueError): + # If the string is given as YYYY-mm, try to parse it. + # If it fails, it means that the string may be in the form YYYY-mm-dd + # or in an invalid format. + # Whatever the case, we let Django deal with it + # and raise an error if needed + value = datetime.strptime(value, "%Y-%m") + value = super().to_python(value) + if value is None: + return None + return value.replace(day=1) class InvoiceCall(models.Model): @@ -1434,10 +1434,11 @@ class InvoiceCall(models.Model): class Meta: verbose_name = _("Invoice call") verbose_name_plural = _("Invoice calls") + constraints = [ + models.UniqueConstraint( + fields=["club", "month"], name="counter_invoicecall_unique_club_month" + ) + ] def __str__(self): return f"invoice call of {self.month} made by {self.club}" - - def save(self, *args, **kwargs): - self.month = self._meta.get_field("month").to_python(self.month) - super().save(*args, **kwargs) diff --git a/counter/templates/counter/invoices_call.jinja b/counter/templates/counter/invoices_call.jinja index 9085d05d..8dd651e9 100644 --- a/counter/templates/counter/invoices_call.jinja +++ b/counter/templates/counter/invoices_call.jinja @@ -16,31 +16,33 @@ +
+

{% trans %}CB Payments{% endtrans %} : {{ sum_cb }} €

+
+ {% include "core/base/notifications.jinja" %}
{% csrf_token %} -
-

{% trans %}CB Payments{% endtrans %} : {{ sum_cb }} €

-
- - - - + + + + + - {% for data in club_data %} + {% for invoice in invoices %} - - + + {% endfor %}
{% trans %}Club{% endtrans %}{% trans %}Sum{% endtrans %}{% trans %}Validated{% endtrans %}
{% trans %}Club{% endtrans %}{% trans %}Sum{% endtrans %}{% trans %}Validated{% endtrans %}
{{ data.club.name }}{{"%.2f"|format(data.sum)}} €{{ invoice.club__name }}{{ "%.2f"|format(invoice.selling_sum) }} € - {{ form[form.get_club_name(data.club.id)] }} + {{ form[invoice.club_id|string] }}
- +
{% endblock %} \ No newline at end of file diff --git a/counter/tests/test_invoices.py b/counter/tests/test_invoices.py index 1aebca5f..099ab1b9 100644 --- a/counter/tests/test_invoices.py +++ b/counter/tests/test_invoices.py @@ -1,27 +1,31 @@ -from datetime import date +from datetime import date, datetime import pytest +from dateutil.relativedelta import relativedelta +from django.contrib.auth.models import Permission +from django.core.exceptions import ValidationError +from django.test import Client +from django.urls import reverse +from django.utils.timezone import localdate from model_bakery import baker +from pytest_django.asserts import assertRedirects from club.models import Club -from counter.models import InvoiceCall +from core.models import User +from counter.baker_recipes import sale_recipe +from counter.forms import InvoiceCallForm +from counter.models import Customer, InvoiceCall, Selling @pytest.mark.django_db -def test_invoice_date_with_date(): +@pytest.mark.parametrize( + "month", [date(2025, 10, 20), "2025-10", datetime(2025, 10, 15, 12, 30)] +) +def test_invoice_date_with_date(month: date | datetime | str): club = baker.make(Club) - invoice = InvoiceCall.objects.create(club=club, month=date(2025, 10, 20)) - + invoice = InvoiceCall.objects.create(club=club, month=month) + invoice.refresh_from_db() assert not invoice.is_validated - assert str(invoice) == f"invoice call of {invoice.month} made by {club}" - assert invoice.month == date(2025, 10, 1) - - -@pytest.mark.django_db -def test_invoice_date_with_string(): - club = baker.make(Club) - invoice = InvoiceCall.objects.create(club=club, month="2025-10") - assert invoice.month == date(2025, 10, 1) @@ -29,5 +33,44 @@ def test_invoice_date_with_string(): def test_invoice_call_invalid_month_string(): club = baker.make(Club) - with pytest.raises(ValueError): + with pytest.raises(ValidationError): InvoiceCall.objects.create(club=club, month="2025-13") + + +@pytest.mark.django_db +@pytest.mark.parametrize("query", [None, {"month": "2025-08"}]) +def test_invoice_call_view(client: Client, query: dict | None): + user = baker.make( + User, + user_permissions=[ + *Permission.objects.filter( + codename__in=["view_invoicecall", "change_invoicecall"] + ) + ], + ) + client.force_login(user) + url = reverse("counter:invoices_call", query=query) + assert client.get(url).status_code == 200 + assertRedirects(client.post(url), url) + + +@pytest.mark.django_db +def test_invoice_call_form(): + Selling.objects.all().delete() + month = localdate() - relativedelta(months=1) + clubs = baker.make(Club, _quantity=2) + recipe = sale_recipe.extend(date=month, customer=baker.make(Customer, amount=10000)) + recipe.make(club=clubs[0], quantity=2, unit_price=200) + recipe.make(club=clubs[0], quantity=3, unit_price=5) + recipe.make(club=clubs[1], quantity=20, unit_price=10) + form = InvoiceCallForm( + month=month, data={str(clubs[0].id): True, str(clubs[1].id): False} + ) + assert form.is_valid() + form.save() + assert InvoiceCall.objects.filter( + club=clubs[0], month=month, is_validated=True + ).exists() + assert InvoiceCall.objects.filter( + club=clubs[1], month=month, is_validated=False + ).exists() diff --git a/counter/views/invoice.py b/counter/views/invoice.py index a215c16a..63585796 100644 --- a/counter/views/invoice.py +++ b/counter/views/invoice.py @@ -12,165 +12,82 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # -from datetime import date, datetime, timedelta -from datetime import timezone as tz +from datetime import datetime +from urllib.parse import urlencode -from django.db.models import Exists, F, OuterRef -from django.shortcuts import redirect -from django.utils import timezone -from django.views.generic import TemplateView +from dateutil.relativedelta import relativedelta +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.contrib.messages.views import SuccessMessageMixin +from django.db.models import F, Sum +from django.utils.timezone import localdate, make_aware +from django.utils.translation import gettext_lazy as _ +from django.views.generic import FormView -from counter.fields import CurrencyField from counter.forms import InvoiceCallForm -from counter.models import Club, InvoiceCall, Refilling, Selling -from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin +from counter.models import Refilling, Selling +from counter.views.mixins import CounterAdminTabsMixin -class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView): +class InvoiceCallView( + CounterAdminTabsMixin, PermissionRequiredMixin, SuccessMessageMixin, FormView +): template_name = "counter/invoices_call.jinja" current_tab = "invoices_call" + permission_required = ["counter.view_invoicecall", "counter.change_invoicecall"] + form_class = InvoiceCallForm + success_message = _("Invoice calls status has been updated.") - def dispatch(self, request, *args, **kwargs): - previous_month = (date.today().replace(day=1) - timedelta(days=1)).strftime( - "%Y-%m" - ) + def get_month(self): + kwargs = self.request.GET or self.request.POST + if "month" in kwargs: + return make_aware(datetime.strptime(kwargs["month"], "%Y-%m")) + return localdate().replace(day=1) - relativedelta(months=1) - if request.method == "GET" and "month" not in request.GET: - request.GET = request.GET.copy() - request.GET["month"] = previous_month + def get_form_kwargs(self): + return super().get_form_kwargs() | {"month": self.get_month()} - if request.method == "POST" and "month" not in request.POST: - request.POST = request.POST.copy() - request.POST["month"] = previous_month + def form_valid(self, form): + form.save() + return super().form_valid(form) - return super().dispatch(request, *args, **kwargs) - - def get(self, request, *args, **kwargs): - month_str = request.GET.get("month") - - try: - start_date = datetime.strptime(month_str, "%Y-%m").date() - today = timezone.now().date().replace(day=1) - if start_date > today: - return redirect("counter:invoices_call") - except ValueError: - return redirect("counter:invoices_call") - return super().get(request, *args, **kwargs) + def get_success_url(self): + # redirect to the month from which the request is originated + url = self.request.path + kwargs = self.request.GET or self.request.POST + if "month" in kwargs: + query = urlencode({"month": kwargs["month"]}) + url += f"?{query}" + return url def get_context_data(self, **kwargs): """Add sums to the context.""" kwargs = super().get_context_data(**kwargs) - - months_with_sellings = list( - Selling.objects.datetimes("date", "month", order="DESC") - ) - - first_month = months_with_sellings[-1].replace(day=1).date() - last_month = (date.today().replace(day=1) - timedelta(days=1)).replace(day=1) - current = last_month - months = [] - - while current >= first_month: - months.append(current) - current = (current.replace(day=1) - timedelta(days=1)).replace(day=1) - - kwargs["months"] = months - - month_str = self.request.GET.get("month") - - try: - start_date = datetime.strptime(self.request.GET["month"], "%Y-%m") - except ValueError: - return redirect("counter:invoices_call") - - 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["months"] = Selling.objects.datetimes("date", "month", order="DESC") + start_date = self.get_month() + end_date = start_date + relativedelta(months=1) kwargs["sum_cb"] = Refilling.objects.filter( payment_method="CARD", is_validated=True, date__gte=start_date, date__lte=end_date, - ).aggregate(amount=Sum(F("amount"), default=0))["amount"] - - kwargs["sum_cb"] += Selling.objects.filter( - payment_method="CARD", - is_validated=True, - date__gte=start_date, - date__lte=end_date, - ).aggregate(amount=Sum(F("quantity") * F("unit_price"), default=0))["amount"] - - kwargs["start_date"] = start_date - - kwargs["sums"] = list( - 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(), - ) - ) + ).aggregate(res=Sum("amount", default=0))["res"] + kwargs["sum_cb"] += ( + Selling.objects.filter( + payment_method="CARD", + is_validated=True, + date__gte=start_date, + date__lte=end_date, ) + .annotate(amount=F("unit_price") * F("quantity")) + .aggregate(res=Sum("amount", default=0))["res"] + ) + kwargs["start_date"] = start_date + kwargs["invoices"] = ( + Selling.objects.filter(date__gte=start_date, date__lt=end_date) + .values("club_id", "club__name") + .annotate(selling_sum=Sum(F("unit_price") * F("quantity"))) .exclude(selling_sum=None) .order_by("-selling_sum") ) - - club_names = [i["club__name"] for i in kwargs["sums"]] - clubs = Club.objects.filter(name__in=club_names) - - invoice_calls = InvoiceCall.objects.filter(month=month_str, club__in=clubs) - invoice_statuses = {ic.club.name: ic.is_validated for ic in invoice_calls} - - kwargs["form"] = InvoiceCallForm(clubs=clubs, month=month_str) - - kwargs["club_data"] = [] - for club in clubs: - selling_sum = next( - ( - item["selling_sum"] - for item in kwargs["sums"] - if item["club__name"] == club.name - ), - 0, - ) - kwargs["club_data"].append( - { - "club": club, - "sum": selling_sum, - "validated": invoice_statuses.get(club.name, False), - } - ) - return kwargs - - def post(self, request, *args, **kwargs): - month_str = request.POST.get("month") - - try: - start_date = datetime.strptime(month_str, "%Y-%m") - start_date = date(start_date.year, start_date.month, 1) - except ValueError: - return redirect(request.path) - - selling_subquery = Selling.objects.filter( - club=OuterRef("pk"), - date__year=start_date.year, - date__month=start_date.month, - ) - - clubs = Club.objects.filter(Exists(selling_subquery)) - - form = InvoiceCallForm(request.POST, clubs=clubs, month=month_str) - - if form.is_valid(): - form.save() - - return redirect(f"{request.path}?month={request.POST.get('month', '')}") diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 8c151488..3c790c88 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-09-26 17:36+0200\n" +"POT-Creation-Date: 2025-10-17 13:41+0200\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -556,6 +556,7 @@ msgstr "" #: core/templates/core/user_godfathers_tree.jinja #: core/templates/core/user_preferences.jinja #: counter/templates/counter/cash_register_summary.jinja +#: counter/templates/counter/invoices_call.jinja #: counter/templates/counter/product_form.jinja #: forum/templates/forum/reply.jinja #: subscription/templates/subscription/fragments/creation_form.jinja @@ -689,15 +690,15 @@ msgstr "Vente" msgid "Mailing list" msgstr "Listes de diffusion" +#: club/views.py +msgid "You are now a member of this club." +msgstr "Vous êtes maintenant membre de ce club." + #: club/views.py #, python-format msgid "%(user)s has been added to club." msgstr "%(user)s a été ajouté au club." -#: club/views.py -msgid "You are now a member of this club." -msgstr "Vous êtes maintenant membre de ce club." - #: com/forms.py msgid "Format: 16:9 | Resolution: 1920x1080" msgstr "Format : 16:9 | Résolution : 1920x1080" @@ -3314,6 +3315,40 @@ msgstr "Changement des comptoirs" msgid "Product scheduled action" msgstr "Actions sur produit planifiées" +#: counter/models.py +msgid "Product actions must declare a clocked schedule." +msgstr "Les actions sur les produits doivent avoir un horaire planifié." + +#: counter/models.py +msgid "Year + month field (day forced to 1)" +msgstr "Champ Année + mois (jour forcé à 1)" + +#: counter/models.py +#, python-format +msgid "" +"“%(value)s” value has an invalid date format. It must be in YYYY-MM format." +msgstr "" +"La valeur « %(value)s » a un format de date invalide. Ce doit être au format " +"YYYY-MM." + +#: counter/models.py +#, python-format +msgid "" +"“%(value)s” value has the correct format (YYYY-MM) but it is an invalid date." +msgstr "La valeur « %(value)s » a le bon format, mais est une date invalide." + +#: counter/models.py +msgid "invoice date" +msgstr "date de la facture" + +#: counter/models.py +msgid "Invoice call" +msgstr "Appel à facture" + +#: counter/models.py +msgid "Invoice calls" +msgstr "Appels à facture" + #: counter/templates/counter/activity.jinja #, python-format msgid "%(counter_name)s activity" @@ -3544,6 +3579,10 @@ msgstr "Payements en Carte Bancaire" msgid "Sum" msgstr "Somme" +#: counter/templates/counter/invoices_call.jinja +msgid "Validated" +msgstr "Validé" + #: counter/templates/counter/last_ops.jinja #, python-format msgid "%(counter_name)s last operations" @@ -3833,6 +3872,10 @@ msgstr "L'utilisateur n'est pas barman." msgid "Bad location, someone is already logged in somewhere else" msgstr "Mauvais comptoir, quelqu'un est déjà connecté ailleurs" +#: counter/views/invoice.py +msgid "Invoice calls status has been updated." +msgstr "Le statut des appels à facture a été mis à jour." + #: counter/views/mixins.py msgid "Cash summary" msgstr "Relevé de caisse"