Compare commits

..

13 Commits

Author SHA1 Message Date
Kenneth SOARES
ef25655c2e fix formatting 2025-09-30 17:29:05 +02:00
Kenneth SOARES
1b00998144 add test case 2025-09-30 17:20:47 +02:00
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
35 changed files with 558 additions and 520 deletions

View File

@@ -6,7 +6,7 @@ addAssignees: author
# A list of team reviewers to be added to pull requests (GitHub team slug) # A list of team reviewers to be added to pull requests (GitHub team slug)
reviewers: reviewers:
- ae-utbm/developpeurs - ae-utbm/sith-3-developers
# Number of reviewers has no impact on GitHub teams # Number of reviewers has no impact on GitHub teams
# Set 0 to add all the reviewers (default: 0) # Set 0 to add all the reviewers (default: 0)

View File

@@ -16,16 +16,7 @@ multi-ecosystem-groups:
updates: updates:
- package-ecosystem: "uv" - package-ecosystem: "uv"
patterns: ["*"]
multi-ecosystem-group: "common" multi-ecosystem-group: "common"
- package-ecosystem: "npm" - package-ecosystem: "npm"
patterns: ["*"]
multi-ecosystem-group: "common" multi-ecosystem-group: "common"
groups:
# npm supports production and development groups, but not uv
# cf. https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#dependency-type-groups
main-deps:
dependency-type: "production"
dev-deps:
dependency-type: "development"

View File

@@ -34,10 +34,12 @@ def migrate_meta_groups(apps: StateApps, schema_editor):
clubs = list(Club.objects.all()) clubs = list(Club.objects.all())
for club in clubs: for club in clubs:
club.board_group = meta_groups.get_or_create( club.board_group = meta_groups.get_or_create(
name=f"{club.unix_name}-bureau", defaults={"is_meta": True} name=club.unix_name + settings.SITH_BOARD_SUFFIX,
defaults={"is_meta": True},
)[0] )[0]
club.members_group = meta_groups.get_or_create( club.members_group = meta_groups.get_or_create(
name=f"{club.unix_name}-membres", defaults={"is_meta": True} name=club.unix_name + settings.SITH_MEMBER_SUFFIX,
defaults={"is_meta": True},
)[0] )[0]
club.save() club.save()
club.refresh_from_db() club.refresh_from_db()

View File

@@ -42,13 +42,6 @@ from core.fields import ResizedImageField
from core.models import Group, Notification, Page, SithFile, User from core.models import Group, Notification, Page, SithFile, User
class ClubQuerySet(models.QuerySet):
def having_board_member(self, user: User) -> Self:
"""Filter all club in which the given user is a board member."""
active_memberships = user.memberships.board().ongoing()
return self.filter(Exists(active_memberships.filter(club=OuterRef("pk"))))
class Club(models.Model): class Club(models.Model):
"""The Club class, made as a tree to allow nice tidy organization.""" """The Club class, made as a tree to allow nice tidy organization."""
@@ -98,8 +91,6 @@ class Club(models.Model):
Group, related_name="club_board", on_delete=models.PROTECT Group, related_name="club_board", on_delete=models.PROTECT
) )
objects = ClubQuerySet.as_manager()
class Meta: class Meta:
ordering = ["name"] ordering = ["name"]

View File

@@ -1,27 +0,0 @@
from datetime import timedelta
import pytest
from django.utils.timezone import localdate
from model_bakery import baker
from model_bakery.recipe import Recipe
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
@pytest.mark.django_db
def test_club_queryset_having_board_member():
clubs = baker.make(Club, _quantity=5)
user = subscriber_user.make()
membership_recipe = Recipe(
Membership, user=user, start_date=localdate() - timedelta(days=3)
)
membership_recipe.make(club=clubs[0], role=1)
membership_recipe.make(club=clubs[1], role=3)
membership_recipe.make(club=clubs[2], role=7)
membership_recipe.make(
club=clubs[3], role=3, end_date=localdate() - timedelta(days=1)
)
club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)
assert set(club_ids) == {clubs[1].id, clubs[2].id}

View File

@@ -1,35 +0,0 @@
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from club.models import Club
from com.models import Poster
from core.baker_recipes import subscriber_user
@pytest.mark.django_db
@pytest.mark.parametrize("route_url", ["club:poster_list", "club:poster_create"])
def test_access(client: Client, route_url):
club = baker.make(Club)
user = subscriber_user.make()
url = reverse(route_url, kwargs={"club_id": club.id})
client.force_login(user)
assert client.get(url).status_code == 403
club.board_group.users.add(user)
assert client.get(url).status_code == 200
@pytest.mark.django_db
@pytest.mark.parametrize("route_url", ["club:poster_edit", "club:poster_delete"])
def test_access_specific_poster(client: Client, route_url):
club = baker.make(Club)
user = subscriber_user.make()
poster = baker.make(Poster)
url = reverse(route_url, kwargs={"club_id": club.id, "poster_id": poster.id})
client.force_login(user)
assert client.get(url).status_code == 403
club.board_group.users.add(user)
assert client.get(url).status_code == 200

View File

