Author SHA1 Message Date
imperosol 7fdb1a914f remove date selection from subscription stats view
Ca n'a jamais marché depuis 2017
2026-09-22 11:37:38 +02:00
imperosol 130afc471c better subscription generation in populate_more 2026-09-22 11:04:18 +02:00
thomas girod e937189b80 Merge pull request #1493 from ae-utbm/padding-refilling
add a space between inputs and submit in refill form
2026-09-21 10:34:03 +02:00
thomas girod 9b8ca76e33 Merge pull request #1494 from ae-utbm/fix-htmx-events
fix: `htmx:after:swap` was still using old syntax
2026-09-21 10:33:40 +02:00
imperosol aedfbf948e fix: htmx:after:swap was still using old syntax 2026-09-20 23:07:09 +02:00
imperosol 8175fc415c add a space between inputs and submit in refill form 2026-09-20 22:37:31 +02:00
thomas girod 994130550a Merge pull request #1492 from ae-utbm/fix-pagination
fix: broken matmat pagination
2026-09-20 22:28:48 +02:00
imperosol 44071f91f6 fix: broken matmat pagination 2026-09-20 22:26:27 +02:00
thomas girod bd02425b54 Merge pull request #1489 from ae-utbm/refill-perm
Refill perm
2026-09-18 18:30:06 +02:00
thomas girod 794d3841a0 Merge pull request #1490 from ae-utbm/fix-migrations
fix migrations
2026-09-18 16:43:05 +02:00
imperosol 62b49240d1 fix migrations 2026-09-18 11:09:56 +02:00
imperosol 46318be572 add tests 2026-09-17 12:51:16 +02:00
imperosol ee6b4d4b1e better selection of who can make people refill 2026-09-16 23:32:57 +02:00
imperosol 22bf38beaf implement SithModelBackend.with_perm 2026-09-16 23:32:57 +02:00
22 changed files with 180 additions and 112 deletions
@@ -18,7 +18,7 @@ class Migration(migrations.Migration):
"Groups that are automatically given or removed " "Groups that are automatically given or removed "
"to user receiving or losing this club role" "to user receiving or losing this club role"
), ),
related_name="club_roles", related_name="linked_roles",
to="core.group", to="core.group",
verbose_name="Linked groups", verbose_name="Linked groups",
), ),
+55 -4
View File
@@ -1,15 +1,16 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING import typing
from django.conf import settings from django.conf import settings
from django.contrib.auth.backends import ModelBackend from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.db.models import Exists, OuterRef, Q, QuerySet
from core.models import Group from core.models import Group, User
if TYPE_CHECKING: if typing.TYPE_CHECKING:
from core.models import User from django.db.models.base import Model
class SithModelBackend(ModelBackend): class SithModelBackend(ModelBackend):
@@ -40,3 +41,53 @@ class SithModelBackend(ModelBackend):
return Permission.objects.filter( return Permission.objects.filter(
group__group__in=groups.values_list("pk", flat=True) group__group__in=groups.values_list("pk", flat=True)
) )
@typing.override
def with_perm(
self,
perm: str | Permission,
is_active: bool | None = True,
include_superusers: bool = False,
obj: Model | None = None,
) -> QuerySet[User]:
"""Return users that have permission "perm".
Contrary to the base django method, superusers aren't included in the
result.
This is because the OR operation to include superusers in the query result
utterly destroy the query performances on postgres
(it makes it like 1000x slower, and I'm not even kidding).
To overcome that, we could use a UNION instead, but then we wouldn't
be able to perform further filter operations on the queryset.
Thus, the `include_superusers` argument is not used at all.
Because of that, it is useless to set `include_superusers`,
as it will be silently ignored.
The only reason it's still there is not to break the interface
of the base class.
"""
if isinstance(perm, str):
try:
app_label, codename = perm.split(".")
except ValueError as e:
raise ValueError(
"Permission name should be in the form "
"app_label.permission_codename."
) from e
permission_q = Q(codename=codename, content_type__app_label=app_label)
elif isinstance(perm, Permission):
permission_q = Q(pk=perm.pk)
else:
raise TypeError(
"The `perm` argument must be a string or a permission instance."
)
user_q = Exists(
Permission.objects.filter(
Q(group__group__users=OuterRef("pk")) | Q(user=OuterRef("pk")),
permission_q,
)
)
if is_active is not None:
user_q &= Q(is_active=is_active)
return User.objects.filter(user_q)
+1
View File
@@ -747,6 +747,7 @@ class Command(BaseCommand):
"add_subscription", "add_subscription",
"add_membership", "add_membership",
"view_hidden_user", "view_hidden_user",
"add_refilling",
] ]
) )
) )
+14 -2
View File
@@ -138,10 +138,22 @@ class Command(BaseCommand):
) )
def create_subscriptions(self, users: list[User]): def create_subscriptions(self, users: list[User]):
subscription_types = [
"un-semestre",
"deux-semestres",
"cursus-tronc-commun",
"cursus-branche",
]
def prepare_subscription(_user: User, start_date: date) -> Subscription: def prepare_subscription(_user: User, start_date: date) -> Subscription:
payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0] payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0]
duration = random.randint(1, 4) subscription_type = random.choice(subscription_types)
s = Subscription(member=_user, payment_method=payment_method) s = Subscription(
member=_user,
payment_method=payment_method,
subscription_type=subscription_type,
)
duration = settings.SITH_SUBSCRIPTIONS[subscription_type]["duration"]
s.subscription_start = s.compute_start(d=start_date, duration=duration) s.subscription_start = s.compute_start(d=start_date, duration=duration)
s.subscription_end = s.compute_end(duration) s.subscription_end = s.compute_end(duration)
return s return s
+27
View File
@@ -0,0 +1,27 @@
import pytest
from django.contrib.auth.models import Permission
from model_bakery import baker
from core.models import Group, User
@pytest.mark.django_db
def test_with_perm():
"""Test that `SithModelBackend.with_perm` works as intended."""
perms = baker.make(Permission, _quantity=4)
groups = baker.make(Group, _quantity=2)
groups[0].permissions.set(perms[0:2])
groups[1].permissions.set(perms[2:4])
users = [
baker.make(User),
baker.make(User, groups=[groups[0]]),
baker.make(User, groups=[groups[1]]),
baker.make(User, user_permissions=[perms[0]]),
baker.make(User, user_permissions=[perms[2]]),
baker.make(User, groups=[groups[1]], user_permissions=[perms[0]]),
]
expected = [users[1], users[3], users[5]]
assert list(User.objects.with_perm(perms[0])) == expected
str_repr = f"{perms[0].content_type.app_label}.{perms[0].codename}"
assert list(User.objects.with_perm(str_repr)) == expected
@@ -11,7 +11,7 @@ class Migration(migrations.Migration):
model_name="permanency", model_name="permanency",
name="end", name="end",
field=models.DateTimeField( field=models.DateTimeField(
db_index=True, verbose_name="end date", null=True db_index=True, verbose_name="end date", null=True, blank=True
), ),
) )
] ]
+1 -3
View File
@@ -6,9 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [("counter", "0018_producttype_priority")]
("counter", "0018_producttype_priority"),
]
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
@@ -6,9 +6,7 @@ import counter.fields
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [("counter", "0019_billinginfo")]
("counter", "0019_billinginfo"),
]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
+11 -6
View File
@@ -669,13 +669,18 @@ class Counter(models.Model):
"""Update the barman activity to prevent timeout.""" """Update the barman activity to prevent timeout."""
self.permanencies.filter(end=None).update(activity=timezone.now()) self.permanencies.filter(end=None).update(activity=timezone.now())
@cached_property
def can_refill(self) -> bool: def can_refill(self) -> bool:
"""Show if the counter authorize the refilling with physic money.""" """Show if the counter authorize the refilling with physic money.
if self.type != "BAR":
return False Refills are authorized if a user having the required permission
# at least one of the barmen is in the AE board is currently logged in.
ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID) """
return any(ae.get_membership_for(barman) for barman in self.barmen_list) return self.type == "BAR" and (
User.objects.with_perm("counter.add_refilling")
.filter(id__in=[u.id for u in self.barmen_list])
.exists()
)
def get_top_barmen(self) -> QuerySet: def get_top_barmen(self) -> QuerySet:
"""Return a QuerySet querying the office hours stats of all the barmen of all time """Return a QuerySet querying the office hours stats of all the barmen of all time
@@ -123,14 +123,14 @@ document.addEventListener("alpine:init", () => {
onRefillingSuccess(event: CustomEvent) { onRefillingSuccess(event: CustomEvent) {
if ( if (
event.type !== "htmx:after-swap" || event.type !== "htmx:after:swap" ||
event.detail.failed || event.detail.ctx.response.status !== 200 ||
event.detail.elt.querySelector(".errorlist") event.detail.ctx.target.querySelector(".errorlist")
) { ) {
return; return;
} }
this.customerBalance += Number.parseFloat( this.customerBalance += Number.parseFloat(
(event.detail.target.querySelector("#id_amount") as HTMLInputElement).value, (event.detail.ctx.target.querySelector("#id_amount") as HTMLInputElement).value,
); );
document.getElementById("selling-accordion")?.setAttribute("open", ""); document.getElementById("selling-accordion")?.setAttribute("open", "");
this.codeField?.widget.focus(); this.codeField?.widget.focus();
@@ -62,8 +62,7 @@
} }
form { form {
margin-top: .5rem; margin: 0;
margin-bottom: .5rem;
} }
} }
@@ -186,7 +186,7 @@
{% if refilling_fragment %} {% if refilling_fragment %}
<div <div
class="accordion-content" class="accordion-content"
@htmx:after-swap="onRefillingSuccess" @htmx:after:swap="onRefillingSuccess"
> >
{{ refilling_fragment }} {{ refilling_fragment }}
</div> </div>
@@ -4,6 +4,8 @@
hx-swap="outerHTML" hx-swap="outerHTML"
> >
{% csrf_token %} {% csrf_token %}
<div class="margin-bottom">
{{ form.as_p() }} {{ form.as_p() }}
<input type="submit" value="{% trans %}Go{% endtrans %}"/> </div>
<input type="submit" class="btn btn-blue" value="{% trans %}Go{% endtrans %}"/>
</form> </form>
+8 -1
View File
@@ -108,6 +108,13 @@ class TestFullClickBase(TestCase):
class TestRefilling(TestFullClickBase): class TestRefilling(TestFullClickBase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.board_admin.user_permissions.add(
Permission.objects.get(codename="add_refilling")
)
def login_in_bar(self, barmen: User | None = None): def login_in_bar(self, barmen: User | None = None):
used_barman = barmen if barmen is not None else self.board_admin used_barman = barmen if barmen is not None else self.board_admin
self.client.post( self.client.post(
@@ -147,7 +154,7 @@ class TestRefilling(TestFullClickBase):
assert self.updated_amount(self.customer) == 0 assert self.updated_amount(self.customer) == 0
def test_refilling_no_refer_fail(self): def test_refilling_no_refer_fail(self):
"""Check that the refill fails is the HTTP_REFERER header is missing""" """Check that the refill fails if the HTTP_REFERER header is missing"""
def refill(): def refill():
return self.client.post( return self.client.post(
+2 -2
View File
@@ -204,7 +204,7 @@ class CounterClick(
res["student_card_fragment"] = StudentCardFormFragment.as_fragment()( res["student_card_fragment"] = StudentCardFormFragment.as_fragment()(
self.request, customer=self.customer self.request, customer=self.customer
) )
if self.object.can_refill(): if self.object.can_refill:
res["refilling_fragment"] = RefillingCreateView.as_fragment()( res["refilling_fragment"] = RefillingCreateView.as_fragment()(
self.request, customer=self.customer, counter=self.object self.request, customer=self.customer, counter=self.object
) )
@@ -250,7 +250,7 @@ class RefillingCreateView(FragmentMixin, CreateView):
if not ( if not (
request.barmen request.barmen
and request.barmen.issubset(self.counter.barmen_list) and request.barmen.issubset(self.counter.barmen_list)
and self.counter.can_refill() and self.counter.can_refill
): ):
raise PermissionDenied raise PermissionDenied
@@ -60,7 +60,7 @@
</p> </p>
<br> <br>
{% if settings.SITH_EBOUTIC_CB_ENABLED %} {% if settings.SITH_EBOUTIC_CB_ENABLED %}
<div @htmx:after-request="fill"> <div @htmx:after:request="fill">
{{ billing_infos_form }} {{ billing_infos_form }}
</div> </div>
{% endif %} {% endif %}
+1 -1
View File
@@ -25,7 +25,7 @@
{% endfor %} {% endfor %}
</div> </div>
{% if page_obj.has_other_pages() %} {% if page_obj.has_other_pages() %}
{{ paginate_htmx(page_obj, paginator) }} {{ paginate_htmx(request, page_obj, paginator) }}
{% endif %} {% endif %}
<hr> <hr>
{% endif %} {% endif %}
+10
View File
@@ -58,3 +58,13 @@ class TestMatmatronch(TestCase):
assert list(response.context_data["object_list"]) == [] assert list(response.context_data["object_list"]) == []
assert not response.context_data["form"].is_valid() assert not response.context_data["form"].is_valid()
assert "Recherche vide" in response.context_data["form"].non_field_errors() assert "Recherche vide" in response.context_data["form"].non_field_errors()
def test_search_many_users(self):
"""Test that the pagination works when a lot of users are returned."""
baker.make(User, promo=17, _quantity=40, _bulk_create=True)
self.client.force_login(subscriber_user.make())
response = self.client.get(reverse("matmat:search", query={"promo": 17}))
assert response.status_code == 200
assert response.context_data["paginator"].count == 43
assert response.context_data["paginator"].num_pages == 3
-1
View File
@@ -20,7 +20,6 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
from django.db.models import F from django.db.models import F
from django.views.generic import ListView from django.views.generic import ListView
from django.views.generic.edit import FormMixin from django.views.generic.edit import FormMixin
+1 -12
View File
@@ -8,22 +8,11 @@ from django.utils.translation import gettext_lazy as _
from core.models import User from core.models import User
from core.utils import get_last_promo from core.utils import get_last_promo
from core.views.forms import SelectDate, SelectDateTime from core.views.forms import SelectDate
from core.views.widgets.ajax_select import AutoCompleteSelectUser from core.views.widgets.ajax_select import AutoCompleteSelectUser
from subscription.models import Subscription from subscription.models import Subscription
class SelectionDateForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["start_date"] = forms.DateTimeField(
label=_("Start date"), widget=SelectDateTime, required=True
)
self.fields["end_date"] = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=True
)
class SubscriptionForm(forms.ModelForm): class SubscriptionForm(forms.ModelForm):
allowed_payment_methods = ["CARD", "CASH", "AE_ACCOUNT"] allowed_payment_methods = ["CARD", "CASH", "AE_ACCOUNT"]
@@ -11,35 +11,23 @@
{% block content %} {% block content %}
<p>
<form>
{{ form.start_date.label }}<br>
{{ form.start_date }}<br><br>
{{ form.end_date.label }}<br>
{{ form.end_date }}<br>
<p><input type="submit" value="{% trans %}Go{% endtrans %}" /></p>
</form>
</p>
<canvas id="statsChart" width="400" height="200"></canvas> <canvas id="statsChart" width="400" height="200"></canvas>
<p>
{% trans %}Total subscriptions{% endtrans %} : {{ subscriptions_total.count() }}<br><br> {% trans %}Total subscriptions{% endtrans %} : {{ subscriptions_total.count() }}<br><br>
{% trans %}Subscriptions by type{% endtrans %}<br><br> {% trans %}Subscriptions by type{% endtrans %}<br><br>
{% for location in locations %} {% for location in locations %}
{{ location[1] }} : <i class="nb">{{ subscriptions_total.filter(location=location[0]).count() }}</i><br> {{ location[1] }} : <i class="nb">{{ subscriptions_total.filter(location=location[0]).count() }}</i><br>
{% endfor %} {% endfor %}
<p>
<br> <br>
<table> <table>
<tr> <thead>
<th>{% trans %}Subscription type{% endtrans %}</th> <th>{% trans %}Subscription type{% endtrans %}</th>
{% for location in locations %} {% for location in locations %}
<th>{{ location[1] }}</th> <th>{{ location[1] }}</th>
{% endfor %} {% endfor %}
<th id="graphLabel">{% trans %}Total{% endtrans %}</th> <th id="graphLabel">{% trans %}Total{% endtrans %}</th>
</thead>
{% for type in subscriptions_types %} {% for type in subscriptions_types %}
<tr> <tr>
<td><i class="types" >{{ subscriptions_types[type]['name'] }}</i></td> <td><i class="types" >{{ subscriptions_types[type]['name'] }}</i></td>
@@ -53,9 +41,8 @@
{% endfor %} {% endfor %}
</td> </td>
{% endfor %} {% endfor %}
<td class="total"><i class="nb">{{subscriptions_total_type.count()}}</i> <td class="total"><i class="nb">{{subscriptions_total_type.count()}}</i></td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
{% endblock %} {% endblock %}
+4 -21
View File
@@ -17,16 +17,14 @@ from django.conf import settings
from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.forms import PasswordResetForm
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.urls import reverse, reverse_lazy from django.urls import reverse
from django.utils.timezone import localdate from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, TemplateView from django.views.generic import CreateView, DetailView, TemplateView
from django.views.generic.edit import FormView
from core.views import FragmentMixin, UseFragmentsMixin from core.views import FragmentMixin, UseFragmentsMixin
from core.views.group import PermissionGroupsUpdateView from core.views.group import PermissionGroupsUpdateView
from subscription.forms import ( from subscription.forms import (
SelectionDateForm,
SubscriptionExistingUserForm, SubscriptionExistingUserForm,
SubscriptionNewUserForm, SubscriptionNewUserForm,
) )
@@ -93,34 +91,19 @@ class SubscriptionPermissionView(PermissionGroupsUpdateView):
extra_context = {"object_name": _("the groups that can create subscriptions")} extra_context = {"object_name": _("the groups that can create subscriptions")}
class SubscriptionsStatsView(FormView): class SubscriptionsStatsView(TemplateView):
template_name = "subscription/stats.jinja" template_name = "subscription/stats.jinja"
form_class = SelectionDateForm
success_url = reverse_lazy("subscriptions:stats")
def dispatch(self, request, *arg, **kwargs): def dispatch(self, request, *arg, **kwargs):
self.start_date = localdate()
self.end_date = self.start_date
if request.user.is_root or request.user.is_board_member: if request.user.is_root or request.user.is_board_member:
return super().dispatch(request, *arg, **kwargs) return super().dispatch(request, *arg, **kwargs)
raise PermissionDenied raise PermissionDenied
def post(self, request, *args, **kwargs):
self.form = self.get_form()
self.start_date = self.form["start_date"]
self.end_date = self.form["end_date"]
return super().post(request, *args, **kwargs)
def get_initial(self):
return {
"start_date": self.start_date.strftime("%Y-%m-%d %H:%M:%S"),
"end_date": self.end_date.strftime("%Y-%m-%d %H:%M:%S"),
}
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
today = localdate()
kwargs["subscriptions_total"] = Subscription.objects.filter( kwargs["subscriptions_total"] = Subscription.objects.filter(
subscription_end__gte=self.end_date, subscription_start__lte=self.start_date subscription_end__gte=today, subscription_start__lte=today
) )
kwargs["subscriptions_types"] = settings.SITH_SUBSCRIPTIONS kwargs["subscriptions_types"] = settings.SITH_SUBSCRIPTIONS
kwargs["payment_types"] = settings.SITH_SUBSCRIPTION_PAYMENT_METHOD kwargs["payment_types"] = settings.SITH_SUBSCRIPTION_PAYMENT_METHOD