11 Commits

Author SHA1 Message Date
Kenneth SOARES
0fef2e0071 change MonthField from CharField to DateField 2025-09-09 12:35:10 +02:00
Kenneth SOARES
c7231608a9 fix imports 2025-09-08 13:03:56 +02:00
Kenneth SOARES
c1ff8a9684 fix n+1 issue with InvoiceCall 2025-09-08 12:46:43 +02:00
Kenneth SOARES
494e90f614 date validity verification
fixed template formatting
2025-09-08 12:46:09 +02:00
Kenneth SOARES
efa9f35b45 invoice call form 2025-09-08 12:46:09 +02:00
Kenneth SOARES
5f17ecc739 improved Club related queries
formatted migration file
2025-09-08 12:46:08 +02:00
Kenneth SOARES
9fed57de20 rename is_validated field 2025-09-08 12:46:08 +02:00
Kenneth SOARES
e903198384 MonthField for InvoiceCall 2025-09-08 12:46:08 +02:00
Kenneth SOARES
e990b94941 fix checkbox width 2025-09-08 12:46:08 +02:00
Kenneth SOARES
b04fa90d6e added checkbox for invoice calls
formatting

separated logic for get and post

created custom month field

fixed formatting

fixed imports
2025-09-08 12:46:08 +02:00
Kenneth SOARES
e47e6df9f5 refactored invoice view and template 2025-09-08 12:46:08 +02:00
13 changed files with 291 additions and 259 deletions

View File