@@ -51,17 +51,13 @@ from club.forms import (
SellingsForm, SellingsForm,
) )
from club.models import Club, Mailing, MailingSubscription, Membership from club.models import Club, Mailing, MailingSubscription, Membership
from com.models import Poster
from com.views import ( from com.views import (
PosterCreateBaseView, PosterCreateBaseView,
PosterDeleteBaseView, PosterDeleteBaseView,
PosterEditBaseView, PosterEditBaseView,
PosterListBaseView, PosterListBaseView,
) )
from core.auth.mixins import ( from core.auth.mixins import CanCreateMixin, CanEditMixin, CanViewMixin
CanEditMixin,
CanViewMixin,
)
from core.models import PageRev from core.models import PageRev
from core.views import DetailFormView, PageEditViewBase from core.views import DetailFormView, PageEditViewBase
from core.views.mixins import TabedViewMixin from core.views.mixins import TabedViewMixin
@@ -70,12 +66,9 @@ from counter.models import Selling
class ClubTabsMixin(TabedViewMixin): class ClubTabsMixin(TabedViewMixin):
def get_tabs_title(self): def get_tabs_title(self):
if not hasattr(self, "object") or not self.object: obj = self.get_object()
self.object = self.get_object() if isinstance(obj, PageRev):
if isinstance(self.object, PageRev): self.object = obj.page.club
self.object = self.object.page.club
elif isinstance(self.object, Poster):
self.object = self.object.club
return self.object.get_display_name() return self.object.get_display_name()
def get_list_of_tabs(self): def get_list_of_tabs(self):
@@ -166,7 +159,7 @@ class ClubTabsMixin(TabedViewMixin):
"club:poster_list", kwargs={"club_id": self.object.id} "club:poster_list", kwargs={"club_id": self.object.id}
), ),
"slug": "posters", "slug": "posters",
"name": _("Posters"), "name": _("Posters list"),
}, },
] ]
) )
@@ -693,45 +686,48 @@ class MailingAutoGenerationView(View):
return redirect("club:mailing", club_id=club.id) return redirect("club:mailing", club_id=club.id)
class PosterListView(ClubTabsMixin, PosterListBaseView): class PosterListView(ClubTabsMixin, PosterListBaseView, CanViewMixin):
"""List communication posters.""" """List communication posters."""
current_tab = "posters"
extra_context = {"app": "club"}
def get_queryset(self):
return super().get_queryset().filter(club=self.club.id)
def get_object(self): def get_object(self):
return self.club return self.club
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "club"
kwargs["club"] = self.club
return kwargs
class PosterCreateView(ClubTabsMixin, PosterCreateBaseView):
class PosterCreateView(PosterCreateBaseView, CanCreateMixin):
"""Create communication poster.""" """Create communication poster."""
current_tab = "posters" pk_url_kwarg = "club_id"
def get_object(self):
obj = super().get_object()
if not obj:
return self.club
return obj
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id}) return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id})
def get_object(self, *args, **kwargs):
return self.club
class PosterEditView(ClubTabsMixin, PosterEditBaseView, CanEditMixin):
class PosterEditView(ClubTabsMixin, PosterEditBaseView):
"""Edit communication poster.""" """Edit communication poster."""
current_tab = "posters"
extra_context = {"app": "club"}
def get_success_url(self): def get_success_url(self):
return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id}) return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id})
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "club"
return kwargs
class PosterDeleteView(ClubTabsMixin, PosterDeleteBaseView):
class PosterDeleteView(PosterDeleteBaseView, ClubTabsMixin, CanEditMixin):
"""Delete communication poster.""" """Delete communication poster."""
current_tab = "posters"
def get_success_url(self): def get_success_url(self):
return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id}) return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id})

View File

@@ -2,6 +2,7 @@ from datetime import date
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django import forms from django import forms
from django.db.models import Exists, OuterRef
from django.forms import CheckboxInput from django.forms import CheckboxInput
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -34,18 +35,20 @@ class PosterForm(forms.ModelForm):
label=_("Start date"), label=_("Start date"),
widget=SelectDateTime, widget=SelectDateTime,
required=True, required=True,
initial=timezone.now(), initial=timezone.now().strftime("%Y-%m-%d %H:%M:%S"),
) )
date_end = forms.DateTimeField( date_end = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=False label=_("End date"), widget=SelectDateTime, required=False
) )
def __init__(self, *args, user: User, **kwargs): def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user", None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if user.is_root or user.is_com_admin: if self.user and not self.user.is_com_admin:
self.fields["club"].widget = AutoCompleteSelectClub() self.fields["club"].queryset = Club.objects.filter(
else: id__in=self.user.clubs_with_rights
self.fields["club"].queryset = Club.objects.having_board_member(user) )
self.fields.pop("display_time")
class NewsDateForm(forms.ModelForm): class NewsDateForm(forms.ModelForm):
@@ -158,9 +161,16 @@ class NewsForm(forms.ModelForm):
# if the author is an admin, he/she can choose any club, # if the author is an admin, he/she can choose any club,
# otherwise, only clubs for which he/she is a board member can be selected # otherwise, only clubs for which he/she is a board member can be selected
if author.is_root or author.is_com_admin: if author.is_root or author.is_com_admin:
self.fields["club"].widget = AutoCompleteSelectClub() self.fields["club"] = forms.ModelChoiceField(
queryset=Club.objects.all(), widget=AutoCompleteSelectClub
)
else: else:
self.fields["club"].queryset = Club.objects.having_board_member(author) active_memberships = author.memberships.board().ongoing()
self.fields["club"] = forms.ModelChoiceField(
queryset=Club.objects.filter(
Exists(active_memberships.filter(club=OuterRef("pk")))
)
)
def is_valid(self): def is_valid(self):
return super().is_valid() and self.date_form.is_valid() return super().is_valid() and self.date_form.is_valid()

View File

@@ -412,5 +412,17 @@ class Poster(models.Model):
if self.date_end and self.date_begin > self.date_end: if self.date_end and self.date_begin > self.date_end:
raise ValidationError(_("Begin date should be before end date")) raise ValidationError(_("Begin date should be before end date"))
def is_owned_by(self, user):
if user.is_anonymous:
return False
return user.is_com_admin or len(user.clubs_with_rights) > 0
def can_be_moderated_by(self, user):
return user.is_com_admin
def get_display_name(self): def get_display_name(self):
return self.club.get_display_name() return self.club.get_display_name()
@property
def page(self):
return self.club.page

View File

