Merge pull request #859 from ae-utbm/account-pages

Optimize user account pages
This commit is contained in:
thomas girod 2024-10-08 19:55:45 +02:00 committed by GitHub
commit 2111a2c67e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 226 additions and 138 deletions

View File

@ -1,6 +1,6 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% macro monthly(obj) %} {% macro monthly(objects) %}
<div> <div>
<table> <table>
<thead> <thead>
@ -11,17 +11,18 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for array in obj %} {% for object in objects %}
{% for dict in array %} {% set link=url(
{% if dict['sum'] != 0 %} 'core:user_account_detail',
{% set link=url('core:user_account_detail', user_id=profile.id, year=dict['date'].year, month=dict['date'].month) %} user_id=profile.id,
<tr> year=object['grouped_date'].year,
<td><a href="{{ link }}">{{ dict['date'].year }}</a></td> month=object['grouped_date'].month
<td><a href="{{ link }}">{{ dict['date']|date("E") }}</a></td> ) %}
<td><a href="{{ link }}">{{ dict['sum'] }} €</a></td> <tr>
</tr> <td><a href="{{ link }}">{{ object["grouped_date"]|date("Y") }}</a></td>
{% endif %} <td><a href="{{ link }}">{{ object["grouped_date"]|date("E") }}</a></td>
{% endfor %} <td><a href="{{ link }}">{{ "%.2f"|format(object["total"]) }} €</a></td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@ -37,19 +38,15 @@
<h3>{% trans %}User account{% endtrans %}</h3> <h3>{% trans %}User account{% endtrans %}</h3>
<p>{% trans %}Amount: {% endtrans %}{{ customer.amount }} €</p> <p>{% trans %}Amount: {% endtrans %}{{ customer.amount }} €</p>
<div id="drop"> <div id="drop">
{% set bought = customer.buyings.exists() %} {% if buyings_month %}
{% set refilled = customer.refillings.exists() %} <h5>{% trans %}Account purchases{% endtrans %}</h5>
{% if bought or refilled %} {{ monthly(buyings_month) }}
{% if bought %}
<h5>{% trans %}Account purchases{% endtrans %}</h5>
{{ monthly(buyings_month) }}
{% endif %}
{% if refilled %}
<h5>{% trans %}Reloads{% endtrans %}</h5>
{{ monthly(refilling_month) }}
{% endif %}
{% endif %} {% endif %}
{% if customer.user.invoices.exists() %} {% if refilling_month %}
<h5>{% trans %}Reloads{% endtrans %}</h5>
{{ monthly(refilling_month) }}
{% endif %}
{% if invoices_month %}
<h5>{% trans %}Eboutic invoices{% endtrans %}</h5> <h5>{% trans %}Eboutic invoices{% endtrans %}</h5>
{{ monthly(invoices_month) }} {{ monthly(invoices_month) }}
{% endif %} {% endif %}
@ -58,7 +55,11 @@
<div> <div>
<ul> <ul>
{% for s in etickets %} {% for s in etickets %}
<li><a href="{{ url('counter:eticket_pdf', selling_id=s.id) }}">{{ s.quantity }} x {{ s.product.eticket }}</a></li> <li>
<a href="{{ url('counter:eticket_pdf', selling_id=s.id) }}">
{{ s.quantity }} x {{ s.product.eticket }}
</a>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>

View File