@@ -560,7 +560,7 @@ class User(AbstractUser):
"""Determine if the object is owned by the user."""
if hasattr(obj, "is_owned_by") and obj.is_owned_by(self):
return True
if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group_id):
if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group.id):
return True
return self.is_root
@@ -569,14 +569,8 @@ class User(AbstractUser):
if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self):
return True
if hasattr(obj, "edit_groups"):
if (
hasattr(obj, "_prefetched_objects_cache")
and "edit_groups" in obj._prefetched_objects_cache
):
pks = [g.id for g in obj.edit_groups.all()]
else:
pks = list(obj.edit_groups.values_list("id", flat=True))
if any(self.is_in_group(pk=pk) for pk in pks):
for pk in obj.edit_groups.values_list("pk", flat=True):
if self.is_in_group(pk=pk):
return True
if isinstance(obj, User) and obj == self:
return True
@@ -587,17 +581,8 @@ class User(AbstractUser):
if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self):
return True
if hasattr(obj, "view_groups"):
# if "view_groups" has already been prefetched, use
# the prefetch cache, else fetch only the ids, to make
# the query lighter.
if (
hasattr(obj, "_prefetched_objects_cache")
and "view_groups" in obj._prefetched_objects_cache
):
pks = [g.id for g in obj.view_groups.all()]
else:
pks = list(obj.view_groups.values_list("id", flat=True))
if any(self.is_in_group(pk=pk) for pk in pks):
for pk in obj.view_groups.values_list("pk", flat=True):
if self.is_in_group(pk=pk):
return True
return self.can_edit(obj)
@@ -1399,9 +1384,9 @@ class Page(models.Model):
@cached_property
def is_club_page(self):
return (
self.name == settings.SITH_CLUB_ROOT_PAGE
or settings.SITH_CLUB_ROOT_PAGE in [p.name for p in self.get_parent_list()]
club_root_page = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first()
return club_root_page is not None and (
self == club_root_page or club_root_page in self.get_parent_list()
)
@cached_property

View File

@@ -514,6 +514,10 @@ th {
text-align: center;
padding: 5px 10px;
>input[type="checkbox"] {
padding: unset;
}
>ul {
margin-top: 0;
}

View File

@@ -5,12 +5,16 @@
{% endblock %}
{% block content %}
{% if page_list %}
<h3>{% trans %}Page list{% endtrans %}</h3>
<ul>
{% for p in page_list %}
<li><a href="{{ p.get_absolute_url() }}">{{ p.display_name }}</a></li>
<li><a href="{{ p.get_absolute_url() }}">{{ p.get_display_name() }}</a></li>
{% endfor %}
</ul>
{% else %}
{% trans %}There is no page in this website.{% endtrans %}
{% endif %}
{% endblock %}

View File

@@ -12,10 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import F, OuterRef, Subquery
from django.db.models.functions import Coalesce
# This file contains all the views that concern the page model
from django.forms.models import modelform_factory
@@ -46,20 +43,6 @@ class CanEditPagePropMixin(CanEditPropMixin):
class PageListView(CanViewMixin, ListView):
model = Page
template_name = "core/page_list.jinja"
queryset = (
Page.objects.annotate(
display_name=Coalesce(
Subquery(
PageRev.objects.filter(page=OuterRef("id"))
.order_by("-date")
.values("title")[:1]
),
F("name"),
)
)
.prefetch_related("view_groups")
.select_related("parent")
)
class PageView(CanViewMixin, DetailView):

View File

@@ -1,19 +1,14 @@
import math
from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django_celery_beat.models import ClockedSchedule, PeriodicTask
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 (
NFCTextInput,
SelectDate,
SelectDateTime,
)
from core.views.forms import NFCTextInput, SelectDate, SelectDateTime
from core.views.widgets.ajax_select import (
AutoCompleteSelect,
AutoCompleteSelectMultipleGroup,
@@ -25,6 +20,7 @@ from counter.models import (
Counter,
Customer,
Eticket,
InvoiceCall,
Product,
Refilling,
ReturnableProduct,
@@ -164,66 +160,6 @@ class CounterEditForm(forms.ModelForm):
}
class ProductArchiveForm(forms.Form):
"""Form for automatic product archiving."""
enabled = forms.BooleanField(
label=_("Enabled"),
widget=forms.CheckboxInput(attrs={"class": "switch"}),
required=False,
)
archive_at = forms.DateTimeField(
label=_("Date and time of archiving"), widget=SelectDateTime, required=False
)
def __init__(self, *args, product: Product, **kwargs):
self.product = product
self.instance = PeriodicTask.objects.filter(
task="counter.tasks.archive_product", args=f"[{product.id}]"
).first()
super().__init__(*args, **kwargs)
if self.instance:
self.fields["enabled"].initial = self.instance.enabled
self.fields["archive_at"].initial = self.instance.clocked.clocked_time
def clean(self):
cleaned_data = super().clean()
if cleaned_data["enabled"] is True and cleaned_data["archive_at"] is None:
raise ValidationError(
_(
"Automatic archiving cannot be enabled "
"without providing a archiving date."
)
)
def save(self):
if not self.changed_data:
return
if not self.instance:
PeriodicTask.objects.create(
task="counter.tasks.archive_product",
args=f"[{self.product.id}]",
name=f"Archive product {self.product}",
clocked=ClockedSchedule.objects.create(
clocked_time=self.cleaned_data["archive_at"]
),
enabled=self.cleaned_data["enabled"],
one_off=True,
)
return
if (
"archive_at" in self.changed_data
and self.cleaned_data["archive_at"] is None
):
self.instance.delete()
elif "archive_at" in self.changed_data:
self.instance.clocked.clocked_time = self.cleaned_data["archive_at"]
self.instance.clocked.save()
self.instance.enabled = self.cleaned_data["enabled"]
self.instance.save()
return self.instance
class ProductEditForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
@@ -265,19 +201,22 @@ class ProductEditForm(forms.ModelForm):
queryset=Counter.objects.all(),
)
def __init__(self, *args, instance=None, **kwargs):
super().__init__(*args, instance=instance, **kwargs)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.id:
self.fields["counters"].initial = self.instance.counters.all()
self.archive_form = ProductArchiveForm(*args, product=self.instance, **kwargs)
def is_valid(self):
return super().is_valid() and self.archive_form.is_valid()
def save(self, *args, **kwargs):
ret = super().save(*args, **kwargs)
self.instance.counters.set(self.cleaned_data["counters"])
self.archive_form.save()
if self.fields["counters"].initial:
# Remove the product from all counter it was added to
# It will then only be added to selected counters
for counter in self.fields["counters"].initial:
counter.products.remove(self.instance)
counter.save()
for counter in self.cleaned_data["counters"]:
counter.products.add(self.instance)
counter.save()
return ret
@@ -436,3 +375,39 @@ class BaseBasketForm(forms.BaseFormSet):
BasketForm = forms.formset_factory(
ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
)
class InvoiceCallForm(forms.Form):
def __init__(self, *args, month, clubs: list[Club] | None = None, **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")
}
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}
)
def get_club_name(self, club_id):
return f"club_{club_id}"