@@ -18,16 +18,17 @@ from unittest.mock import patch
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import html from django.utils import html
from django.utils.timezone import now from django.utils.timezone import localtime, now
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertNumQueries, assertRedirects from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club, Membership from club.models import Club, Membership
from com.models import News, NewsDate, Sith, Weekmail, WeekmailArticle from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, Group, User from core.models import AnonymousUser, Group, User
@@ -206,6 +207,31 @@ class TestWeekmailArticle(TestCase):
assert not self.article.is_owned_by(self.sli) assert not self.article.is_owned_by(self.sli)
class TestPoster(TestCase):
@classmethod
def setUpTestData(cls):
cls.com_admin = User.objects.get(username="comunity")
cls.poster = Poster.objects.create(
name="dummy",
file=SimpleUploadedFile("dummy.jpg", b"azertyuiop"),
club=Club.objects.first(),
date_begin=localtime(now()),
)
cls.sli = User.objects.get(username="sli")
cls.sli.memberships.all().delete()
Membership(user=cls.sli, club=Club.objects.first(), role=5).save()
cls.susbcriber = User.objects.get(username="subscriber")
cls.anonymous = AnonymousUser()
def test_poster_owner(self):
"""Test that poster are owned by com admins and board members in clubs."""
assert self.poster.is_owned_by(self.com_admin)
assert not self.poster.is_owned_by(self.anonymous)
assert not self.poster.is_owned_by(self.susbcriber)
assert self.poster.is_owned_by(self.sli)
class TestNewsCreation(TestCase): class TestNewsCreation(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@@ -28,9 +28,7 @@ from typing import Any
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import ( from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
PermissionRequiredMixin,
)
from django.contrib.syndication.views import Feed from django.contrib.syndication.views import Feed
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import Max from django.db.models import Max
@@ -52,7 +50,6 @@ from core.auth.mixins import (
CanEditPropMixin, CanEditPropMixin,
CanViewMixin, CanViewMixin,
PermissionOrAuthorRequiredMixin, PermissionOrAuthorRequiredMixin,
PermissionOrClubBoardRequiredMixin,
) )
from core.models import User from core.models import User
from core.views.mixins import QuickNotifMixin, TabedViewMixin from core.views.mixins import QuickNotifMixin, TabedViewMixin
@@ -102,6 +99,13 @@ class ComTabsMixin(TabedViewMixin):
] ]
class IsComAdminMixin(AccessMixin):
def dispatch(self, request, *args, **kwargs):
if not request.user.is_com_admin:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class ComEditView(ComTabsMixin, CanEditPropMixin, UpdateView): class ComEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
model = Sith model = Sith
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
@@ -554,109 +558,161 @@ class MailingModerateView(View):
raise PermissionDenied raise PermissionDenied
class PosterListBaseView(PermissionOrClubBoardRequiredMixin, ListView): class PosterAdminViewMixin(IsComAdminMixin, ComTabsMixin):
current_tab = "posters"
class PosterListBaseView(PosterAdminViewMixin, ListView):
"""List communication posters.""" """List communication posters."""
current_tab = "posters"
model = Poster model = Poster
template_name = "com/poster_list.jinja" template_name = "com/poster_list.jinja"
permission_required = "com.view_poster"
ordering = ["-date_begin"] def dispatch(self, request, *args, **kwargs):
club_id = kwargs.pop("club_id", None)
self.club = None
if club_id:
self.club = get_object_or_404(Club, pk=club_id)
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
if self.request.user.is_com_admin:
return Poster.objects.all().order_by("-date_begin")
else:
return Poster.objects.filter(club=self.club.id)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"club": self.club} kwargs = super().get_context_data(**kwargs)
if not self.request.user.is_com_admin:
kwargs["club"] = self.club
return kwargs
class PosterCreateBaseView(PermissionOrClubBoardRequiredMixin, CreateView): class PosterCreateBaseView(PosterAdminViewMixin, CreateView):
"""Create communication poster.""" """Create communication poster."""
current_tab = "posters"
form_class = PosterForm form_class = PosterForm
template_name = "core/create.jinja" template_name = "core/create.jinja"
permission_required = "com.add_poster"
def get_queryset(self): def get_queryset(self):
return Poster.objects.all() return Poster.objects.all()
def get_form_kwargs(self): def dispatch(self, request, *args, **kwargs):
return super().get_form_kwargs() | {"user": self.request.user} if "club_id" in kwargs:
self.club = get_object_or_404(Club, pk=kwargs["club_id"])
return super().dispatch(request, *args, **kwargs)
def get_initial(self): def get_form_kwargs(self):
return {"club": self.club} kwargs = super().get_form_kwargs()
kwargs.update({"user": self.request.user})
return kwargs
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"club": self.club} kwargs = super().get_context_data(**kwargs)
if not self.request.user.is_com_admin:
kwargs["club"] = self.club
return kwargs
def form_valid(self, form): def form_valid(self, form):
if self.request.user.has_perm("com.moderate_poster"): if self.request.user.is_com_admin:
form.instance.is_moderated = True form.instance.is_moderated = True
return super().form_valid(form) return super().form_valid(form)
class PosterEditBaseView(PermissionOrClubBoardRequiredMixin, UpdateView): class PosterEditBaseView(PosterAdminViewMixin, UpdateView):
"""Edit communication poster.""" """Edit communication poster."""
pk_url_kwarg = "poster_id" pk_url_kwarg = "poster_id"
current_tab = "posters"
form_class = PosterForm form_class = PosterForm
template_name = "com/poster_edit.jinja" template_name = "com/poster_edit.jinja"
permission_required = "com.change_poster"
def get_initial(self):
return {
"date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S")
if self.object.date_begin
else None,
"date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S")
if self.object.date_end
else None,
}
def dispatch(self, request, *args, **kwargs):
if kwargs.get("club_id"):
try:
self.club = Club.objects.get(pk=kwargs["club_id"])
except Club.DoesNotExist as e:
raise PermissionDenied from e
return super().dispatch(request, *args, **kwargs)
def get_queryset(self): def get_queryset(self):
return Poster.objects.all() return Poster.objects.all()
def get_form_kwargs(self): def get_form_kwargs(self):
return super().get_form_kwargs() | {"user": self.request.user} kwargs = super().get_form_kwargs()
kwargs.update({"user": self.request.user})
return kwargs
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"club": self.club} kwargs = super().get_context_data(**kwargs)
if hasattr(self, "club"):
kwargs["club"] = self.club
return kwargs
def form_valid(self, form): def form_valid(self, form):
if not self.request.user.has_perm("com.moderate_poster"): if self.request.user.is_com_admin:
form.instance.is_moderated = False form.instance.is_moderated = False
return super().form_valid(form) return super().form_valid(form)
class PosterDeleteBaseView( class PosterDeleteBaseView(PosterAdminViewMixin, DeleteView):
PermissionOrClubBoardRequiredMixin, ComTabsMixin, DeleteView
):
"""Edit communication poster.""" """Edit communication poster."""
pk_url_kwarg = "poster_id" pk_url_kwarg = "poster_id"
current_tab = "posters" current_tab = "posters"
model = Poster model = Poster
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
permission_required = "com.delete_poster"
def dispatch(self, request, *args, **kwargs):
if kwargs.get("club_id"):
try:
self.club = Club.objects.get(pk=kwargs["club_id"])
except Club.DoesNotExist as e:
raise PermissionDenied from e
return super().dispatch(request, *args, **kwargs)
class PosterListView(ComTabsMixin, PosterListBaseView): class PosterListView(PosterListBaseView):
"""List communication posters.""" """List communication posters."""
current_tab = "posters"
def get_queryset(self):
qs = super().get_queryset()
if self.request.user.has_perm("com.view_poster"):
return qs
return qs.filter(club=self.club.id)
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["app"] = "com" kwargs["app"] = "com"
return kwargs return kwargs
class PosterCreateView(ComTabsMixin, PosterCreateBaseView): class PosterCreateView(PosterCreateBaseView):
"""Create communication poster.""" """Create communication poster."""
current_tab = "posters"
success_url = reverse_lazy("com:poster_list") success_url = reverse_lazy("com:poster_list")
extra_context = {"app": "com"}
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "com"
return kwargs
class PosterEditView(ComTabsMixin, PosterEditBaseView): class PosterEditView(PosterEditBaseView):
"""Edit communication poster.""" """Edit communication poster."""
current_tab = "posters"
success_url = reverse_lazy("com:poster_list") success_url = reverse_lazy("com:poster_list")
extra_context = {"app": "com"}
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "com"
return kwargs
class PosterDeleteView(PosterDeleteBaseView): class PosterDeleteView(PosterDeleteBaseView):
@@ -665,39 +721,44 @@ class PosterDeleteView(PosterDeleteBaseView):
success_url = reverse_lazy("com:poster_list") success_url = reverse_lazy("com:poster_list")
class PosterModerateListView(PermissionRequiredMixin, ComTabsMixin, ListView): class PosterModerateListView(PosterAdminViewMixin, ListView):
"""Moderate list communication poster.""" """Moderate list communication poster."""
current_tab = "posters" current_tab = "posters"
model = Poster model = Poster
template_name = "com/poster_moderate.jinja" template_name = "com/poster_moderate.jinja"
queryset = Poster.objects.filter(is_moderated=False).all() queryset = Poster.objects.filter(is_moderated=False).all()
permission_required = "com.moderate_poster"
extra_context = {"app": "com"} def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "com"
return kwargs
class PosterModerateView(PermissionRequiredMixin, ComTabsMixin, View): class PosterModerateView(PosterAdminViewMixin, View):
"""Moderate communication poster.""" """Moderate communication poster."""
current_tab = "posters"
permission_required = "com.moderate_poster"
extra_context = {"app": "com"}
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
obj = get_object_or_404(Poster, pk=kwargs["object_id"]) obj = get_object_or_404(Poster, pk=kwargs["object_id"])
obj.is_moderated = True if obj.can_be_moderated_by(request.user):
obj.moderator = request.user obj.is_moderated = True
obj.save() obj.moderator = request.user
return redirect("com:poster_moderate_list") obj.save()
return redirect("com:poster_moderate_list")
raise PermissionDenied
def get_context_data(self, **kwargs):
kwargs = super(PosterModerateListView, self).get_context_data(**kwargs)
kwargs["app"] = "com"
return kwargs
class ScreenListView(PermissionRequiredMixin, ComTabsMixin, ListView): class ScreenListView(IsComAdminMixin, ComTabsMixin, ListView):
"""List communication screens.""" """List communication screens."""
current_tab = "screens" current_tab = "screens"
model = Screen model = Screen
template_name = "com/screen_list.jinja" template_name = "com/screen_list.jinja"
permission_required = "com.view_screen"
class ScreenSlideshowView(DetailView): class ScreenSlideshowView(DetailView):
@@ -708,12 +769,12 @@ class ScreenSlideshowView(DetailView):
template_name = "com/screen_slideshow.jinja" template_name = "com/screen_slideshow.jinja"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | { kwargs = super().get_context_data(**kwargs)
"posters": self.object.active_posters() kwargs["posters"] = self.object.active_posters()
} return kwargs
class ScreenCreateView(PermissionRequiredMixin, ComTabsMixin, CreateView): class ScreenCreateView(IsComAdminMixin, ComTabsMixin, CreateView):
"""Create communication screen.""" """Create communication screen."""
current_tab = "screens" current_tab = "screens"
@@ -721,10 +782,9 @@ class ScreenCreateView(PermissionRequiredMixin, ComTabsMixin, CreateView):
fields = ["name"] fields = ["name"]
template_name = "core/create.jinja" template_name = "core/create.jinja"
success_url = reverse_lazy("com:screen_list") success_url = reverse_lazy("com:screen_list")
permission_required = "com.add_screen"
class ScreenEditView(PermissionRequiredMixin, ComTabsMixin, UpdateView): class ScreenEditView(IsComAdminMixin, ComTabsMixin, UpdateView):
"""Edit communication screen.""" """Edit communication screen."""
pk_url_kwarg = "screen_id" pk_url_kwarg = "screen_id"
@@ -733,10 +793,9 @@ class ScreenEditView(PermissionRequiredMixin, ComTabsMixin, UpdateView):
fields = ["name"] fields = ["name"]
template_name = "com/screen_edit.jinja" template_name = "com/screen_edit.jinja"
success_url = reverse_lazy("com:screen_list") success_url = reverse_lazy("com:screen_list")
permission_required = "com.change_screen"
class ScreenDeleteView(PermissionRequiredMixin, ComTabsMixin, DeleteView): class ScreenDeleteView(IsComAdminMixin, ComTabsMixin, DeleteView):
"""Delete communication screen.""" """Delete communication screen."""
pk_url_kwarg = "screen_id" pk_url_kwarg = "screen_id"
@@ -744,4 +803,3 @@ class ScreenDeleteView(PermissionRequiredMixin, ComTabsMixin, DeleteView):
model = Screen model = Screen
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("com:screen_list") success_url = reverse_lazy("com:screen_list")
permission_required = "com.delete_screen"