@ -5,44 +5,49 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if customer %} <h3>{% trans %}User account{% endtrans %}</h3>
<h3>{% trans %}User account{% endtrans %}</h3> <p>{% trans %}Amount: {% endtrans %}{{ customer.amount }} €</p>
<p>{% trans %}Amount: {% endtrans %}{{ customer.amount }} €</p> <p><a href="{{ url('core:user_account', user_id=profile.id) }}">{% trans %}Back{% endtrans %}</a></p>
<p><a href="{{ url('core:user_account', user_id=profile.id) }}">{% trans %}Back{% endtrans %}</a></p> {% if purchases %}
{% if customer.buyings.exists() %} <h4>{% trans %}Account purchases{% endtrans %}</h4>
<h4>{% trans %}Account purchases{% endtrans %}</h4> <table>
<table> <thead>
<thead> <tr>
<td>{% trans %}Date{% endtrans %}</td>
<td>{% trans %}Counter{% endtrans %}</td>
<td>{% trans %}Barman{% endtrans %}</td>
<td>{% trans %}Label{% endtrans %}</td>
<td>{% trans %}Quantity{% endtrans %}</td>
<td>{% trans %}Total{% endtrans %}</td>
<td>{% trans %}Payment method{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for purchase in purchases %}
<tr> <tr>
<td>{% trans %}Date{% endtrans %}</td> <td>
<td>{% trans %}Counter{% endtrans %}</td> {{ purchase.date|localtime|date(DATETIME_FORMAT) }}
<td>{% trans %}Barman{% endtrans %}</td> - {{ purchase.date|localtime|time(DATETIME_FORMAT) }}
<td>{% trans %}Label{% endtrans %}</td> </td>
<td>{% trans %}Quantity{% endtrans %}</td> <td>{{ purchase.counter }}</td>
<td>{% trans %}Total{% endtrans %}</td> <td><a href="{{ purchase.seller.get_absolute_url() }}">{{ purchase.seller.get_display_name() }}</a></td>
<td>{% trans %}Payment method{% endtrans %}</td> <td>{{ purchase.label }}</td>
</tr> <td>{{ purchase.quantity }}</td>
</thead> <td>{{ purchase.quantity * purchase.unit_price }} €</td>
<tbody> <td>{{ purchase.get_payment_method_display() }}</td>
{% for i in customer.buyings.order_by('-date').all().filter( {% if purchase.is_owned_by(user) %}
date__year=year, date__month=month) %} <td>
<tr> <a href="{{ url('counter:selling_delete', selling_id=purchase.id) }}">
<td>{{ i.date|localtime|date(DATETIME_FORMAT) }} - {{ i.date|localtime|time(DATETIME_FORMAT) }}</td> {% trans %}Delete{% endtrans %}
<td>{{ i.counter }}</td> </a>
<td><a href="{{ i.seller.get_absolute_url() }}">{{ i.seller.get_display_name() }}</a></td> </td>
<td>{{ i.label }}</td>
<td>{{ i.quantity }}</td>
<td>{{ i.quantity * i.unit_price }} €</td>
<td>{{ i.get_payment_method_display() }}</td>
{% if i.is_owned_by(user) %}
<td><a href="{{ url('counter:selling_delete', selling_id=i.id) }}">{% trans %}Delete{% endtrans %}</a></td>
{% endif %} {% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% endif %} {% endif %}
{% if customer.refillings.exists() %} {% if refills %}
<h4>{% trans %}Reloads{% endtrans %}</h4> <h4>{% trans %}Reloads{% endtrans %}</h4>
<table> <table>
<thead> <thead>
@ -55,22 +60,30 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for i in customer.refillings.order_by('-date').filter( date__year=year, date__month=month) %} {% for refill in refills %}
<tr> <tr>
<td>{{ i.date|localtime|date(DATETIME_FORMAT) }} - {{ i.date|localtime|time(DATETIME_FORMAT) }}</td> <td>{{ refill.date|localtime|date(DATETIME_FORMAT) }} - {{ refill.date|localtime|time(DATETIME_FORMAT) }}</td>
<td>{{ i.counter }}</td> <td>{{ refill.counter }}</td>
<td><a href="{{ i.operator.get_absolute_url() }}">{{ i.operator.get_display_name() }}</a></td> <td>
<td>{{ i.amount }} €</td> <a href="{{ refill.operator.get_absolute_url() }}">
<td>{{ i.get_payment_method_display() }}</td> {{ refill.operator.get_display_name() }}
{% if i.is_owned_by(user) %} </a>
<td><a href="{{ url('counter:refilling_delete', refilling_id=i.id) }}">{% trans %}Delete{% endtrans %}</a></td> </td>
<td>{{ refill.amount }} €</td>
<td>{{ refill.get_payment_method_display() }}</td>
{% if refill.is_owned_by(user) %}
<td>
<a href="{{ url('counter:refilling_delete', refilling_id=refill.id) }}">
{% trans %}Delete{% endtrans %}
</a>
</td>
{% endif %} {% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% endif %} {% endif %}
{% if customer.user.invoices.exists() %} {% if invoices %}
<h4>{% trans %}Eboutic invoices{% endtrans %}</h4> <h4>{% trans %}Eboutic invoices{% endtrans %}</h4>
<table> <table>
<thead> <thead>
@ -81,25 +94,24 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for i in customer.user.invoices.order_by('-date').all().filter( {% for invoice in invoices %}
date__year=year, date__month=month) %} <tr>
<tr> <td>
<td>{{ i.date|localtime|date(DATETIME_FORMAT) }} - {{ i.date|localtime|time(DATETIME_FORMAT) }}</td> {{ invoice.date|localtime|date(DATETIME_FORMAT) }}
<td> - {{ invoice.date|localtime|time(DATETIME_FORMAT) }}
<ul> </td>
{% for it in i.items.all() %} <td>
<li>{{ it.quantity }} x {{ it.product_name }} - {{ it.product_unit_price }} €</li> <ul>
{% endfor %} {% for it in invoice.items.all() %}
</ul> <li>{{ it.quantity }} x {{ it.product_name }} - {{ it.product_unit_price }} €</li>
</td> {% endfor %}
<td>{{ i.get_total() }} €</td> </ul>
</tr> </td>
{% endfor %} <td>{{ invoice.total }} €</td>
</tbody> </tr>
</table> {% endfor %}
{% endif %} </tbody>
{% else %} </table>
<p>{% trans %}User has no account{% endtrans %}</p> {% endif %}
{% endif %} <p><a href="{{ url('core:user_account', user_id=profile.id) }}">{% trans %}Back{% endtrans %}</a></p>
<p><a href="{{ url('core:user_account', user_id=profile.id) }}">{% trans %}Back{% endtrans %}</a></p>
{% endblock %} {% endblock %}