View File

@@ -0,0 +1,45 @@
# Generated by Django 5.2.3 on 2025-09-09 10:24
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", "0031_alter_counter_options"),
]
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",
},
),
]

View File

@@ -445,8 +445,7 @@ class Product(models.Model):
buying_groups = list(self.buying_groups.all())
if not buying_groups:
return True
res = any(user.is_in_group(pk=group.id) for group in buying_groups)
return res
return any(user.is_in_group(pk=group.id) for group in buying_groups)
@property
def profit(self):
@@ -1363,3 +1362,45 @@ class ReturnableProductBalance(models.Model):
f"return balance of {self.customer} "
f"for {self.returnable.product_id} : {self.balance}"
)
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, 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)
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")
def __str__(self):
return f"invoice call of {self.month} made by {self.club}"

View File

@@ -1,13 +0,0 @@
# Create your tasks here
from celery import shared_task
from counter.models import Product
@shared_task
def archive_product(product_id):
product = Product.objects.get(id=product_id)
product.archived = True
product.save()
product.counters.clear()

View File

@@ -15,24 +15,32 @@
</select>
<input type="submit" value="{% trans %}Go{% endtrans %}" />
</form>
<form method="post" action="">
{% csrf_token %}
<br>
<p>{% trans %}CB Payments{% endtrans %} : {{ sum_cb }} €</p>
<br>
<table>
<thead>
<td>{% trans %}Club{% endtrans %}</td>
<td>{% trans %}Sum{% endtrans %}</td>
<td>{% trans %}Validated{% endtrans %}</td>
</thead>
<tbody>
{% for i in sums %}
{% for data in club_data %}
<tr>
<td>{{ i['club__name'] }}</td>
<td>{{ i['selling_sum'] }} €</td>
<td>{{ data.club.name }}</td>
<td>{{"%.2f"|format(data.sum)}} €</td>
<td>
{{ form[form.get_club_name(data.club.id)] }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<input type="hidden" name="month" value="{{ start_date|date('Y-m') }}">
<button type="submit">{% trans %}Save validation{% endtrans %}</button>
</form>
{% endblock %}

View File

@@ -1,31 +0,0 @@
{% extends "core/base.jinja" %}
{% block content %}
{% if object %}
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
{% else %}
<h2>{% trans %}Product creation{% endtrans %}</h2>
{% endif %}
<form method="post">
{% csrf_token %}
{{ form.as_p() }}
<br />
<h3>{% trans %}Automatic archiving{% endtrans %}</h3>
<p>
<em>
{%- trans trimmed -%}
Automatic archiving allows you to mark a product as archived
and remove it from all its counters at a specified time and date.
{%- endtrans -%}
</em>
</p>
<fieldset x-data="{enabled: {{ form.archive_form.enabled.initial|tojson }}}">
{{ form.archive_form.as_p() }}
</fieldset>
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock %}

View File

@@ -147,7 +147,7 @@ class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
model = Product
form_class = ProductEditForm
template_name = "counter/product_form.jinja"
template_name = "core/create.jinja"
current_tab = "products"
@@ -157,7 +157,7 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
model = Product
form_class = ProductEditForm
pk_url_kwarg = "product_id"
template_name = "counter/product_form.jinja"
template_name = "core/edit.jinja"
current_tab = "products"

View File

