Compare commits

..

2 Commits

Author SHA1 Message Date
imperosol
59ded530ff docs: more details on AI guideline rationals 2025-09-16 10:53:43 +02:00
imperosol
e85d0a2449 docs: AI guideline 2025-09-16 09:51:49 +02:00
28 changed files with 373 additions and 425 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

@@ -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

@@ -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

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

120
docs/explanation/ia.md Normal file
View File

@@ -0,0 +1,120 @@
Cette page expose la politique du Pôle informatique de l'AE
en ce qui concerne l'usage et l'implémentation de systèmes d'IA
dans le cadre de l'AE et du développement de ses outils.
## Cadre
En accord avec le règlement européen sur
l'intelligence artificielle du 13 juin 2024,
nous définissons comme IA :
> Un système basé sur une machine qui est
> conçu pour fonctionner avec différents niveaux d'autonomie
> et qui peut faire preuve d'adaptabilité après son déploiement,
> et qui, pour des objectifs explicites ou implicites, déduit,
> à partir des données qu'il reçoit,
> comment générer des résultats tels que des prédictions,
> du contenu, des recommandations ou des décisions
> qui peuvent influencer des environnements physiques ou virtuels.
Cette définition recouvre toutes les IAs génératives, ce qui inclut
ChatGPT, DeepSeek, Claude, Copilot, Mistral, Llama et autres outils similaires.
## Utilisation dans le développement
!!!abstract
La soumission de code généré par IA est strictement interdite.
Aucune contribution contenant du code généré par IA n'est acceptée.
Toute PR contenant en proportion significative du code duquel
on peut raisonnablement penser qu'il a été généré par IA
ne sera acceptée.
Nous déconseillons également fortement l'usage de tout
recours à un système d'IA dans le processus de développement,
quel que soit son usage (debug, recherche d'information ou autres).
A la place de l'IA, Référez-vous en priorité à la documentation du site,
à celle de Django et à l'aide des autres développeurs.
## Intégration dans le site
L'intégration sur le site AE de systèmes d'IA
et de toute fonctionnalité basée sur des systèmes d'IA
est strictement prohibée, quel qu'en soit l'objectif.
Toute tâche de modération, de génération
ou de détection de contenu ne doit être accomplie
que par des êtres humains ou par des algorithmes
déterministes, testés et compris.
L'usage des données du site a des fins d'entrainement d'IA,
ainsi que la transmission de ces données à un système d'IA
est strictement interdit.
Tout acte de cette nature sera considéré comme une violation
grave de la politique de gestion des données de l'AE.
## Motifs
Le site AE est un programme écrit par des humains, pour des humains.
C'est un logiciel dont la complexité nécessite des connaissances
plus approfondies que ce qui est attendu de la part d'un
étudiant en TC ou en base branche.
À ce titre, l'interdiction de l'IA dans le cadre de son
développement est pensée avant tout dans une optique
de formation des développeurs, de stabilité de la base de code
et de transmission des connaissances.
### Formation des développeurs
Travailler sur le site AE est possiblement le meilleur moyen de
monter en compétences en informatique pour un étudiant de l'UTBM.
Automatisation des tests, gestion des données et de la sécurité,
infrastructure, maintenance du code existant...
Le site AE est un logiciel complet, dont le développement
possède une dimension pédagogique réelle.
En utilisant l'IA, le développement n'est plus un moyen efficace
de se former.
### Stabilité de la base de code
Les développeurs du site AE sont pour la plupart en cours de formation,
sans compréhension globale de la base de code du site,
des outils logiciels sur lesquels il se base et des bonnes
pratiques permettant d'écrire du code viable.
En se reposant sur un système d'IA sans être capacité
de comprendre intégralement le code proposé ni de le mettre
en perspective avec le reste de la base de code,
c'est toute la maintenance de la base de code qui se retrouve compromise.
### Transmission des connaissances
L'équipe du pôle informatique se renouvelle très souvent.
À ce titre, les nouveaux développeurs se doivent d'hériter
d'une base de code viable.
Quant aux anciens développeurs, ils se doivent d'en avoir
compris le fonctionnement, afin d'être en mesure
de guider et d'aider leurs successeurs.
Comme développé dans les deux points précédents,
cet objectif est incompatible avec l'usage de systèmes d'IA.
### Autres motifs
En plus de ces aspects purement liés à la qualité
du code et à la pédagogie, l'IA pose des problèmes
écologiques et éthiques :
- Les projets commerciaux d'IA se livrent fréquemment
à des violations flagrantes du droit d'auteur
et à un mépris complet des petits acteurs du web
(dont le site AE fait partie) pour entraîner leurs modèles.
- Leurs activités nécessitent une consommation massive d'énergie et d'eau,
ainsi qu'une quantité massive de matériel, entrainant conflits d'usage,
et intensification des dommages sociaux et environnementaux de l'industrie.
- La promotion et l'utilisation des modèles d'IA ont causé un préjudice
important aux salariés, une aliénation de leur travail et une baisse de la qualité des services.
- Les LLM ont favorisé les activités de spam et d'escroquerie.
- L'IA est devenu un maillon essentiel du fichage des individus,
du renforcement des biais sociétaux, et de la désinformation de masse.

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

View File

@@ -57,6 +57,7 @@ nav:
- Accueil: explanation/index.md - Accueil: explanation/index.md
- Technologies utilisées: explanation/technos.md - Technologies utilisées: explanation/technos.md
- Conventions: explanation/conventions.md - Conventions: explanation/conventions.md
- Politique IA: explanation/ia.md
- Archives: explanation/archives.md - Archives: explanation/archives.md
- Tutoriels: - Tutoriels:
- Installer le projet: tutorial/install.md - Installer le projet: tutorial/install.md

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],