View File

@ -1,7 +1,8 @@
from datetime import timedelta from datetime import timedelta
import pytest
from django.core.management import call_command from django.core.management import call_command
from django.test import TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from model_bakery import baker, seq from model_bakery import baker, seq
@ -95,3 +96,18 @@ class TestSearchUsersView(TestSearchUsers):
self.client.force_login(subscriber_user.make()) self.client.force_login(subscriber_user.make())
response = self.client.get(reverse("core:search")) response = self.client.get(reverse("core:search"))
assert response.status_code == 200 assert response.status_code == 200
@pytest.mark.django_db
def test_user_account_not_found(client: Client):
client.force_login(baker.make(User, is_superuser=True))
user = baker.make(User)
res = client.get(reverse("core:user_account", kwargs={"user_id": user.id}))
assert res.status_code == 404
res = client.get(
reverse(
"core:user_account_detail",
kwargs={"user_id": user.id, "year": 2024, "month": 10},
)
)
assert res.status_code == 404

View File

@ -21,7 +21,6 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
import logging
# This file contains all the views that concern the user model # This file contains all the views that concern the user model
from datetime import date, timedelta from datetime import date, timedelta
@ -32,6 +31,8 @@ from django.contrib.auth import login, views
from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.models import DateField, QuerySet
from django.db.models.functions import Trunc
from django.forms import CheckboxSelectMultiple from django.forms import CheckboxSelectMultiple
from django.forms.models import modelform_factory from django.forms.models import modelform_factory
from django.http import Http404 from django.http import Http404
@ -68,6 +69,8 @@ from core.views.forms import (
UserProfileForm, UserProfileForm,
) )
from counter.forms import StudentCardForm from counter.forms import StudentCardForm
from counter.models import Refilling, Selling
from eboutic.models import Invoice
from subscription.models import Subscription from subscription.models import Subscription
from trombi.views import UserTrombiForm from trombi.views import UserTrombiForm
@ -615,65 +618,57 @@ class UserAccountBase(UserTabsMixin, DetailView):
model = User model = User
pk_url_kwarg = "user_id" pk_url_kwarg = "user_id"
current_tab = "account" current_tab = "account"
queryset = User.objects.select_related("customer")
def dispatch(self, request, *arg, **kwargs): # Manually validates the rights def dispatch(self, request, *arg, **kwargs): # Manually validates the rights
res = super().dispatch(request, *arg, **kwargs)
if ( if (
self.object == request.user kwargs.get("user_id") == request.user.id
or request.user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) or request.user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
or request.user.is_in_group( or request.user.is_in_group(
name=settings.SITH_BAR_MANAGER["unix_name"] + settings.SITH_BOARD_SUFFIX name=settings.SITH_BAR_MANAGER["unix_name"] + settings.SITH_BOARD_SUFFIX
) )
or request.user.is_root or request.user.is_root
): ):
return res return super().dispatch(request, *arg, **kwargs)
raise PermissionDenied raise PermissionDenied
def get_object(self, queryset=None):
obj = super().get_object(queryset)
if not hasattr(obj, "customer"):
raise Http404(_("User has no account"))
return obj
class UserAccountView(UserAccountBase): class UserAccountView(UserAccountBase):
"""Display a user's account.""" """Display a user's account."""
template_name = "core/user_account.jinja" template_name = "core/user_account.jinja"
def expense_by_month(self, obj, calc): @staticmethod
stats = [] def expense_by_month[T](qs: QuerySet[T]) -> QuerySet[T]:
month_trunc = Trunc("date", "month", output_field=DateField())
for year in obj.datetimes("date", "year", order="DESC"): return (
stats.append([]) qs.annotate(grouped_date=month_trunc)
i = 0 .values("grouped_date")
for month in obj.filter(date__year=year.year).datetimes( .annotate_total()
"date", "month", order="DESC" .exclude(total=0)
): .order_by("-grouped_date")
q = obj.filter(date__year=month.year, date__month=month.month) )
stats[i].append({"sum": sum([calc(p) for p in q]), "date": month})
i += 1
return stats
def invoices_calc(self, query):
t = 0
for it in query.items.all():
t += it.quantity * it.product_unit_price
return t
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["profile"] = self.object kwargs["profile"] = self.object
try: kwargs["customer"] = self.object.customer
kwargs["customer"] = self.object.customer kwargs["buyings_month"] = self.expense_by_month(
kwargs["buyings_month"] = self.expense_by_month( Selling.objects.filter(customer=self.object.customer)
self.object.customer.buyings, (lambda q: q.unit_price * q.quantity) )
) kwargs["refilling_month"] = self.expense_by_month(
kwargs["invoices_month"] = self.expense_by_month( Refilling.objects.filter(customer=self.object.customer)
self.object.customer.user.invoices, self.invoices_calc )
) kwargs["invoices_month"] = self.expense_by_month(
kwargs["refilling_month"] = self.expense_by_month( Invoice.objects.filter(user=self.object)
self.object.customer.refillings, (lambda q: q.amount) )
) kwargs["etickets"] = self.object.customer.buyings.exclude(product__eticket=None)
kwargs["etickets"] = self.object.customer.buyings.exclude(
product__eticket=None
).all()
except Exception as e:
logging.error(e)
return kwargs return kwargs
@ -685,13 +680,29 @@ class UserAccountDetailView(UserAccountBase, YearMixin, MonthMixin):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["profile"] = self.object kwargs["profile"] = self.object
kwargs["year"] = self.get_year() kwargs["customer"] = self.object.customer
kwargs["month"] = self.get_month() year, month = self.get_year(), self.get_month()
try: filters = {
kwargs["customer"] = self.object.customer "customer": self.object.customer,
except: "date__year": year,
pass "date__month": month,
kwargs["tab"] = "account" }
kwargs["purchases"] = list(
Selling.objects.filter(**filters)
.select_related("counter", "counter__club", "seller")
.order_by("-date")
)
kwargs["refills"] = list(
Refilling.objects.filter(**filters)
.select_related("counter", "counter__club", "operator")
.order_by("-date")
)
kwargs["invoices"] = list(
Invoice.objects.filter(user=self.object, date__year=year, date__month=month)
.annotate_total()
.prefetch_related("items")
.order_by("-date")
)
return kwargs return kwargs