@@ -12,15 +12,17 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from datetime import datetime, timedelta
from datetime import date, datetime, timedelta
from datetime import timezone as tz
from django.db.models import F
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 counter.fields import CurrencyField
from counter.models import Refilling, Selling
from counter.forms import InvoiceCallForm
from counter.models import Club, InvoiceCall, Refilling, Selling
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
@@ -28,12 +30,30 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
template_name = "counter/invoices_call.jinja"
current_tab = "invoices_call"
def get(self, request, *args, **kwargs):
month_str = request.GET.get("month")
if month_str:
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_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:
month_str = self.request.GET.get("month")
if month_str:
try:
start_date = datetime.strptime(self.request.GET["month"], "%Y-%m")
except ValueError:
return redirect("counter:invoices_call")
else:
start_date = datetime(
year=timezone.now().year,
@@ -46,30 +66,23 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
)
from django.db.models import Case, Sum, When
kwargs["sum_cb"] = sum(
[
r.amount
for r in Refilling.objects.filter(
kwargs["sum_cb"] = 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(
).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"] = (
kwargs["sums"] = list(
Selling.objects.values("club__name")
.annotate(
selling_sum=Sum(
@@ -86,4 +99,56 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
.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")
if not month_str:
return self.get(request, *args, **kwargs)
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', '')}")

View File

@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-14 01:35+0200\n"
"POT-Creation-Date: 2025-09-01 18:18+0200\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -561,7 +561,6 @@ msgstr ""
#: core/templates/core/user_godfathers_tree.jinja
#: core/templates/core/user_preferences.jinja
#: counter/templates/counter/cash_register_summary.jinja
#: counter/templates/counter/product_form.jinja
#: forum/templates/forum/reply.jinja
#: subscription/templates/subscription/fragments/creation_form.jinja
#: trombi/templates/trombi/comment.jinja
@@ -1714,8 +1713,8 @@ msgid ""
"AE UTBM is a voluntary organisation run by UTBM students. It organises "
"student life at UTBM and manages its student facilities."
msgstr ""
"L'AE UTBM est une association bénévole gérée par les étudiants de l'UTBM. "
"Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie."
"L'AE UTBM est une association bénévole gérée par les étudiants de "
"l'UTBM. Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie."
#: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja
msgid "Contacts"
@@ -2158,6 +2157,10 @@ msgstr ""
msgid "Page history"
msgstr "Historique de la page"
#: core/templates/core/page_list.jinja
msgid "There is no page in this website."
msgstr "Il n'y a pas de page sur ce site web."
#: core/templates/core/page_prop.jinja
msgid "Page properties"
msgstr "Propriétés de la page"
@@ -2893,20 +2896,6 @@ msgstr "Cet UID est invalide"
msgid "User not found"
msgstr "Utilisateur non trouvé"
#: counter/forms.py
msgid "Enabled"
msgstr "Activé"
#: counter/forms.py
msgid "Date and time of archiving"
msgstr "Date et heure de l'archivage"
#: counter/forms.py
msgid ""
"Automatic archiving cannot be enabled without providing a archiving date."
msgstr ""
"L'archivage automatique ne peut pas activé sans fournir une date d'archivage."
#: counter/forms.py
msgid ""
"Describe the product. If it's an event's click, give some insights about it, "
@@ -3559,29 +3548,6 @@ msgstr ""
"votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura "
"aucune conséquence autre que le retrait de l'argent de votre compte."
#: counter/templates/counter/product_form.jinja
#, python-format
msgid "Edit product %(name)s"
msgstr "Édition du produit %(name)s"
#: counter/templates/counter/product_form.jinja
#, fuzzy
#| msgid "Product state"
msgid "Product creation"
msgstr "Etat du produit"
#: counter/templates/counter/product_form.jinja
msgid "Automatic archiving"
msgstr "Archivage automatique"
#: counter/templates/counter/product_form.jinja
msgid ""
"Automatic archiving allows you to mark a product as archived and remove it "
"from all its counters at a specified time and date."
msgstr ""
"L'archivage automatique permet de marquer un produit comme archivé et de le "
"retirer de tous ses comptoirs à une heure et une date voulues."
#: counter/templates/counter/product_list.jinja
msgid "Product list"
msgstr "Liste des produits"