View File

@@ -25,7 +25,6 @@ from core.schemas import (
UserFamilySchema, UserFamilySchema,
UserFilterSchema, UserFilterSchema,
UserProfileSchema, UserProfileSchema,
UserSchema,
) )
from core.templatetags.renderer import markdown from core.templatetags.renderer import markdown
@@ -70,22 +69,16 @@ class MailingListController(ControllerBase):
return data return data
@api_controller("/user") @api_controller("/user", permissions=[CanAccessLookup])
class UserController(ControllerBase): class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup]) @route.get("", response=list[UserProfileSchema])
def fetch_profiles(self, pks: Query[set[int]]): def fetch_profiles(self, pks: Query[set[int]]):
return User.objects.filter(pk__in=pks) return User.objects.filter(pk__in=pks)
@route.get("/{int:user_id}", response=UserSchema, permissions=[CanView])
def fetch_user(self, user_id: int):
"""Fetch a single user"""
return self.get_object_or_exception(User, id=user_id)
@route.get( @route.get(
"/search", "/search",
response=PaginatedResponseSchema[UserProfileSchema], response=PaginatedResponseSchema[UserProfileSchema],
url_name="search_users", url_name="search_users",
permissions=[CanAccessLookup],
) )
@paginate(PageNumberPaginationExtra, page_size=20) @paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]): def search_users(self, filters: Query[UserFilterSchema]):

