Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions core/static/core/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,10 @@ th {
text-align: center;
padding: 5px 10px;

>input[type="checkbox"] {
padding: unset;
}

>ul {
margin-top: 0;
}
Expand Down
9 changes: 9 additions & 0 deletions counter/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
Counter,
Customer,
Eticket,
InvoiceCall,
Permanency,
Product,
ProductType,
Expand Down Expand Up @@ -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"
52 changes: 51 additions & 1 deletion counter/forms.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
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 _
from django_celery_beat.models import ClockedSchedule
from phonenumber_field.widgets import RegionalPhoneNumberWidget

from club.models import Club
from club.widgets.ajax_select import AutoCompleteSelectClub
from core.models import User
from core.views.forms import (
Expand All @@ -29,10 +32,12 @@
Counter,
Customer,
Eticket,
InvoiceCall,
Product,
Refilling,
ReturnableProduct,
ScheduledProductAction,
Selling,
StudentCard,
get_product_actions,
)
Expand Down Expand Up @@ -478,3 +483,48 @@ def _check_recorded_products(self, customer: Customer):
BasketForm = forms.formset_factory(
BasketProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
)


class InvoiceCallForm(forms.Form):
def __init__(self, *args, month: date, **kwargs):
super().__init__(*args, **kwargs)
self.month = month
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
}

def save(self):
invoice_calls = [
InvoiceCall(
month=self.month,
club_id=club.id,
is_validated=self.cleaned_data.get(str(club.id), False),
)
for club in self.clubs
]
InvoiceCall.objects.bulk_create(
invoice_calls,
update_conflicts=True,
update_fields=["is_validated"],
unique_fields=["month", "club"],
)
51 changes: 51 additions & 0 deletions counter/migrations/0033_invoicecall.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Generated by Django 5.2.3 on 2025-10-15 21:54

import django.db.models.deletion
from django.db import migrations, models

import counter.models


class Migration(migrations.Migration):
dependencies = [
("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"),
("counter", "0032_scheduledproductaction"),
]

operations = [
migrations.CreateModel(
name="InvoiceCall",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"is_validated",
models.BooleanField(default=False, verbose_name="is validated"),
),
("month", counter.models.MonthField(verbose_name="invoice date")),
(
"club",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="club.club"
),
),
],
options={
"verbose_name": "Invoice call",
"verbose_name_plural": "Invoice calls",
"constraints": [
models.UniqueConstraint(
fields=("club", "month"),
name="counter_invoicecall_unique_club_month",
)
],
},
),
]
47 changes: 47 additions & 0 deletions counter/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from __future__ import annotations

import base64
import contextlib
import os
import random
import string
Expand Down Expand Up @@ -1395,3 +1396,49 @@ def validate_unique(self, *args, **kwargs):
# adapted in the case of scheduled product action,
# so we skip it and execute directly Model.validate_unique
return super(PeriodicTask, self).validate_unique(*args, **kwargs)


class MonthField(models.DateField):
description = _("Year + month field (day forced to 1)")
default_error_messages = {
"invalid": _(
"“%(value)s” value has an invalid date format. It must be "
"in YYYY-MM format."
),
"invalid_date": _(
"“%(value)s” value has the correct format (YYYY-MM) "
"but it is an invalid date."
),
}

def to_python(self, value):
if isinstance(value, str):
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):
is_validated = models.BooleanField(verbose_name=_("is validated"), default=False)
club = models.ForeignKey(Club, on_delete=models.CASCADE)
month = MonthField(verbose_name=_("invoice date"))

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}"
42 changes: 26 additions & 16 deletions counter/templates/counter/invoices_call.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,34 @@
</select>
<input type="submit" value="{% trans %}Go{% endtrans %}" />
</form>

<br>
<p>{% trans %}CB Payments{% endtrans %} : {{ sum_cb }} €</p>
<br>
<table>
<thead>
<td>{% trans %}Club{% endtrans %}</td>
<td>{% trans %}Sum{% endtrans %}</td>
</thead>
<tbody>
{% for i in sums %}
{% include "core/base/notifications.jinja" %}
<form method="post" action="">
{% csrf_token %}
<table>
<thead>
<tr>
<td>{{ i['club__name'] }}</td>
<td>{{ i['selling_sum'] }} €</td>
<td>{% trans %}Club{% endtrans %}</td>
<td>{% trans %}Sum{% endtrans %}</td>
<td>{% trans %}Validated{% endtrans %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}



</thead>
<tbody>
{% for invoice in invoices %}
<tr>
<td>{{ invoice.club__name }}</td>
<td>{{ "%.2f"|format(invoice.selling_sum) }} €</td>
<td>
{{ form[invoice.club_id|string] }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<input type="hidden" name="month" value="{{ start_date|date('Y-m') }}">
<button type="submit">{% trans %}Save{% endtrans %}</button>
</form>
{% endblock %}
76 changes: 76 additions & 0 deletions counter/tests/test_invoices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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 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
@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=month)
invoice.refresh_from_db()
assert not invoice.is_validated
assert invoice.month == date(2025, 10, 1)


@pytest.mark.django_db
def test_invoice_call_invalid_month_string():
club = baker.make(Club)

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()
Loading
Loading