View File

@ -20,7 +20,7 @@ import random
import string import string
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from datetime import timezone as tz from datetime import timezone as tz
from typing import Tuple from typing import Self, Tuple
from dict2xml import dict2xml from dict2xml import dict2xml
from django.conf import settings from django.conf import settings
@ -585,6 +585,23 @@ class Counter(models.Model):
)["total"] )["total"]
class RefillingQuerySet(models.QuerySet):
def annotate_total(self) -> Self:
"""Annotate the Queryset with the total amount.
The total is just the sum of the amounts for each row.
If no grouping is involved (like in most queries),
this is just the same as doing nothing and fetching the
`amount` attribute.
However, it may be useful when there is a `group by` clause
in the query, or when other models are queried and having
a common interface is helpful (e.g. `Selling.objects.annotate_total()`
and `Refilling.objects.annotate_total()` will both have the `total` field).
"""
return self.annotate(total=Sum("amount"))
class Refilling(models.Model): class Refilling(models.Model):
"""Handle the refilling.""" """Handle the refilling."""
@ -613,6 +630,8 @@ class Refilling(models.Model):
) )
is_validated = models.BooleanField(_("is validated"), default=False) is_validated = models.BooleanField(_("is validated"), default=False)
objects = RefillingQuerySet.as_manager()
class Meta: class Meta:
verbose_name = _("refilling") verbose_name = _("refilling")
@ -657,6 +676,15 @@ class Refilling(models.Model):
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
class SellingQuerySet(models.QuerySet):
def annotate_total(self) -> Self:
"""Annotate the Queryset with the total amount of the sales.
The total is considered as the sum of (unit_price * quantity).
"""
return self.annotate(total=Sum(F("unit_price") * F("quantity")))
class Selling(models.Model): class Selling(models.Model):
"""Handle the sellings.""" """Handle the sellings."""
@ -703,6 +731,8 @@ class Selling(models.Model):
) )
is_validated = models.BooleanField(_("is validated"), default=False) is_validated = models.BooleanField(_("is validated"), default=False)
objects = SellingQuerySet.as_manager()
class Meta: class Meta:
verbose_name = _("selling") verbose_name = _("selling")