View File

@@ -29,14 +29,8 @@ from typing import TYPE_CHECKING, Any, LiteralString
from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
from django.core.exceptions import ImproperlyConfigured, PermissionDenied from django.core.exceptions import ImproperlyConfigured, PermissionDenied
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property
from django.utils.translation import gettext as _
from django.views.generic.base import View from django.views.generic.base import View
from club.models import Club
if TYPE_CHECKING: if TYPE_CHECKING:
from django.db.models import Model from django.db.models import Model
@@ -303,50 +297,3 @@ class PermissionOrAuthorRequiredMixin(PermissionRequiredMixin):
self.author_field += "_id" self.author_field += "_id"
author_id = getattr(obj, self.author_field, None) author_id = getattr(obj, self.author_field, None)
return author_id == self.request.user.id return author_id == self.request.user.id
class PermissionOrClubBoardRequiredMixin(PermissionRequiredMixin):
"""Require that the user has the required perm or is the board of the club.
This mixin can be used in any view that is called from a url
having a `club_id` kwarg.
Example:
In `urls.py` :
```python
urlpatterns = [
path("foo/<int:club_id>/bar/", FooView.as_view())
]
```
In `views.py` :
```python
# this view is available to users that either have the
# "foo.view_foo" permission or are in the board of the club
# which id was given in the url
class FooView(PermissionOrClubBoardRequiredMixin, View):
permission_required = "foo.view_foo"
```
"""
club_pk_url_kwarg = "club_id"
@cached_property
def club(self):
club_id: str | int = self.kwargs.pop(self.club_pk_url_kwarg, None)
if club_id is None:
return None
if isinstance(club_id, int) or club_id.isdigit():
return get_object_or_404(Club, pk=club_id)
raise Http404(_("No club found with id %(id)s") % {"id": club_id})
def has_permission(self):
if self.request.user.is_anonymous:
return False
if super().has_permission():
return True
return self.club is not None and any(
g.id == self.club.board_group_id for g in self.request.user.cached_groups
)

View File

@@ -94,11 +94,7 @@ class Command(BaseCommand):
username=self.faker.user_name(), username=self.faker.user_name(),
first_name=self.faker.first_name(), first_name=self.faker.first_name(),
last_name=self.faker.last_name(), last_name=self.faker.last_name(),
date_of_birth=( date_of_birth=self.faker.date_of_birth(minimum_age=15, maximum_age=25),
None
if random.random() < 0.2
else self.faker.date_of_birth(minimum_age=15, maximum_age=25)
),
email=self.faker.email(), email=self.faker.email(),
phone=self.faker.phone_number(), phone=self.faker.phone_number(),
address=self.faker.address(), address=self.faker.address(),

View File

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

View File

@@ -34,22 +34,6 @@ class SimpleUserSchema(ModelSchema):
fields = ["id", "nick_name", "first_name", "last_name"] fields = ["id", "nick_name", "first_name", "last_name"]
class UserSchema(ModelSchema):
class Meta:
model = User
fields = [
"id",
"nick_name",
"first_name",
"last_name",
"date_of_birth",
"email",
"role",
"quote",
"promo",
]
class UserProfileSchema(ModelSchema): class UserProfileSchema(ModelSchema):
"""The necessary information to show a user profile""" """The necessary information to show a user profile"""

View File

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

View File

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

View File

@@ -12,10 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from django.contrib.auth.mixins import PermissionRequiredMixin 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 # This file contains all the views that concern the page model
from django.forms.models import modelform_factory from django.forms.models import modelform_factory
@@ -46,20 +43,6 @@ class CanEditPagePropMixin(CanEditPropMixin):
class PageListView(CanViewMixin, ListView): class PageListView(CanViewMixin, ListView):
model = Page model = Page
template_name = "core/page_list.jinja" 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): class PageView(CanViewMixin, DetailView):

View File

@@ -5,6 +5,7 @@ from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget from phonenumber_field.widgets import RegionalPhoneNumberWidget
from club.models import Club
from club.widgets.ajax_select import AutoCompleteSelectClub from club.widgets.ajax_select import AutoCompleteSelectClub
from core.models import User from core.models import User
from core.views.forms import NFCTextInput, SelectDate, SelectDateTime from core.views.forms import NFCTextInput, SelectDate, SelectDateTime
@@ -19,6 +20,7 @@ from counter.models import (
Counter, Counter,
Customer, Customer,
Eticket, Eticket,
InvoiceCall,
Product, Product,
Refilling, Refilling,
ReturnableProduct, ReturnableProduct,
@@ -373,3 +375,39 @@ class BaseBasketForm(forms.BaseFormSet):
BasketForm = forms.formset_factory( BasketForm = forms.formset_factory(
ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1 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

@@ -535,6 +535,13 @@ class Counter(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def __getattribute__(self, name: str):
if name == "edit_groups":
return Group.objects.filter(
name=self.club.unix_name + settings.SITH_BOARD_SUFFIX
).all()
return object.__getattribute__(self, name)
def get_absolute_url(self) -> str: def get_absolute_url(self) -> str:
if self.type == "EBOUTIC": if self.type == "EBOUTIC":
return reverse("eboutic:main") return reverse("eboutic:main")
@@ -683,10 +690,8 @@ class Counter(models.Model):
Prices will be annotated Prices will be annotated
""" """
products = ( products = self.products.select_related("product_type").prefetch_related(
self.products.filter(archived=False) "buying_groups"
.select_related("product_type")
.prefetch_related("buying_groups")
) )
# Only include age appropriate products # Only include age appropriate products
@@ -1357,3 +1362,49 @@ class ReturnableProductBalance(models.Model):
f"return balance of {self.customer} " f"return balance of {self.customer} "
f"for {self.returnable.product_id} : {self.balance}" 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}"
def save(self, *args, **kwargs):
self.month = self._meta.get_field("month").to_python(self.month)
super().save(*args, **kwargs)

View File

@@ -15,24 +15,32 @@
</select> </select>
<input type="submit" value="{% trans %}Go{% endtrans %}" /> <input type="submit" value="{% trans %}Go{% endtrans %}" />
</form> </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 %}
<tr>
<td>{{ i['club__name'] }}</td>
<td>{{ i['selling_sum'] }} €</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
<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 data in club_data %}
<tr>
<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

@@ -583,16 +583,6 @@ class TestCounterClick(TestFullClickBase):
- self.beer.selling_price - self.beer.selling_price
) )
def test_no_fetch_archived_product(self):
counter = baker.make(Counter)
customer = baker.make(Customer)
product_recipe.make(archived=True, counters=[counter])
unarchived_products = product_recipe.make(
archived=False, counters=[counter], _quantity=3
)
customer_products = counter.get_products_for(customer)
assert unarchived_products == customer_products
class TestCounterStats(TestCase): class TestCounterStats(TestCase):
@classmethod @classmethod