View File

@ -16,12 +16,12 @@ from __future__ import annotations
import hmac import hmac
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any, Self
from dict2xml import dict2xml from dict2xml import dict2xml
from django.conf import settings from django.conf import settings
from django.db import DataError, models from django.db import DataError, models
from django.db.models import F, Sum from django.db.models import F, OuterRef, Subquery, Sum
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -160,6 +160,22 @@ class Basket(models.Model):
return data return data
class InvoiceQueryset(models.QuerySet):
def annotate_total(self) -> Self:
"""Annotate the queryset with the total amount of each invoice.
The total amount is the sum of (product_unit_price * quantity)
for all items related to the invoice.
"""
return self.annotate(
total=Subquery(
InvoiceItem.objects.filter(invoice_id=OuterRef("pk"))
.annotate(total=Sum(F("product_unit_price") * F("quantity")))
.values("total")
)
)
class Invoice(models.Model): class Invoice(models.Model):
"""Invoices are generated once the payment has been validated.""" """Invoices are generated once the payment has been validated."""
@ -173,6 +189,8 @@ class Invoice(models.Model):
date = models.DateTimeField(_("date"), auto_now=True) date = models.DateTimeField(_("date"), auto_now=True)
validated = models.BooleanField(_("validated"), default=False) validated = models.BooleanField(_("validated"), default=False)
objects = InvoiceQueryset.as_manager()
def __str__(self): def __str__(self):
return f"{self.user} - {self.get_total()} - {self.date}" return f"{self.user} - {self.get_total()} - {self.date}"