View File

@@ -0,0 +1,33 @@
from datetime import date
import pytest
from model_bakery import baker
from club.models import Club
from counter.models import InvoiceCall
@pytest.mark.django_db
def test_invoice_date_with_date():
club = baker.make(Club)
invoice = InvoiceCall.objects.create(club=club, month=date(2025, 10, 20))
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)
@pytest.mark.django_db
def test_invoice_call_invalid_month_string():
club = baker.make(Club)
with pytest.raises(ValueError):
InvoiceCall.objects.create(club=club, month="2025-13")

View File

@@ -12,15 +12,17 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from datetime import datetime, timedelta from datetime import date, datetime, timedelta
from datetime import timezone as tz 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.utils import timezone
from django.views.generic import TemplateView from django.views.generic import TemplateView
from counter.fields import CurrencyField 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 from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
@@ -28,12 +30,30 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
template_name = "counter/invoices_call.jinja" template_name = "counter/invoices_call.jinja"
current_tab = "invoices_call" 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): def get_context_data(self, **kwargs):
"""Add sums to the context.""" """Add sums to the context."""
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["months"] = Selling.objects.datetimes("date", "month", order="DESC") kwargs["months"] = Selling.objects.datetimes("date", "month", order="DESC")
if "month" in self.request.GET: month_str = self.request.GET.get("month")
start_date = datetime.strptime(self.request.GET["month"], "%Y-%m")
if month_str:
try:
start_date = datetime.strptime(self.request.GET["month"], "%Y-%m")
except ValueError:
return redirect("counter:invoices_call")
else: else:
start_date = datetime( start_date = datetime(
year=timezone.now().year, year=timezone.now().year,
@@ -46,30 +66,23 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
) )
from django.db.models import Case, Sum, When from django.db.models import Case, Sum, When
kwargs["sum_cb"] = sum( kwargs["sum_cb"] = Refilling.objects.filter(
[ payment_method="CARD",
r.amount is_validated=True,
for r in Refilling.objects.filter( date__gte=start_date,
payment_method="CARD", date__lte=end_date,
is_validated=True, ).aggregate(amount=Sum(F("amount"), default=0))["amount"]
date__gte=start_date,
date__lte=end_date, kwargs["sum_cb"] += Selling.objects.filter(
) payment_method="CARD",
] is_validated=True,
) date__gte=start_date,
kwargs["sum_cb"] += sum( date__lte=end_date,
[ ).aggregate(amount=Sum(F("quantity") * F("unit_price"), default=0))["amount"]
s.quantity * s.unit_price
for s in Selling.objects.filter(
payment_method="CARD",
is_validated=True,
date__gte=start_date,
date__lte=end_date,
)
]
)
kwargs["start_date"] = start_date kwargs["start_date"] = start_date
kwargs["sums"] = (
kwargs["sums"] = list(
Selling.objects.values("club__name") Selling.objects.values("club__name")
.annotate( .annotate(
selling_sum=Sum( selling_sum=Sum(
@@ -86,4 +99,56 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
.exclude(selling_sum=None) .exclude(selling_sum=None)
.order_by("-selling_sum") .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 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 "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-19 17:22+0200\n" "POT-Creation-Date: 2025-09-01 18:18+0200\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -514,8 +514,8 @@ msgstr "Éditer le Trombi"
msgid "New Trombi" msgid "New Trombi"
msgstr "Nouveau Trombi" msgstr "Nouveau Trombi"
#: club/templates/club/club_tools.jinja club/views.py #: club/templates/club/club_tools.jinja com/templates/com/poster_list.jinja
#: com/templates/com/poster_list.jinja core/templates/core/user_tools.jinja #: core/templates/core/user_tools.jinja
msgid "Posters" msgid "Posters"
msgstr "Affiches" msgstr "Affiches"
@@ -675,6 +675,10 @@ msgstr "Vente"
msgid "Mailing list" msgid "Mailing list"
msgstr "Listes de diffusion" msgstr "Listes de diffusion"
#: club/views.py com/views.py
msgid "Posters list"
msgstr "Liste d'affiches"
#: com/forms.py #: com/forms.py
msgid "Format: 16:9 | Resolution: 1920x1080" msgid "Format: 16:9 | Resolution: 1920x1080"
msgstr "Format : 16:9 | Résolution : 1920x1080" msgstr "Format : 16:9 | Résolution : 1920x1080"
@@ -1245,10 +1249,6 @@ msgstr "Message d'info"
msgid "Alert message" msgid "Alert message"
msgstr "Message d'alerte" msgstr "Message d'alerte"
#: com/views.py
msgid "Posters list"
msgstr "Liste d'affiches"
#: com/views.py #: com/views.py
msgid "Screens list" msgid "Screens list"
msgstr "Liste d'écrans" msgstr "Liste d'écrans"
@@ -1272,11 +1272,6 @@ msgstr ""
"Vous devez êtres un membre du bureau du club sélectionné pour poster dans le " "Vous devez êtres un membre du bureau du club sélectionné pour poster dans le "
"Weekmail." "Weekmail."
#: core/auth/mixins.py
#, python-format
msgid "No club found with id %(id)s"
msgstr "Pas de club avec l'id %(id)s trouvé"
#: core/models.py #: core/models.py
msgid "Is manually manageable" msgid "Is manually manageable"
msgstr "Est gérable manuellement" msgstr "Est gérable manuellement"
@@ -1718,8 +1713,8 @@ msgid ""
"AE UTBM is a voluntary organisation run by UTBM students. It organises " "AE UTBM is a voluntary organisation run by UTBM students. It organises "
"student life at UTBM and manages its student facilities." "student life at UTBM and manages its student facilities."
msgstr "" msgstr ""
"L'AE UTBM est une association bénévole gérée par les étudiants de l'UTBM. " "L'AE UTBM est une association bénévole gérée par les étudiants de "
"Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie." "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 #: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja
msgid "Contacts" msgid "Contacts"
@@ -2162,6 +2157,10 @@ msgstr ""
msgid "Page history" msgid "Page history"
msgstr "Historique de la page" 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 #: core/templates/core/page_prop.jinja
msgid "Page properties" msgid "Page properties"
msgstr "Propriétés de la page" msgstr "Propriétés de la page"
@@ -5136,10 +5135,6 @@ msgstr "Tee-shirt AE"
msgid "A user with that email address already exists" msgid "A user with that email address already exists"
msgstr "Un utilisateur avec cette adresse email existe déjà" msgstr "Un utilisateur avec cette adresse email existe déjà"
#: subscription/forms.py
msgid "This user didn't fill its birthdate yet."
msgstr "Cet utilisateur n'a pas encore renseigné sa date de naissance"
#: subscription/models.py #: subscription/models.py
msgid "Bad subscription type" msgid "Bad subscription type"
msgstr "Mauvais type de cotisation" msgstr "Mauvais type de cotisation"
@@ -5179,7 +5174,7 @@ msgid ""
"%(user)s received its new %(type)s subscription. It will be active until " "%(user)s received its new %(type)s subscription. It will be active until "
"%(end)s included." "%(end)s included."
msgstr "" msgstr ""
"%(user)s a reçu sa nouvelle cotisaton %(type)s. Elle sera active jusqu'au " "%(user)s a reçu sa nouvelle cotisaton %(type)s. Elle sert active jusqu'au "
"%(end)s inclu." "%(end)s inclu."
#: subscription/templates/subscription/fragments/creation_success.jinja #: subscription/templates/subscription/fragments/creation_success.jinja

8
package-lock.json generated
View File

@@ -50,7 +50,7 @@
"@types/jquery": "^3.5.31", "@types/jquery": "^3.5.31",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.6", "vite": "^6.2.6",
"vite-bundle-visualizer": "^1.2.1", "vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.1.2" "vite-plugin-static-copy": "^3.1.2"
} }
@@ -5737,9 +5737,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.3.6", "version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -35,7 +35,7 @@
"@types/jquery": "^3.5.31", "@types/jquery": "^3.5.31",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.6", "vite": "^6.2.6",
"vite-bundle-visualizer": "^1.2.1", "vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.1.2" "vite-plugin-static-copy": "^3.1.2"
}, },

View File

@@ -25,7 +25,7 @@ dependencies = [
"Pillow<12.0.0,>=11.1.0", "Pillow<12.0.0,>=11.1.0",
"mistune<4.0.0,>=3.1.3", "mistune<4.0.0,>=3.1.3",
"django-jinja<3.0.0,>=2.11.0", "django-jinja<3.0.0,>=2.11.0",
"cryptography>=45.0.3,<47.0.0", "cryptography>=45.0.3,<46.0.0",
"django-phonenumber-field<9.0.0,>=8.1.0", "django-phonenumber-field<9.0.0,>=8.1.0",
"phonenumbers>=9.0.2,<10.0.0", "phonenumbers>=9.0.2,<10.0.0",
"reportlab<5.0.0,>=4.3.1", "reportlab<5.0.0,>=4.3.1",

View File

@@ -405,6 +405,9 @@ SITH_FORUM_PAGE_LENGTH = 30
SITH_SAS_ROOT_DIR_ID = env.int("SITH_SAS_ROOT_DIR_ID", default=4) SITH_SAS_ROOT_DIR_ID = env.int("SITH_SAS_ROOT_DIR_ID", default=4)
SITH_SAS_IMAGES_PER_PAGE = 60 SITH_SAS_IMAGES_PER_PAGE = 60
SITH_BOARD_SUFFIX = "-bureau"
SITH_MEMBER_SUFFIX = "-membres"
SITH_PROFILE_DEPARTMENTS = [ SITH_PROFILE_DEPARTMENTS = [
("TC", _("TC")), ("TC", _("TC")),
("IMSI", _("IMSI")), ("IMSI", _("IMSI")),

View File

@@ -23,8 +23,8 @@ class SelectionDateForm(forms.Form):
class SubscriptionForm(forms.ModelForm): class SubscriptionForm(forms.ModelForm):
def __init__(self, *args, initial=None, **kwargs): def __init__(self, *args, **kwargs):
initial = initial or {} initial = kwargs.pop("initial", {})
if "subscription_type" not in initial: if "subscription_type" not in initial:
initial["subscription_type"] = "deux-semestres" initial["subscription_type"] = "deux-semestres"
if "payment_method" not in initial: if "payment_method" not in initial:
@@ -131,57 +131,8 @@ class SubscriptionExistingUserForm(SubscriptionForm):
"""Form to add a subscription to an existing user.""" """Form to add a subscription to an existing user."""
template_name = "subscription/forms/create_existing_user.html" template_name = "subscription/forms/create_existing_user.html"
required_css_class = "required"
birthdate = forms.fields_for_model(
User,
["date_of_birth"],
widgets={"date_of_birth": SelectDate(attrs={"hidden": True})},
help_texts={"date_of_birth": _("This user didn't fill its birthdate yet.")},
)["date_of_birth"]
class Meta: class Meta:
model = Subscription model = Subscription
fields = ["member", "subscription_type", "payment_method", "location"] fields = ["member", "subscription_type", "payment_method", "location"]
widgets = {"member": AutoCompleteSelectUser} widgets = {"member": AutoCompleteSelectUser}
field_order = [
"member",
"birthdate",
"subscription_type",
"payment_method",
"location",
]
def __init__(self, *args, initial=None, **kwargs):
super().__init__(*args, initial=initial, **kwargs)
self.fields["birthdate"].required = True
if not initial:
return
member: str | None = initial.get("member")
if member and member.isdigit():
member: User | None = User.objects.filter(id=int(member)).first()
else:
member = None
if member and member.date_of_birth:
# if there is an initial member with a birthdate,
# there is no need to ask this to the user
self.fields["birthdate"].initial = member.date_of_birth
elif member:
# if there is an initial member without a birthdate,
# then the field must be displayed
self.fields["birthdate"].widget.attrs.update({"hidden": False})
# if there is no initial member, it means that it will be
# dynamically selected using the AutoCompleteSelectUser widget.
# JS will take care of un-hiding the field if necessary
def save(self, *args, **kwargs):
if self.errors:
return super().save(*args, **kwargs)
if (
self.cleaned_data["birthdate"] is not None
and self.instance.member.date_of_birth is None
):
self.instance.member.date_of_birth = self.cleaned_data["birthdate"]
self.instance.member.save()
return super().save(*args, **kwargs)

View File

@@ -1,5 +1,3 @@
import { userFetchUser } from "#openapi";
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("existing_user_subscription_form", () => ({ Alpine.data("existing_user_subscription_form", () => ({
loading: false, loading: false,
@@ -14,24 +12,13 @@ document.addEventListener("alpine:init", () => {
}, },
async loadProfile(userId: number) { async loadProfile(userId: number) {
const birthdayInput = document.getElementById("id_birthdate") as HTMLInputElement;
if (!Number.isInteger(userId)) { if (!Number.isInteger(userId)) {
this.profileFragment = ""; this.profileFragment = "";
birthdayInput.hidden = true;
return; return;
} }
this.loading = true; this.loading = true;
const [miniProfile, userInfos] = await Promise.all([ const response = await fetch(`/user/${userId}/mini/`);
fetch(`/user/${userId}/mini/`), this.profileFragment = await response.text();
// biome-ignore lint/style/useNamingConvention: api is snake_case
userFetchUser({ path: { user_id: userId } }),
]);
this.profileFragment = await miniProfile.text();
// If the user has no birthdate yet, show the form input
// to fill this info.
// Else keep the input hidden and change its value to the user birthdate
birthdayInput.value = userInfos.data.date_of_birth;
birthdayInput.hidden = userInfos.data.date_of_birth !== null;
this.loading = false; this.loading = false;
}, },
})); }));

View File

@@ -1,14 +1,4 @@
#subscription-form form { #subscription-form form {
margin-top: 0;
.form-content {
margin-top: 0;
}
fieldset p:first-of-type, & > p:first-of-type {
margin-top: 0;
}
.form-content.existing-user { .form-content.existing-user {
max-height: 100%; max-height: 100%;
display: flex; display: flex;
@@ -23,11 +13,6 @@
* then display the user profile right in the middle of the remaining space. */ * then display the user profile right in the middle of the remaining space. */
fieldset { fieldset {
flex: 0 1 auto; flex: 0 1 auto;
p:has(input[hidden]) {
// when the input is hidden, hide the whole label+input+help text group
display: none;
}
} }
#subscription-form-user-mini-profile { #subscription-form-user-mini-profile {

View File

@@ -1,6 +1,6 @@
"""Tests focused on testing subscription creation""" """Tests focused on testing subscription creation"""
from datetime import date, timedelta from datetime import timedelta
from typing import Callable from typing import Callable
import pytest import pytest
@@ -31,26 +31,6 @@ def test_form_existing_user_valid(
): ):
"""Test `SubscriptionExistingUserForm`""" """Test `SubscriptionExistingUserForm`"""
user = user_factory() user = user_factory()
user.date_of_birth = date(year=1967, month=3, day=14)
user.save()
data = {
"member": user,
"birthdate": user.date_of_birth,
"subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
}
form = SubscriptionExistingUserForm(data)
assert form.is_valid()
form.save()
user.refresh_from_db()
assert user.is_subscribed
@pytest.mark.django_db
def test_form_existing_user_with_birthdate(settings: SettingsWrapper):
"""Test `SubscriptionExistingUserForm`"""
user = baker.make(User, date_of_birth=None)
data = { data = {
"member": user, "member": user,
"subscription_type": "deux-semestres", "subscription_type": "deux-semestres",
@@ -58,15 +38,11 @@ def test_form_existing_user_with_birthdate(settings: SettingsWrapper):
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0], "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
} }
form = SubscriptionExistingUserForm(data) form = SubscriptionExistingUserForm(data)
assert not form.is_valid()
data |= {"birthdate": date(year=1967, month=3, day=14)}
form = SubscriptionExistingUserForm(data)
assert form.is_valid() assert form.is_valid()
form.save() form.save()
user.refresh_from_db() user.refresh_from_db()
assert user.is_subscribed assert user.is_subscribed
assert user.date_of_birth == date(year=1967, month=3, day=14)
@pytest.mark.django_db @pytest.mark.django_db
@@ -156,14 +132,6 @@ def test_page_access(
assert res.status_code == status_code assert res.status_code == status_code
@pytest.mark.django_db
def test_page_access_with_get_data(client: Client):
user = old_subscriber_user.make()
client.force_login(baker.make(User, is_superuser=True))
res = client.get(reverse("subscription:subscription", query={"member": user.id}))
assert res.status_code == 200
@pytest.mark.django_db @pytest.mark.django_db
def test_submit_form_existing_user(client: Client, settings: SettingsWrapper): def test_submit_form_existing_user(client: Client, settings: SettingsWrapper):
client.force_login( client.force_login(
@@ -172,12 +140,11 @@ def test_submit_form_existing_user(client: Client, settings: SettingsWrapper):
user_permissions=Permission.objects.filter(codename="add_subscription"), user_permissions=Permission.objects.filter(codename="add_subscription"),
) )
) )
user = old_subscriber_user.make(date_of_birth=date(year=1967, month=3, day=14)) user = old_subscriber_user.make()
response = client.post( response = client.post(
reverse("subscription:fragment-existing-user"), reverse("subscription:fragment-existing-user"),
{ {
"member": user.id, "member": user.id,
"birthdate": user.date_of_birth,
"subscription_type": "deux-semestres", "subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0], "location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0], "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],