13 Commits

Author SHA1 Message Date
imperosol
753b219c53 add feedback when moving reservation slot 2026-03-08 19:26:18 +01:00
imperosol
bb36707c29 test: room and slots creation/edition 2026-03-08 19:25:31 +01:00
imperosol
5d78529702 fix: rebase issues 2026-03-08 19:25:30 +01:00
imperosol
ed51f75601 test: ReservationForm 2026-03-08 19:24:44 +01:00
imperosol
08d19fed59 add translations 2026-03-08 19:24:44 +01:00
imperosol
a97fc7a29a Room reservation form 2026-03-08 19:24:42 +01:00
imperosol
9645c7fead Room reservations planning 2026-03-08 19:24:14 +01:00
imperosol
0f71e220c4 room management views 2026-03-08 19:21:40 +01:00
imperosol
d6639f4ff5 fix: FutureDateTime form field 2026-03-08 19:21:40 +01:00
imperosol
d07aeb5e63 reservable rooms API 2026-03-08 19:21:40 +01:00
imperosol
056afbebda generate test data for the reservations 2026-03-08 19:21:38 +01:00
Thomas Girod
e7eb3562ad create reservation models 2026-03-08 19:20:56 +01:00
Sli
77995dcc0f Adapt calendar to new tooltip library 2026-03-08 19:20:50 +01:00
97 changed files with 4156 additions and 3093 deletions

3
.gitignore vendored
View File

@@ -24,9 +24,6 @@ node_modules/
# compiled documentation
site/
# rollup-bundle-visualizer report
.bundle-size-report.html
### Redis ###
# Ignore redis binary dump (dump.rdb) files

View File

@@ -1,7 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.5
rev: v0.15.0
hooks:
- id: ruff-check # just check the code, and print the errors
- id: ruff-check # actually fix the fixable errors, but print nothing
@@ -12,7 +12,7 @@ repos:
rev: v0.6.1
hooks:
- id: biome-check
additional_dependencies: ["@biomejs/biome@2.4.6"]
additional_dependencies: ["@biomejs/biome@2.3.14"]
- repo: https://github.com/rtts/djhtml
rev: 3.0.10
hooks:

View File

@@ -7,7 +7,7 @@
},
"files": {
"ignoreUnknown": false,
"includes": ["**/static/**", "vite.config.mts"]
"includes": ["**/static/**"]
},
"formatter": {
"enabled": true,

View File

@@ -14,7 +14,7 @@
#
from django.contrib import admin
from club.models import Club, ClubRole, Membership
from club.models import Club, Membership
@admin.register(Club)
@@ -30,20 +30,6 @@ class ClubAdmin(admin.ModelAdmin):
)
@admin.register(ClubRole)
class ClubRoleAdmin(admin.ModelAdmin):
list_display = ("name", "club", "is_board", "is_presidency")
search_fields = ("name",)
autocomplete_fields = ("club",)
list_select_related = ("club",)
list_filter = (
"is_board",
"is_presidency",
("club", admin.RelatedOnlyFieldListFilter),
)
show_facets = admin.ModelAdmin.show_facets.ALWAYS
@admin.register(Membership)
class MembershipAdmin(admin.ModelAdmin):
list_display = ("user", "club", "role", "start_date", "end_date")

View File

@@ -6,15 +6,9 @@ from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from api.auth import ApiKeyAuth
from api.permissions import CanAccessLookup, CanView, HasPerm
from api.permissions import CanAccessLookup, HasPerm
from club.models import Club, Membership
from club.schemas import (
ClubSchema,
ClubSearchFilterSchema,
SimpleClubSchema,
UserMembershipSchema,
)
from core.models import User
from club.schemas import ClubSchema, ClubSearchFilterSchema, SimpleClubSchema
@api_controller("/club")
@@ -39,28 +33,8 @@ class ClubController(ControllerBase):
)
def fetch_club(self, club_id: int):
prefetch = Prefetch(
"members",
queryset=Membership.objects.ongoing().select_related("user", "role"),
"members", queryset=Membership.objects.ongoing().select_related("user")
)
return self.get_object_or_exception(
Club.objects.prefetch_related(prefetch), id=club_id
)
@api_controller("/user/{int:user_id}/club")
class UserClubController(ControllerBase):
@route.get(
"",
response=list[UserMembershipSchema],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[CanView],
url_name="fetch_user_clubs",
)
def fetch_user_clubs(self, user_id: int):
"""Get all the active memberships of the given user."""
user = self.get_object_or_exception(User, id=user_id)
return (
Membership.objects.ongoing()
.filter(user=user)
.select_related("club", "user", "role")
)

View File

@@ -23,12 +23,13 @@
#
from django import forms
from django.db.models import Exists, OuterRef, Q, QuerySet
from django.conf import settings
from django.db.models import Exists, OuterRef, Q
from django.db.models.functions import Lower
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from club.models import Club, ClubRole, Mailing, MailingSubscription, Membership
from club.models import Club, Mailing, MailingSubscription, Membership
from core.models import User
from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import (
@@ -214,7 +215,9 @@ class ClubOldMemberForm(forms.Form):
def __init__(self, *args, user: User, club: Club, **kwargs):
super().__init__(*args, **kwargs)
self.fields["members_old"].queryset = club.members.ongoing().editable_by(user)
self.fields["members_old"].queryset = (
Membership.objects.ongoing().filter(club=club).editable_by(user)
)
class ClubMemberForm(forms.ModelForm):
@@ -232,14 +235,19 @@ class ClubMemberForm(forms.ModelForm):
self.request_user = request_user
self.request_user_membership = self.club.get_membership_for(self.request_user)
super().__init__(*args, **kwargs)
self.fields["role"].queryset = self.available_roles
self.fields["role"].required = True
self.fields["role"].choices = [
(value, name)
for value, name in settings.SITH_CLUB_ROLES.items()
if value <= self.max_available_role
]
self.instance.club = club
@property
def available_roles(self) -> QuerySet[ClubRole]:
def max_available_role(self):
"""The greatest role that will be obtainable with this form."""
# this is unreachable, because it will be overridden by subclasses
return ClubRole.objects.none() # pragma: no cover
return -1 # pragma: no cover
class ClubAddMemberForm(ClubMemberForm):
@@ -250,7 +258,7 @@ class ClubAddMemberForm(ClubMemberForm):
widgets = {"user": AutoCompleteSelectUser}
@cached_property
def available_roles(self):
def max_available_role(self):
"""The greatest role that will be obtainable with this form.
Admins and the club president can attribute any role.
@@ -258,13 +266,13 @@ class ClubAddMemberForm(ClubMemberForm):
Other users cannot attribute roles with this form
"""
if self.request_user.has_perm("club.add_membership"):
return self.club.roles.all()
return settings.SITH_CLUB_ROLES_ID["President"]
membership = self.request_user_membership
if membership is None or not membership.role.is_board:
return ClubRole.objects.none()
if membership.role.is_presidency:
return self.club.roles.all()
return self.club.roles.above_instance(membership.role)
if membership is None or membership.role <= settings.SITH_MAXIMUM_FREE_ROLE:
return -1
if membership.role == settings.SITH_CLUB_ROLES_ID["President"]:
return membership.role
return membership.role - 1
def clean_user(self):
"""Check that the user is not trying to add a user already in the club.
@@ -288,11 +296,13 @@ class JoinClubForm(ClubMemberForm):
def __init__(self, *args, club: Club, request_user: User, **kwargs):
super().__init__(*args, club=club, request_user=request_user, **kwargs)
# this form doesn't manage the user who will join the club,
# so we must set this here to avoid errors
self.instance.user = self.request_user
@cached_property
def available_roles(self):
return self.club.roles.filter(is_board=False)
def max_available_role(self):
return settings.SITH_MAXIMUM_FREE_ROLE
def clean(self):
"""Check that the user is subscribed and isn't already in the club."""

View File

@@ -2,15 +2,12 @@
import django.db.models.deletion
import django.db.models.functions.datetime
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Q
from django.utils.timezone import localdate
# Before the club role rework, the maximum free role
# was the hardcoded highest non-board role
MAXIMUM_FREE_ROLE = 1
def migrate_meta_groups(apps: StateApps, schema_editor):
"""Attach the existing meta groups to the clubs.
@@ -49,7 +46,10 @@ def migrate_meta_groups(apps: StateApps, schema_editor):
).select_related("user")
club.members_group.users.set([m.user for m in memberships])
club.board_group.users.set(
[m.user for m in memberships.filter(role__gt=MAXIMUM_FREE_ROLE)]
[
m.user
for m in memberships.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
]
)

View File

@@ -1,138 +0,0 @@
# Generated by Django 5.2.3 on 2025-06-21 21:59
import django.db.models.deletion
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Case, When
PRESIDENCY_ROLES = [10, 9]
MAXIMUM_FREE_ROLE = 1
SITH_CLUB_ROLES = {
10: "Président⸱e",
9: "Vice-Président⸱e",
7: "Trésorier⸱e",
5: "Responsable communication",
4: "Secrétaire",
3: "Responsable info",
2: "Membre du bureau",
1: "Membre actif⸱ve",
0: "Curieux⸱euse",
}
def migrate_roles(apps: StateApps, schema_editor):
ClubRole = apps.get_model("club", "ClubRole")
Membership = apps.get_model("club", "Membership")
updates = []
for club_id, role in Membership.objects.values_list("club", "role").distinct():
new_role = ClubRole.objects.create(
name=SITH_CLUB_ROLES[role],
is_board=role > MAXIMUM_FREE_ROLE,
is_presidency=role in PRESIDENCY_ROLES,
club_id=club_id,
order=max(SITH_CLUB_ROLES) - role,
)
updates.append(When(role=role, then=new_role.id))
# all updates must happen at the same time
# otherwise, the 10 first created ClubRole would be
# re-modified after their initial creation, and it would
# result in an incoherent state.
# To avoid that, all updates are wrapped in a single giant Case(When) statement
# cf. https://docs.djangoproject.com/fr/stable/ref/models/conditional-expressions/#conditional-update
Membership.objects.update(role=Case(*updates))
class Migration(migrations.Migration):
dependencies = [
("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"),
("core", "0047_alter_notification_date_alter_notification_type"),
]
operations = [
migrations.AlterField(
model_name="club",
name="page",
field=models.OneToOneField(
blank=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="club",
to="core.page",
),
),
migrations.CreateModel(
name="ClubRole",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"order",
models.PositiveIntegerField(
db_index=True, editable=False, verbose_name="order"
),
),
(
"club",
models.ForeignKey(
help_text="The club with which this role is associated",
on_delete=django.db.models.deletion.CASCADE,
related_name="roles",
to="club.club",
verbose_name="club",
),
),
("name", models.CharField(max_length=50, verbose_name="name")),
(
"description",
models.TextField(
default="", blank=True, verbose_name="description"
),
),
(
"is_board",
models.BooleanField(default=False, verbose_name="Board role"),
),
(
"is_presidency",
models.BooleanField(default=False, verbose_name="Presidency role"),
),
(
"is_active",
models.BooleanField(
default=True,
help_text=(
"If the role is inactive, people joining the club "
"won't be able to get it."
),
verbose_name="is active",
),
),
],
options={
"ordering": ("order",),
"verbose_name": "club role",
"verbose_name_plural": "club roles",
},
),
migrations.AddConstraint(
model_name="clubrole",
constraint=models.CheckConstraint(
condition=models.Q(
("is_presidency", False), ("is_board", True), _connector="OR"
),
name="clubrole_presidency_implies_board",
),
),
migrations.RunPython(migrate_roles, migrations.RunPython.noop),
# because Postgres migrations run in a single transaction,
# we cannot change the actual values of Membership.role
# and apply the FOREIGN KEY constraint in the same migration.
# The constraint is created in the next migration
]

View File

@@ -1,25 +0,0 @@
# Generated by Django 5.2.3 on 2025-09-27 09:57
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("club", "0015_clubrole_alter_membership_role")]
operations = [
# because Postgres migrations run in a single transaction,
# we cannot change the actual values of Membership.role
# and apply the FOREIGN KEY constraint in the same migration.
# The data migration was made in the previous migration.
migrations.AlterField(
model_name="membership",
name="role",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="members",
to="club.clubrole",
verbose_name="role",
),
),
]

View File

@@ -29,14 +29,14 @@ from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email
from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q
from django.db.models import Exists, F, OuterRef, Q, Value
from django.db.models.functions import Greatest
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.text import slugify
from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _
from ordered_model.models import OrderedModel
from core.fields import ResizedImageField
from core.models import Group, Notification, Page, SithFile, User
@@ -89,7 +89,7 @@ class Club(models.Model):
on_delete=models.SET_NULL,
)
page = models.OneToOneField(
Page, related_name="club", blank=True, on_delete=models.PROTECT
Page, related_name="club", blank=True, on_delete=models.CASCADE
)
members_group = models.OneToOneField(
Group, related_name="club", on_delete=models.PROTECT
@@ -138,7 +138,9 @@ class Club(models.Model):
@cached_property
def president(self) -> Membership | None:
"""Fetch the membership of the current president of this club."""
return self.members.filter(end_date=None).order_by("role__order").first()
return self.members.filter(
role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
).first()
def check_loop(self):
"""Raise a validation error when a loop is found within the parent list."""
@@ -206,9 +208,7 @@ class Club(models.Model):
@cached_property
def current_members(self) -> list[Membership]:
return list(
self.members.ongoing().select_related("user", "role").order_by("-role")
)
return list(self.members.ongoing().select_related("user").order_by("-role"))
def get_membership_for(self, user: User) -> Membership | None:
"""Return the current membership of the given user."""
@@ -220,77 +220,6 @@ class Club(models.Model):
return user.is_in_group(pk=self.board_group_id)
class ClubRole(OrderedModel):
club = models.ForeignKey(
Club,
verbose_name=_("club"),
help_text=_("The club with which this role is associated"),
related_name="roles",
on_delete=models.CASCADE,
)
name = models.CharField(_("name"), max_length=50)
description = models.TextField(_("description"), blank=True, default="")
is_board = models.BooleanField(_("Board role"), default=False)
is_presidency = models.BooleanField(_("Presidency role"), default=False)
is_active = models.BooleanField(
_("is active"),
default=True,
help_text=_(
"If the role is inactive, people joining the club won't be able to get it."
),
)
order_with_respect_to = "club"
class Meta(OrderedModel.Meta):
verbose_name = _("club role")
verbose_name_plural = _("club roles")
abstract = False
constraints = [
# presidency IMPLIES board <=> NOT presidency OR board
# cf. MT1 :)
models.CheckConstraint(
condition=Q(is_presidency=False) | Q(is_board=True),
name="clubrole_presidency_implies_board",
)
]
def __str__(self):
return self.name
def get_display_name(self):
return f"{self.name} - {self.club.name}"
def get_absolute_url(self):
return reverse("club:club_roles", kwargs={"club_id": self.club_id})
def clean(self):
errors = []
if self.is_presidency and not self.is_board:
errors.append(
ValidationError(
_(
"Role %(name)s was declared as a presidency role "
"without being a board role"
)
% {"name": self.name}
)
)
if (
self.is_board
and self.club.roles.filter(is_board=False, order__lt=self.order).exists()
):
errors.append(
ValidationError(
_("Board role %(role)s cannot be placed below a member role")
% {"role": self.name}
)
)
if errors:
raise ValidationError(errors)
return super().clean()
class MembershipQuerySet(models.QuerySet):
def ongoing(self) -> Self:
"""Filter all memberships which are not finished yet."""
@@ -303,10 +232,9 @@ class MembershipQuerySet(models.QuerySet):
are included, even if there are no more members.
If you want to get the users who are currently in the board,
mind combining this with the [MembershipQuerySet.ongoing][]
queryset method
mind combining this with the `ongoing` queryset method
"""
return self.filter(role__is_board=True)
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
def editable_by(self, user: User) -> Self:
"""Filter Memberships that this user can edit.
@@ -329,16 +257,21 @@ class MembershipQuerySet(models.QuerySet):
"""
if user.has_perm("club.change_membership"):
return self.all()
return self.ongoing().filter(
return self.filter(
Q(user=user)
| Exists(
Membership.objects.ongoing().filter(
Membership.objects.filter(
Q(
role__gt=Greatest(
OuterRef("role"), Value(settings.SITH_MAXIMUM_FREE_ROLE)
)
),
user=user,
end_date=None,
club=OuterRef("club"),
role__is_board=True,
role__order__lt=OuterRef("role__order"),
)
)
),
end_date=None,
)
def update(self, **kwargs) -> int:
@@ -408,11 +341,10 @@ class Membership(models.Model):
)
start_date = models.DateField(_("start date"), default=timezone.now)
end_date = models.DateField(_("end date"), null=True, blank=True)
role = models.ForeignKey(
ClubRole,
verbose_name=_("role"),
related_name="members",
on_delete=models.PROTECT,
role = models.IntegerField(
_("role"),
choices=sorted(settings.SITH_CLUB_ROLES.items()),
default=sorted(settings.SITH_CLUB_ROLES.items())[0][0],
)
description = models.CharField(
_("description"), max_length=128, null=False, blank=True
@@ -430,7 +362,7 @@ class Membership(models.Model):
def __str__(self):
return (
f"{self.club.name} - {self.user.username} "
f"- {self.role.name} "
f"- {settings.SITH_CLUB_ROLES[self.role]} "
f"- {str(_('past member')) if self.end_date is not None else ''}"
)
@@ -459,11 +391,7 @@ class Membership(models.Model):
if user.is_root or user.is_board_member:
return True
membership = self.club.get_membership_for(user)
if not membership:
return False
return membership.user_id == user.id or (
membership.is_board and membership.role.order < self.role.order
)
return membership is not None and membership.role >= self.role
def delete(self, *args, **kwargs):
self._remove_club_groups([self])
@@ -539,7 +467,7 @@ class Membership(models.Model):
group_id=membership.club.members_group_id,
)
)
if membership.role.is_board:
if membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
club_groups.append(
User.groups.through(
user_id=membership.user_id,

View File

@@ -3,7 +3,7 @@ from typing import Annotated
from django.db.models import Q
from ninja import FilterLookup, FilterSchema, ModelSchema
from club.models import Club, ClubRole, Membership
from club.models import Club, Membership
from core.schemas import NonEmptyStr, SimpleUserSchema
@@ -39,21 +39,12 @@ class ClubProfileSchema(ModelSchema):
return obj.get_absolute_url()
class ClubRoleSchema(ModelSchema):
class Meta:
model = ClubRole
fields = ["id", "name", "is_presidency", "is_board"]
class ClubMemberSchema(ModelSchema):
"""A schema to represent all memberships in a club."""
class Meta:
model = Membership
fields = ["start_date", "end_date", "description"]
fields = ["start_date", "end_date", "role", "description"]
user: SimpleUserSchema
role: ClubRoleSchema
class ClubSchema(ModelSchema):
@@ -62,14 +53,3 @@ class ClubSchema(ModelSchema):
fields = ["id", "name", "logo", "is_active", "short_description", "address"]
members: list[ClubMemberSchema]
class UserMembershipSchema(ModelSchema):
"""A schema to represent the active club memberships of a user."""
class Meta:
model = Membership
fields = ["id", "start_date", "description"]
club: SimpleClubSchema
role: ClubRoleSchema

View File

@@ -45,7 +45,7 @@
{% for m in members %}
<tr>
<td>{{ user_profile_link(m.user) }}</td>
<td>{{ m.role.name }}</td>
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
<td>{{ m.description }}</td>
<td>{{ m.start_date }}</td>
{%- if can_end_membership -%}

View File

@@ -17,7 +17,7 @@
{% for member in old_members %}
<tr>
<td>{{ user_profile_link(member.user) }}</td>
<td>{{ member.role.name }}</td>
<td>{{ settings.SITH_CLUB_ROLES[member.role] }}</td>
<td>{{ member.description }}</td>
<td>{{ member.start_date }}</td>
<td>{{ member.end_date }}</td>

View File

@@ -1,25 +1,63 @@
{% extends "core/base.jinja" %}
{% from "reservation/macros.jinja" import room_detail %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
{% endblock %}
{% block content %}
<h3>{% trans %}Club tools{% endtrans %}</h3>
<h3>{% trans %}Club tools{% endtrans %} ({{ club.name }})</h3>
<div>
<h4>{% trans %}Communication:{% endtrans %}</h4>
<ul>
<li> <a href="{{ url('com:news_new') }}?club={{ object.id }}">{% trans %}Create a news{% endtrans %}</a></li>
<li> <a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">{% trans %}Post in the Weekmail{% endtrans %}</a></li>
<li>
<a href="{{ url('com:news_new') }}?club={{ object.id }}">
{% trans %}Create a news{% endtrans %}
</a>
</li>
<li>
<a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">
{% trans %}Post in the Weekmail{% endtrans %}
</a>
</li>
{% if object.trombi %}
<li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li>
<li>
<a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">
{% trans %}Edit Trombi{% endtrans %}</a>
</li>
{% else %}
<li> <a href="{{ url('trombi:create', club_id=object.id) }}">{% trans %}New Trombi{% endtrans %}</a></li>
<li> <a href="{{ url('club:poster_list', club_id=object.id) }}">{% trans %}Posters{% endtrans %}</a></li>
<li><a href="{{ url('trombi:create', club_id=object.id) }}">{% trans %}New Trombi{% endtrans %}</a></li>
<li><a href="{{ url('club:poster_list', club_id=object.id) }}">{% trans %}Posters{% endtrans %}</a></li>
{% endif %}
</ul>
<h4>{% trans %}Reservable rooms{% endtrans %}</h4>
<a
href="{{ url("reservation:room_create") }}?club={{ object.id }}"
class="btn btn-blue"
>
{% trans %}Add a room{% endtrans %}
</a>
{%- if reservable_rooms|length > 0 -%}
<ul class="card-group">
{%- for room in reservable_rooms -%}
{{ room_detail(
room,
can_edit=user.can_edit(room),
can_delete=request.user.has_perm("reservation.delete_room")
) }}
{%- endfor -%}
</ul>
{%- else -%}
<p>
{% trans %}This club manages no reservable room{% endtrans %}
</p>
{%- endif -%}
<h4>{% trans %}Counters:{% endtrans %}</h4>
<ul>
{% for c in object.counters.filter(type="OFFICE") %}
<li>{{ c }}:
<a href="{{ url('counter:details', counter_id=c.id) }}">View</a>
<a href="{{ url('counter:admin', counter_id=c.id) }}">Edit</a>
{% for counter in counters %}
<li>{{ counter }}:
<a href="{{ url('counter:details', counter_id=counter.id) }}">View</a>
<a href="{{ url('counter:admin', counter_id=counter.id) }}">Edit</a>
</li>
{% endfor %}
</ul>

View File

@@ -8,7 +8,7 @@ from django.utils.timezone import now
from model_bakery import baker
from model_bakery.recipe import Recipe
from club.models import Club, ClubRole, Membership
from club.models import Club, Membership
from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User
@@ -43,11 +43,6 @@ class TestClub(TestCase):
cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID)
cls.club = baker.make(Club)
cls.president_role = baker.make(
ClubRole, club=cls.club, is_board=True, is_presidency=True, order=0
)
cls.board_role = baker.make(ClubRole, club=cls.club, is_board=True, order=1)
cls.member_role = baker.make(ClubRole, club=cls.club, order=2)
cls.new_members_url = reverse(
"club:club_new_members", kwargs={"club_id": cls.club.id}
)
@@ -56,17 +51,12 @@ class TestClub(TestCase):
yesterday = now() - timedelta(days=1)
membership_recipe = Recipe(Membership, club=cls.club)
membership_recipe.make(
user=cls.simple_board_member, start_date=a_month_ago, role=cls.board_role
)
membership_recipe.make(user=cls.richard, role=cls.member_role)
membership_recipe.make(
user=cls.president, start_date=a_month_ago, role=cls.president_role
user=cls.simple_board_member, start_date=a_month_ago, role=3
)
membership_recipe.make(user=cls.richard, role=1)
membership_recipe.make(user=cls.president, start_date=a_month_ago, role=10)
membership_recipe.make( # sli was a member but isn't anymore
user=cls.sli,
start_date=a_month_ago,
end_date=yesterday,
role=cls.board_role,
user=cls.sli, start_date=a_month_ago, end_date=yesterday, role=2
)
def setUp(self):

View File

@@ -5,7 +5,7 @@ from django.utils.timezone import localdate
from model_bakery import baker
from model_bakery.recipe import Recipe
from club.models import Club, ClubRole, Membership
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
@@ -16,19 +16,11 @@ def test_club_queryset_having_board_member():
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[0], role=baker.make(ClubRole, club=clubs[0], is_board=False)
)
membership_recipe.make(
club=clubs[1], role=baker.make(ClubRole, club=clubs[1], is_board=True)
)
membership_recipe.make(
club=clubs[2], role=baker.make(ClubRole, club=clubs[2], is_board=True)
)
membership_recipe.make(
club=clubs[3],
role=baker.make(ClubRole, club=clubs[3], is_board=True),
end_date=localdate() - timedelta(days=1),
club=clubs[3], role=3, end_date=localdate() - timedelta(days=1)
)
club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)

View File

@@ -1,7 +1,6 @@
from datetime import date, timedelta
import pytest
from django.conf import settings
from django.contrib.auth.models import Permission
from django.test import Client, TestCase
from django.urls import reverse
@@ -9,7 +8,7 @@ from model_bakery import baker
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertNumQueries
from club.models import Club, ClubRole, Membership
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
from core.models import Group, Page, User
@@ -27,10 +26,8 @@ class TestClubSearch(TestCase):
"id", flat=True
)
)
Membership.objects.all().delete()
ClubRole.objects.all().delete()
Page.objects.exclude(club=None).delete()
Club.objects.all().delete()
Page.objects.exclude(name=settings.SITH_CLUB_ROOT_PAGE).delete()
Group.objects.filter(id__in=groups).delete()
cls.clubs = baker.make(

View File

@@ -4,7 +4,7 @@ from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from club.models import Club, ClubRole, Membership
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
@@ -12,12 +12,7 @@ from core.baker_recipes import subscriber_user
def test_club_board_member_cannot_edit_club_properties(client: Client):
user = subscriber_user.make()
club = baker.make(Club, name="old name", is_active=True, address="old address")
baker.make(
Membership,
club=club,
user=user,
role=baker.make(ClubRole, club=club, is_board=True),
)
baker.make(Membership, club=club, user=user, role=7)
client.force_login(user)
res = client.post(
reverse("club:club_edit", kwargs={"club_id": club.id}),
@@ -37,12 +32,7 @@ def test_edit_club_page_doesnt_crash(client: Client):
"""crash test for club:club_edit"""
club = baker.make(Club)
user = subscriber_user.make()
baker.make(
Membership,
club=club,
user=user,
role=baker.make(ClubRole, club=club, is_board=True),
)
baker.make(Membership, club=club, user=user, role=3)
client.force_login(user)
res = client.get(reverse("club:club_edit", kwargs={"club_id": club.id}))
assert res.status_code == 200

View File

@@ -3,10 +3,9 @@ from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
from model_bakery import baker
from club.forms import MailingForm
from club.models import Club, ClubRole, Mailing, Membership
from club.models import Club, Mailing, Membership
from core.models import User
@@ -26,7 +25,7 @@ class TestMailingForm(TestCase):
user=cls.rbatsbak,
club=cls.club,
start_date=timezone.now(),
role=baker.make(ClubRole, club=cls.club, is_board=True),
role=settings.SITH_CLUB_ROLES_ID["Board member"],
).save()
def test_mailing_list_add_no_moderation(self):

View File

@@ -1,9 +1,9 @@
import itertools
from collections.abc import Callable
from datetime import timedelta
import pytest
from bs4 import BeautifulSoup
from django.conf import settings
from django.contrib.auth.models import Permission
from django.core.cache import cache
from django.db.models import Max
@@ -14,7 +14,7 @@ from model_bakery import baker
from pytest_django.asserts import assertRedirects
from club.forms import ClubAddMemberForm, JoinClubForm
from club.models import Club, ClubRole, Membership
from club.models import Club, Membership
from club.tests.base import TestClub
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User
@@ -75,22 +75,17 @@ class TestMembershipQuerySet(TestClub):
def test_update_change_club_groups(self):
"""Test that `update` set the user groups accordingly."""
user = baker.make(User)
board_role, member_role = baker.make(
ClubRole, is_board=iter([True, False]), _quantity=2, _bulk_create=True
)
membership = baker.make(
Membership, end_date=None, user=user, role=board_role, club=board_role.club
)
membership = baker.make(Membership, end_date=None, user=user, role=5)
members_group = membership.club.members_group
board_group = membership.club.board_group
assert user.groups.contains(members_group)
assert user.groups.contains(board_group)
user.memberships.update(role=member_role) # from board to simple member
user.memberships.update(role=1) # from board to simple member
assert user.groups.contains(members_group)
assert not user.groups.contains(board_group)
user.memberships.update(role=board_role) # from member to board
user.memberships.update(role=5) # from member to board
assert user.groups.contains(members_group)
assert user.groups.contains(board_group)
@@ -101,17 +96,7 @@ class TestMembershipQuerySet(TestClub):
def test_delete_remove_from_groups(self):
"""Test that `delete` removes from club groups"""
user = baker.make(User)
club = baker.make(Club)
roles = baker.make(
ClubRole,
is_board=iter([False, True]),
club=club,
_quantity=2,
_bulk_create=True,
)
memberships = baker.make(
Membership, club=club, role=iter(roles), user=user, _quantity=2
)
memberships = baker.make(Membership, role=iter([1, 5]), user=user, _quantity=2)
club_groups = {
memberships[0].club.members_group,
memberships[1].club.members_group,
@@ -127,20 +112,13 @@ class TestMembershipEditableBy(TestCase):
def setUpTestData(cls):
Membership.objects.all().delete()
cls.club_a, cls.club_b = baker.make(Club, _quantity=2)
roles = baker.make(
ClubRole,
is_presidency=itertools.cycle([True, False, False, False]),
is_board=itertools.cycle([True, True, True, False]),
order=itertools.cycle(range(4)),
club=iter(
[*itertools.repeat(cls.club_a, 4), *itertools.repeat(cls.club_b, 4)]
),
_quantity=8,
_bulk_create=True,
)
cls.memberships = [
*baker.make(Membership, role=iter(roles[:4]), club=cls.club_a, _quantity=4),
*baker.make(Membership, role=iter(roles[4:]), club=cls.club_b, _quantity=4),
*baker.make(
Membership, role=iter([7, 3, 3, 1]), club=cls.club_a, _quantity=4
),
*baker.make(
Membership, role=iter([7, 3, 3, 1]), club=cls.club_b, _quantity=4
),
]
def test_admin_user(self):
@@ -162,7 +140,7 @@ class TestMembershipEditableBy(TestCase):
class TestMembership(TestClub):
def assert_membership_started_today(self, user: User, role: ClubRole):
def assert_membership_started_today(self, user: User, role: int):
"""Assert that the given membership is active and started today."""
membership = user.memberships.ongoing().filter(club=self.club).first()
assert membership is not None
@@ -211,27 +189,21 @@ class TestMembership(TestClub):
"Marquer comme ancien",
]
rows = table.find("tbody").find_all("tr")
memberships = (
self.club.members.ongoing()
.order_by("role__order")
.select_related("user", "role")
)
user_role = ClubRole.objects.get(members__user=self.simple_board_member)
for row, membership in zip(rows, memberships, strict=False):
memberships = self.club.members.ongoing().order_by("-role")
for row, membership in zip(
rows, memberships.select_related("user"), strict=False
):
user = membership.user
user_url = reverse("core:user_profile", args=[user.id])
cols = row.find_all("td")
user_link = cols[0].find("a")
assert user_link.attrs["href"] == user_url
assert user_link.text == user.get_display_name()
assert cols[1].text == membership.role.name
assert cols[1].text == settings.SITH_CLUB_ROLES[membership.role]
assert cols[2].text == membership.description
assert cols[3].text == str(membership.start_date)
if (
membership.role.order > user_role.order
or membership.user_id == self.simple_board_member.id
):
if membership.role < 3 or membership.user_id == self.simple_board_member.id:
# 3 is the role of simple_board_member
form_input = cols[4].find("input")
expected_attrs = {
@@ -247,15 +219,14 @@ class TestMembership(TestClub):
"""Test that root users can add members to clubs"""
self.client.force_login(self.root)
response = self.client.post(
self.new_members_url,
{"user": self.subscriber.id, "role": self.board_role.id},
self.new_members_url, {"user": self.subscriber.id, "role": 3}
)
assert response.status_code == 200
assert response.headers.get("HX-Redirect", "") == reverse(
"club:club_members", kwargs={"club_id": self.club.id}
)
self.subscriber.refresh_from_db()
self.assert_membership_started_today(self.subscriber, role=self.board_role)
self.assert_membership_started_today(self.subscriber, role=3)
def test_add_unauthorized_members(self):
"""Test that users who are not currently subscribed
@@ -263,7 +234,7 @@ class TestMembership(TestClub):
"""
for user in self.public, self.old_subscriber:
form = ClubAddMemberForm(
data={"user": user.id, "role": self.member_role},
data={"user": user.id, "role": 1},
request_user=self.root,
club=self.club,
)
@@ -284,7 +255,7 @@ class TestMembership(TestClub):
nb_memberships = self.simple_board_member.memberships.count()
self.client.post(
self.members_url,
{"users": self.simple_board_member.id, "role": self.member_role},
{"users": self.simple_board_member.id, "role": current_membership.role + 1},
)
self.simple_board_member.refresh_from_db()
assert nb_memberships == self.simple_board_member.memberships.count()
@@ -303,7 +274,7 @@ class TestMembership(TestClub):
max_id = User.objects.aggregate(id=Max("id"))["id"]
for members in [max_id + 1], [max_id + 1, self.subscriber.id]:
form = ClubAddMemberForm(
data={"user": members, "role": self.member_role},
data={"user": members, "role": 1},
request_user=self.root,
club=self.club,
)
@@ -319,13 +290,12 @@ class TestMembership(TestClub):
def test_president_add_members(self):
"""Test that the president of the club can add members."""
president = self.club.members.get(role=self.president_role).user
president = self.club.members.get(role=10).user
nb_club_membership = self.club.members.count()
nb_subscriber_memberships = self.subscriber.memberships.count()
self.client.force_login(president)
response = self.client.post(
self.new_members_url,
{"user": self.subscriber.id, "role": self.president_role.id},
self.new_members_url, {"user": self.subscriber.id, "role": 9}
)
assert response.status_code == 200
assert response.headers.get("HX-Redirect", "") == reverse(
@@ -335,17 +305,14 @@ class TestMembership(TestClub):
self.subscriber.refresh_from_db()
assert self.club.members.count() == nb_club_membership + 1
assert self.subscriber.memberships.count() == nb_subscriber_memberships + 1
self.assert_membership_started_today(self.subscriber, role=self.president_role)
self.assert_membership_started_today(self.subscriber, role=9)
def test_add_member_greater_role(self):
"""Test that a member of the club member cannot create
a membership with a greater role than its own.
"""
user_role = self.simple_board_member.memberships.first().role
other_role = baker.make(ClubRole, club=user_role.club, is_board=True)
other_role.above(user_role)
form = ClubAddMemberForm(
data={"user": self.subscriber.id, "role": other_role.id},
data={"user": self.subscriber.id, "role": 10},
request_user=self.simple_board_member,
club=self.club,
)
@@ -353,10 +320,7 @@ class TestMembership(TestClub):
assert not form.is_valid()
assert form.errors == {
"role": [
"Sélectionnez un choix valide. "
"Ce choix ne fait pas partie de ceux disponibles."
]
"role": ["Sélectionnez un choix valide. 10 n\u2019en fait pas partie."]
}
self.club.refresh_from_db()
assert nb_memberships == self.club.members.count()
@@ -372,9 +336,8 @@ class TestMembership(TestClub):
assert form.errors == {"role": ["Ce champ est obligatoire."]}
def test_add_member_already_there(self):
role = ClubRole.objects.get(members__user=self.simple_board_member)
form = ClubAddMemberForm(
data={"user": self.simple_board_member, "role": role.id},
data={"user": self.simple_board_member, "role": 3},
request_user=self.root,
club=self.club,
)
@@ -385,27 +348,22 @@ class TestMembership(TestClub):
def test_add_other_member_forbidden(self):
non_member = subscriber_user.make()
simple_member = baker.make(
Membership, club=self.club, role=self.member_role
).user
simple_member = baker.make(Membership, club=self.club, role=1).user
for user in non_member, simple_member:
form = ClubAddMemberForm(
data={"user": subscriber_user.make(), "role": self.member_role.id},
data={"user": subscriber_user.make(), "role": 1},
request_user=user,
club=self.club,
)
assert not form.is_valid()
assert form.errors == {
"role": [
"Sélectionnez un choix valide. "
"Ce choix ne fait pas partie de ceux disponibles."
]
"role": ["Sélectionnez un choix valide. 1 n\u2019en fait pas partie."]
}
def test_simple_members_dont_see_form_anymore(self):
"""Test that simple club members don't see the form to add members"""
user = subscriber_user.make()
baker.make(Membership, club=self.club, user=user, role=self.member_role)
baker.make(Membership, club=self.club, user=user, role=1)
self.client.force_login(user)
res = self.client.get(self.members_url)
assert res.status_code == 200
@@ -424,10 +382,9 @@ class TestMembership(TestClub):
"""Test that board members of the club can end memberships
of users with lower roles.
"""
# reminder : simple_board_member has role 3
self.client.force_login(self.simple_board_member)
role = baker.make(ClubRole, club=self.club, is_board=True)
role.below(self.board_role)
membership = baker.make(Membership, club=self.club, role=role)
membership = baker.make(Membership, club=self.club, role=2, end_date=None)
response = self.client.post(self.members_url, {"members_old": [membership.id]})
self.assertRedirects(response, self.members_url)
self.club.refresh_from_db()
@@ -437,9 +394,7 @@ class TestMembership(TestClub):
"""Test that board members of the club cannot end memberships
of users with higher roles.
"""
membership = self.president.memberships.filter(
club=self.club, end_date=None
).first()
membership = self.president.memberships.filter(club=self.club).first()
self.client.force_login(self.simple_board_member)
self.client.post(self.members_url, {"members_old": [membership.id]})
self.club.refresh_from_db()
@@ -481,9 +436,7 @@ class TestMembership(TestClub):
def test_remove_from_club_group(self):
"""Test that when a membership ends, the user is removed from club groups."""
user = baker.make(User)
baker.make(
Membership, user=user, club=self.club, end_date=None, role=self.board_role
)
baker.make(Membership, user=user, club=self.club, end_date=None, role=3)
assert user.groups.contains(self.club.members_group)
assert user.groups.contains(self.club.board_group)
user.memberships.update(end_date=localdate())
@@ -494,20 +447,18 @@ class TestMembership(TestClub):
"""Test that when a membership begins, the user is added to the club group."""
assert not self.subscriber.groups.contains(self.club.members_group)
assert not self.subscriber.groups.contains(self.club.board_group)
baker.make(
Membership, club=self.club, user=self.subscriber, role=self.board_role
)
baker.make(Membership, club=self.club, user=self.subscriber, role=3)
assert self.subscriber.groups.contains(self.club.members_group)
assert self.subscriber.groups.contains(self.club.board_group)
def test_change_position_in_club(self):
"""Test that when moving from board to members, club group change"""
membership = baker.make(
Membership, club=self.club, user=self.subscriber, role=self.board_role
Membership, club=self.club, user=self.subscriber, role=3
)
assert self.subscriber.groups.contains(self.club.members_group)
assert self.subscriber.groups.contains(self.club.board_group)
membership.role = self.member_role
membership.role = 1
membership.save()
assert self.subscriber.groups.contains(self.club.members_group)
assert not self.subscriber.groups.contains(self.club.board_group)
@@ -520,11 +471,7 @@ class TestMembership(TestClub):
# make sli a board member
self.sli.memberships.all().delete()
Membership(
club=self.ae,
user=self.sli,
role=baker.make(ClubRole, club=self.ae, is_board=True),
).save()
Membership(club=self.ae, user=self.sli, role=3).save()
assert self.club.is_owned_by(self.sli)
def test_change_club_name(self):
@@ -550,7 +497,7 @@ class TestMembership(TestClub):
@pytest.mark.django_db
def test_membership_set_old(client: Client):
membership = baker.make(Membership, end_date=None, user=subscriber_user.make())
membership = baker.make(Membership, end_date=None, user=(subscriber_user.make()))
client.force_login(membership.user)
response = client.post(
reverse("club:membership_set_old", kwargs={"membership_id": membership.id})
@@ -584,63 +531,55 @@ class TestJoinClub:
cache.clear()
@pytest.mark.parametrize(
("user_factory", "board_role", "errors"),
("user_factory", "role", "errors"),
[
(
subscriber_user.make,
True,
2,
{
"role": [
"Sélectionnez un choix valide. "
"Ce choix ne fait pas partie de ceux disponibles."
"Sélectionnez un choix valide. 2 n\u2019en fait pas partie."
]
},
),
(
lambda: baker.make(User),
False,
1,
{"__all__": ["Vous devez être cotisant pour faire partie d'un club"]},
),
],
)
def test_join_club_errors(
self, user_factory: Callable[[], User], board_role, errors: dict
self, user_factory: Callable[[], User], role: int, errors: dict
):
club = baker.make(Club)
user = user_factory()
role = baker.make(ClubRole, club=club, is_board=board_role)
form = JoinClubForm(club=club, request_user=user, data={"role": role.id})
form = JoinClubForm(club=club, request_user=user, data={"role": role})
assert not form.is_valid()
assert form.errors == errors
def test_user_already_in_club(self):
club = baker.make(Club)
user = subscriber_user.make()
role = baker.make(ClubRole, is_board=False)
baker.make(Membership, user=user, club=role.club)
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
baker.make(Membership, user=user, club=club)
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
assert not form.is_valid()
assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]}
def test_ok(self):
club = baker.make(Club)
user = subscriber_user.make()
role = baker.make(ClubRole, is_board=False)
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
assert form.is_valid()
form.save()
assert Membership.objects.ongoing().filter(user=user, club=role.club).exists()
assert Membership.objects.ongoing().filter(user=user, club=club).exists()
class TestOldMembersView(TestCase):
@classmethod
def setUpTestData(cls):
club = baker.make(Club)
roles = baker.make(
ClubRole,
club=club,
is_board=itertools.cycle([True, True, False]),
_quantity=10,
_bulk_create=True,
)
roles = [1, 1, 1, 2, 2, 4, 4, 5, 7, 9, 10]
cls.memberships = baker.make(
Membership,
role=iter(roles),

View File

@@ -5,7 +5,7 @@ from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects
from club.models import Club, ClubRole, Membership
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
from core.markdown import markdown
from core.models import PageRev, User
@@ -59,12 +59,7 @@ def test_page_revision(client: Client):
def test_edit_page(client: Client):
club = baker.make(Club)
user = subscriber_user.make()
baker.make(
Membership,
user=user,
club=club,
role=baker.make(ClubRole, club=club, is_board=True),
)
baker.make(Membership, user=user, club=club, role=3)
client.force_login(user)
url = reverse("club:club_edit_page", kwargs={"club_id": club.id})
content = "# foo\nLorem ipsum dolor sit amet"

View File

@@ -1,53 +0,0 @@
from datetime import timedelta
from django.test import TestCase
from django.urls import reverse
from django.utils.timezone import localdate
from model_bakery import baker
from model_bakery.recipe import Recipe
from club.models import Club, ClubRole, Membership
from club.schemas import UserMembershipSchema
from core.baker_recipes import subscriber_user
from core.models import Page
class TestFetchClub(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = subscriber_user.make()
pages = baker.make(Page, _quantity=3, _bulk_create=True)
clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True)
recipe = Recipe(
Membership,
user=cls.user,
start_date=localdate() - timedelta(days=2),
role=baker.make(ClubRole),
)
cls.members = Membership.objects.bulk_create(
[
recipe.prepare(club=clubs[0]),
recipe.prepare(club=clubs[1], end_date=localdate() - timedelta(days=1)),
recipe.prepare(club=clubs[1]),
]
)
def test_fetch_memberships(self):
self.client.force_login(subscriber_user.make())
res = self.client.get(
reverse("api:fetch_user_clubs", kwargs={"user_id": self.user.id})
)
assert res.status_code == 200
assert [UserMembershipSchema.model_validate(m) for m in res.json()] == [
UserMembershipSchema.from_orm(m) for m in (self.members[0], self.members[2])
]
def test_fetch_club_nb_queries(self):
self.client.force_login(subscriber_user.make())
with self.assertNumQueries(6):
# - 5 queries for authentication
# - 1 query for the actual data
res = self.client.get(
reverse("api:fetch_user_clubs", kwargs={"user_id": self.user.id})
)
assert res.status_code == 200

View File

@@ -28,6 +28,7 @@ import csv
import itertools
from typing import TYPE_CHECKING, Any
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
@@ -259,6 +260,12 @@ class ClubToolsView(ClubTabsMixin, CanEditMixin, DetailView):
template_name = "club/club_tools.jinja"
current_tab = "tools"
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"reservable_rooms": list(self.object.reservable_rooms.all()),
"counters": list(self.object.counters.filter(type="OFFICE")),
}
class ClubAddMembersFragment(
FragmentMixin, PermissionRequiredMixin, SuccessMessageMixin, CreateView
@@ -317,7 +324,7 @@ class ClubMembersView(
membership = self.object.get_membership_for(self.request.user)
if (
membership
and not membership.role.is_board
and membership.role <= settings.SITH_MAXIMUM_FREE_ROLE
and not self.request.user.has_perm("club.add_membership")
):
# Simple club members won't see the form anymore.
@@ -342,8 +349,8 @@ class ClubMembersView(
kwargs["members"] = list(
self.object.members.ongoing()
.annotate(is_editable=Q(id__in=editable))
.order_by("role__order")
.select_related("user", "role")
.order_by("-role")
.select_related("user")
)
kwargs["can_end_membership"] = len(editable) > 0
return kwargs
@@ -371,8 +378,8 @@ class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
return super().get_context_data(**kwargs) | {
"old_members": (
self.object.members.exclude(end_date=None)
.order_by("role__order", "description", "-end_date")
.select_related("user", "role")
.order_by("-role", "description", "-end_date")
.select_related("user")
)
}
@@ -723,7 +730,9 @@ class MailingAutoGenerationView(View):
def get(self, request, *args, **kwargs):
club = self.mailing.club
self.mailing.subscriptions.all().delete()
members = club.members.ongoing().filter(role__is_board=True)
members = club.members.filter(
role__gte=settings.SITH_CLUB_ROLES_ID["Board member"]
).exclude(end_date__lte=timezone.now())
for member in members.all():
MailingSubscription(user=member.user, mailing=self.mailing).save()
return redirect("club:mailing", club_id=club.id)

View File

@@ -81,7 +81,6 @@
}
#links_content {
overflow: auto;
box-shadow: $shadow-color 1px 1px 1px;
min-height: 20em;
padding-bottom: 1em;

View File

@@ -1,9 +1,11 @@
{% extends "core/base.jinja" %}
{% from "com/macros.jinja" import news_moderation_alert %}
{% block title %}AE UTBM{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}">
<link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}">
<link rel="stylesheet" href="{{ static('core/components/calendar.scss') }}">
{# Atom feed discovery, not really css but also goes there #}
<link rel="alternate" type="application/rss+xml" title="{% trans %}News feed{% endtrans %}" href="{{ url("com:news_feed") }}">
@@ -213,6 +215,12 @@
<i class="fa-solid fa-magnifying-glass fa-xl"></i>
<a href="{{ url("matmat:search") }}">{% trans %}Matmatronch{% endtrans %}</a>
</li>
{% if user.has_perm("reservation.view_reservationslot") %}
<li>
<i class="fa-solid fa-thumbtack fa-xl"></i>
<a href="{{ url("reservation:main") }}">{% trans %}Room reservation{% endtrans %}</a>
</li>
{% endif %}
<li>
<i class="fa-solid fa-check-to-slot fa-xl"></i>
<a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a>

View File

@@ -28,7 +28,7 @@ from django.utils.translation import gettext as _
from model_bakery import baker
from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club, ClubRole, Membership
from club.models import Club, Membership
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, Group, User
@@ -214,8 +214,7 @@ class TestNewsCreation(TestCase):
def setUpTestData(cls):
cls.club = baker.make(Club)
cls.user = subscriber_user.make()
role = baker.make(ClubRole, club=cls.club, is_board=True)
baker.make(Membership, user=cls.user, club=cls.club, role=role)
baker.make(Membership, user=cls.user, club=cls.club, role=5)
def setUp(self):
self.client.force_login(self.user)

View File

@@ -504,7 +504,7 @@ class WeekmailArticleCreateView(CreateView):
self.object = form.instance
form.is_valid() # Valid a first time to populate club field
m = form.instance.club.get_membership_for(request.user)
if m is None or not m.role.is_board:
if m is None or m.role <= settings.SITH_MAXIMUM_FREE_ROLE:
form.add_error(
"club",
ValidationError(

View File

@@ -307,7 +307,6 @@ class PermissionOrClubBoardRequiredMixin(PermissionRequiredMixin):
return False
if super().has_permission():
return True
return (
self.club is not None
and self.club.board_group_id in self.request.user.all_groups
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

@@ -4,9 +4,9 @@ from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.utils.timezone import localdate, now
from model_bakery import seq
from model_bakery.recipe import Recipe, foreign_key, related
from model_bakery.recipe import Recipe, related
from club.models import ClubRole, Membership
from club.models import Membership
from core.models import Group, User
from subscription.models import Subscription
@@ -52,9 +52,7 @@ ae_board_membership = Recipe(
Membership,
start_date=now() - timedelta(days=30),
club_id=settings.SITH_MAIN_CLUB_ID,
role=foreign_key(
Recipe(ClubRole, club_id=settings.SITH_MAIN_CLUB_ID, is_board=True)
),
role=settings.SITH_CLUB_ROLES_ID["Board member"],
)
board_user = Recipe(

View File

@@ -36,7 +36,7 @@ from django.utils import timezone
from django.utils.timezone import localdate
from PIL import Image
from club.models import Club, ClubRole, Membership
from club.models import Club, Membership
from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate, Sith, Weekmail
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
@@ -62,13 +62,6 @@ class PopulatedGroups(NamedTuple):
campus_admin: Group
class PopulatedClubs(NamedTuple):
ae: Club
troll: Club
pdf: Club
refound: Club
class Command(BaseCommand):
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
SAS_FIXTURE_PATH: ClassVar[Path] = (
@@ -118,16 +111,28 @@ class Command(BaseCommand):
club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create(name="SAS", owner=root)
clubs = self._create_clubs()
main_club = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
)
main_club.board_group.permissions.add(
*Permission.objects.filter(
codename__in=["view_subscription", "add_subscription"]
)
)
bar_club = Club.objects.create(
id=settings.SITH_PDF_CLUB_ID,
name="PdF",
address="6 Boulevard Anatole France, 90000 Belfort",
)
self.reset_index("club")
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
Counter(id=bar_id, name=bar_name, club=clubs.pdf, type="BAR").save()
Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR").save()
self.reset_index("counter")
counters = [
Counter(name="Eboutic", club=clubs.ae, type="EBOUTIC"),
Counter(name="AE", club=clubs.ae, type="OFFICE"),
Counter(name="Vidage comptes AE", club=clubs.ae, type="OFFICE"),
Counter(name="Eboutic", club=main_club, type="EBOUTIC"),
Counter(name="AE", club=main_club, type="OFFICE"),
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
]
Counter.objects.bulk_create(counters)
bar_groups = []
@@ -310,41 +315,54 @@ class Command(BaseCommand):
self._create_subscription(tutu)
StudentCard(uid="9A89B82018B0A0", customer=sli.customer).save()
Membership.objects.create(
user=skia, club=clubs.ae, role=clubs.ae.roles.get(name="Respo Info")
# Clubs
Club.objects.create(
name="Bibo'UT", address="46 de la Boustifaille", parent=main_club
)
guyut = Club.objects.create(
name="Guy'UT", address="42 de la Boustifaille", parent=main_club
)
Club.objects.create(name="Woenzel'UT", address="Woenzel", parent=guyut)
troll = Club.objects.create(
name="Troll Penché", address="Terre Du Milieu", parent=main_club
)
refound = Club.objects.create(
name="Carte AE", address="Jamais imprimée", parent=main_club
)
Membership.objects.create(user=skia, club=main_club, role=3)
Membership.objects.create(
user=comunity,
club=clubs.pdf,
club=bar_club,
start_date=localdate(),
role=clubs.pdf.roles.get(name="Membre du bureau"),
role=settings.SITH_CLUB_ROLES_ID["Board member"],
)
Membership.objects.create(
user=sli,
club=clubs.troll,
role=clubs.troll.roles.get(name="Vice-Président⸱e"),
club=troll,
role=9,
description="Padawan Troll",
start_date=localdate() - timedelta(days=17),
)
Membership.objects.create(
user=krophil,
club=clubs.troll,
role=clubs.troll.roles.get(name="Président⸱e"),
club=troll,
role=10,
description="Maitre Troll",
start_date=localdate() - timedelta(days=200),
)
Membership.objects.create(
user=skia,
club=clubs.troll,
role=clubs.troll.roles.get(name="Membre du bureau"),
club=troll,
role=2,
description="Grand Ancien Troll",
start_date=localdate() - timedelta(days=400),
end_date=localdate() - timedelta(days=86),
)
Membership.objects.create(
user=richard,
club=clubs.troll,
role=clubs.troll.roles.get(name="Membre du bureau"),
club=troll,
role=2,
description="",
start_date=localdate() - timedelta(days=200),
end_date=localdate() - timedelta(days=100),
@@ -361,7 +379,7 @@ class Command(BaseCommand):
purchase_price="15",
selling_price="15",
special_selling_price="15",
club=clubs.ae,
club=main_club,
)
cotis2 = Product.objects.create(
name="Cotis 2 semestres",
@@ -370,7 +388,7 @@ class Command(BaseCommand):
purchase_price="28",
selling_price="28",
special_selling_price="28",
club=clubs.ae,
club=main_club,
)
refill = Product.objects.create(
name="Rechargement 15 €",
@@ -379,7 +397,7 @@ class Command(BaseCommand):
purchase_price="15",
selling_price="15",
special_selling_price="15",
club=clubs.ae,
club=main_club,
)
barb = Product.objects.create(
name="Barbar",
@@ -388,7 +406,7 @@ class Command(BaseCommand):
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=clubs.ae,
club=main_club,
limit_age=18,
)
cble = Product.objects.create(
@@ -398,7 +416,7 @@ class Command(BaseCommand):
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=clubs.ae,
club=main_club,
limit_age=18,
)
cons = Product.objects.create(
@@ -408,7 +426,7 @@ class Command(BaseCommand):
purchase_price="1",
selling_price="1",
special_selling_price="1",
club=clubs.ae,
club=main_club,
)
dcons = Product.objects.create(
name="Déconsigne Eco-cup",
@@ -417,7 +435,7 @@ class Command(BaseCommand):
purchase_price="-1",
selling_price="-1",
special_selling_price="-1",
club=clubs.ae,
club=main_club,
)
cors = Product.objects.create(
name="Corsendonk",
@@ -426,7 +444,7 @@ class Command(BaseCommand):
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=clubs.ae,
club=main_club,
limit_age=18,
)
carolus = Product.objects.create(
@@ -436,7 +454,7 @@ class Command(BaseCommand):
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=clubs.ae,
club=main_club,
limit_age=18,
)
Product.objects.create(
@@ -445,7 +463,7 @@ class Command(BaseCommand):
purchase_price="0",
selling_price="0",
special_selling_price="0",
club=clubs.refound,
club=refound,
)
groups.subscribers.products.add(
cotis, cotis2, refill, barb, cble, cors, carolus
@@ -458,7 +476,7 @@ class Command(BaseCommand):
eboutic = Counter.objects.get(name="Eboutic")
eboutic.products.add(barb, cotis, cotis2, refill)
Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE")
Counter.objects.create(name="Carte AE", club=refound, type="OFFICE")
ReturnableProduct.objects.create(
product=cons, returned_product=dcons, max_return=3
@@ -482,7 +500,7 @@ class Command(BaseCommand):
end_date="7942-06-12 10:28:45+01",
)
el.view_groups.add(groups.public)
el.edit_groups.add(clubs.ae.board_group)
el.edit_groups.add(main_club.board_group)
el.candidature_groups.add(groups.subscribers)
el.vote_groups.add(groups.subscribers)
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
@@ -555,7 +573,7 @@ class Command(BaseCommand):
title="Apero barman",
summary="Viens boire un coup avec les barmans",
content="Glou glou glou glou glou glou glou",
club=clubs.pdf,
club=bar_club,
author=subscriber,
is_published=True,
moderator=skia,
@@ -573,7 +591,7 @@ class Command(BaseCommand):
content=(
"Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/"
),
club=clubs.pdf,
club=bar_club,
author=subscriber,
is_published=True,
moderator=skia,
@@ -589,7 +607,7 @@ class Command(BaseCommand):
title="Repas fromager",
summary="Wien manger du l'bon fromeug'",
content="Fô viendre mangey d'la bonne fondue!",
club=clubs.pdf,
club=bar_club,
author=subscriber,
is_published=True,
moderator=skia,
@@ -605,7 +623,7 @@ class Command(BaseCommand):
title="SdF",
summary="Enjoy la fin des finaux!",
content="Viens faire la fête avec tout plein de gens!",
club=clubs.pdf,
club=bar_club,
author=subscriber,
is_published=True,
moderator=skia,
@@ -623,7 +641,7 @@ class Command(BaseCommand):
summary="Viens jouer!",
content="Rejoins la fine équipe du Troll Penché et viens "
"t'amuser le Vendredi soir!",
club=clubs.troll,
club=troll,
author=subscriber,
is_published=True,
moderator=skia,
@@ -760,52 +778,6 @@ class Command(BaseCommand):
)
s.save()
def _create_clubs(self) -> PopulatedClubs:
ae = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
)
ae.board_group.permissions.add(
*Permission.objects.filter(
codename__in=["view_subscription", "add_subscription", "add_membership"]
)
)
pdf = Club.objects.create(
id=settings.SITH_PDF_CLUB_ID,
name="PdF",
address="6 Boulevard Anatole France, 90000 Belfort",
)
troll = Club.objects.create(
name="Troll Penché", address="Terre Du Milieu", parent=ae
)
refound = Club.objects.create(
name="Carte AE", address="Jamais imprimée", parent=ae
)
roles = []
presidency_roles = ["Président⸱e", "Vice-Président⸱e"]
board_roles = [
"Trésorier⸱e",
"Secrétaire",
"Respo Info",
"Respo Com",
"Membre du bureau",
]
simple_roles = ["Membre actif⸱ve", "Curieux⸱euse"]
for club in ae, pdf, troll, refound:
for i, role in enumerate(presidency_roles):
roles.append(
ClubRole(
club=club, order=i, name=role, is_presidency=True, is_board=True
)
)
for i, role in enumerate(board_roles, start=len(presidency_roles)):
roles.append(ClubRole(club=club, order=i, name=role, is_board=True))
for i, role in enumerate(
simple_roles, start=len(presidency_roles) + len(board_roles)
):
roles.append(ClubRole(club=club, order=i, name=role))
ClubRole.objects.bulk_create(roles)
return PopulatedClubs(ae=ae, troll=troll, pdf=pdf, refound=refound)
def _create_groups(self) -> PopulatedGroups:
perms = Permission.objects.all()
@@ -818,7 +790,11 @@ class Command(BaseCommand):
subscribers = Group.objects.create(name="Cotisants")
subscribers.permissions.add(
*list(perms.filter(codename__in=["add_news", "add_uecomment"]))
*list(
perms.filter(
codename__in=["add_news", "add_uecomment", "view_reservationslot"]
)
)
)
old_subscribers = Group.objects.create(name="Anciens cotisants")
old_subscribers.permissions.add(

View File

@@ -1,6 +1,7 @@
import random
from datetime import date, timedelta
from datetime import timezone as tz
from math import ceil
from typing import Iterator
from dateutil.relativedelta import relativedelta
@@ -11,7 +12,7 @@ from django.db.models import Count, Exists, Min, OuterRef, Subquery
from django.utils.timezone import localdate, make_aware, now
from faker import Faker
from club.models import Club, ClubRole, Membership
from club.models import Club, Membership
from core.models import Group, User, UserBan
from counter.models import (
Counter,
@@ -24,6 +25,7 @@ from counter.models import (
)
from forum.models import Forum, ForumMessage, ForumTopic
from pedagogy.models import UE
from reservation.models import ReservationSlot, Room
from subscription.models import Subscription
@@ -41,45 +43,20 @@ class Command(BaseCommand):
self.stdout.write("Creating users...")
users = self.create_users()
self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of users
# len(subscribers) is approximately 480
subscribers = random.sample(users, k=int(0.8 * len(users)))
self.stdout.write("Creating subscriptions...")
self.create_subscriptions(subscribers)
self.stdout.write("Creating club memberships...")
users_qs = User.objects.filter(id__in=[s.id for s in subscribers])
subscribers_now = list(
users_qs.annotate(
filter=Exists(
Subscription.objects.filter(
member_id=OuterRef("pk"), subscription_end__gte=now()
)
)
)
)
old_subscribers = list(
users_qs.annotate(
filter=Exists(
Subscription.objects.filter(
member_id=OuterRef("pk"), subscription_end__lt=now()
)
)
)
)
self.make_club(
Club.objects.get(id=settings.SITH_MAIN_CLUB_ID),
random.sample(subscribers_now, k=min(30, len(subscribers_now))),
random.sample(old_subscribers, k=min(60, len(old_subscribers))),
)
self.make_club(
Club.objects.get(name="Troll Penché"),
random.sample(subscribers_now, k=min(20, len(subscribers_now))),
random.sample(old_subscribers, k=min(80, len(old_subscribers))),
)
self.create_club_memberships(subscribers)
self.stdout.write("Creating rooms and reservation...")
self.create_resources_and_reservations(random.sample(subscribers, k=40))
self.stdout.write("Creating uvs...")
self.create_ues()
self.stdout.write("Creating products...")
self.create_products()
self.stdout.write("Creating sales and refills...")
sellers = random.sample(list(User.objects.all()), 100)
sellers = list(User.objects.order_by("?")[:100])
self.create_sales(sellers)
self.stdout.write("Creating permanences...")
self.create_permanences(sellers)
@@ -172,25 +149,20 @@ class Command(BaseCommand):
Customer.objects.bulk_create(customers, ignore_conflicts=True)
def make_club(self, club: Club, members: list[User], old_members: list[User]):
roles: list[ClubRole] = list(club.roles.all())
def zip_roles(users: list[User]) -> Iterator[tuple[User, ClubRole]]:
important_roles = [r for r in roles if r.is_board]
important_roles.sort(key=lambda r: r.order)
simple_board_role = important_roles.pop()
member_roles = [r for r in roles if not r.is_board]
def zip_roles(users: list[User]) -> Iterator[tuple[User, int]]:
roles = iter(sorted(settings.SITH_CLUB_ROLES.keys(), reverse=True))
user_idx = 0
for _role in important_roles:
while (role := next(roles)) > 2:
# one member for each major role
yield users[user_idx], _role
yield users[user_idx], role
user_idx += 1
for _ in range(int(0.3 * (len(users) - user_idx))):
# 30% of the remaining in the board
yield users[user_idx], simple_board_role
yield users[user_idx], 2
user_idx += 1
for remaining in users[user_idx + 1 :]:
# everything else is a simple member
yield remaining, random.choices(member_roles, weights=(0.8, 0.2))[0]
yield remaining, 1
memberships = []
old_members = old_members.copy()
@@ -202,18 +174,114 @@ class Command(BaseCommand):
start_date=start,
end_date=self.faker.past_date(start),
user=old,
role=random.choice(roles),
role=random.choice(list(settings.SITH_CLUB_ROLES.keys())),
club=club,
)
)
for member, role in zip_roles(members):
start = self.faker.past_date("-1y")
memberships.append(
Membership(start_date=start, user=member, role=role, club=club)
Membership(
start_date=start,
user=member,
role=role,
club=club,
)
)
memberships = Membership.objects.bulk_create(memberships)
Membership._add_club_groups(memberships)
def create_club_memberships(self, users: list[User]):
users_qs = User.objects.filter(id__in=[s.id for s in users])
subscribers_now = list(
users_qs.annotate(
filter=Exists(
Subscription.objects.filter(
member_id=OuterRef("pk"), subscription_end__gte=now()
)
)
)
)
old_subscribers = list(
users_qs.annotate(
filter=Exists(
Subscription.objects.filter(
member_id=OuterRef("pk"), subscription_end__lt=now()
)
)
)
)
self.make_club(
Club.objects.get(id=settings.SITH_MAIN_CLUB_ID),
random.sample(subscribers_now, k=min(30, len(subscribers_now))),
random.sample(old_subscribers, k=min(60, len(old_subscribers))),
)
self.make_club(
Club.objects.get(name="Troll Penché"),
random.sample(subscribers_now, k=min(20, len(subscribers_now))),
random.sample(old_subscribers, k=min(80, len(old_subscribers))),
)
def create_resources_and_reservations(self, users: list[User]):
"""Generate reservable rooms and reservations slots for those rooms.
Contrary to the other data generator,
this one generates more data than what is expected on the real db.
"""
ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID)
pdf = Club.objects.get(id=settings.SITH_PDF_CLUB_ID)
troll = Club.objects.get(name="Troll Penché")
rooms = [
Room(
name=name,
club=club,
location=location,
description=self.faker.text(100),
)
for name, club, location in [
("Champi", ae, "BELFORT"),
("Muzik", ae, "BELFORT"),
("Pôle Tech", ae, "BELFORT"),
("Jolly", troll, "BELFORT"),
("Cookut", pdf, "BELFORT"),
("Lucky", pdf, "BELFORT"),
("Potards", pdf, "SEVENANS"),
("Bureau AE", ae, "SEVENANS"),
]
]
rooms = Room.objects.bulk_create(rooms)
reservations = []
for room in rooms:
# how much people use this room.
# The higher the number, the more reservations exist,
# the smaller the interval between two slot is,
# and the more future reservations have already been made ahead of time
affluence = random.randint(2, 6)
slot_start = make_aware(self.faker.past_datetime("-5y").replace(minute=0))
generate_until = make_aware(
self.faker.future_datetime(timedelta(days=1) * affluence**2)
)
while slot_start < generate_until:
if slot_start.hour < 8:
# if a reservation would start in the middle of the night
# make it start the next morning instead
slot_start += timedelta(hours=10 - slot_start.hour)
duration = timedelta(minutes=15) * (1 + int(random.gammavariate(3, 2)))
reservations.append(
ReservationSlot(
room=room,
author=random.choice(users),
start_at=slot_start,
end_at=slot_start + duration,
created_at=slot_start - self.faker.time_delta("+7d"),
)
)
slot_start += duration + (
timedelta(minutes=15) * ceil(random.expovariate(affluence / 192))
)
reservations.sort(key=lambda slot: slot.created_at)
ReservationSlot.objects.bulk_create(reservations)
def create_ues(self):
root = User.objects.get(username="root")
categories = ["CS", "TM", "OM", "QC", "EC"]
@@ -410,7 +478,7 @@ class Command(BaseCommand):
Permanency.objects.bulk_create(perms)
def create_forums(self):
forumers = random.sample(list(User.objects.all()), 100)
forumers = list(User.objects.order_by("?")[:100])
most_actives = random.sample(forumers, 10)
categories = list(Forum.objects.filter(is_category=True))
new_forums = [
@@ -428,7 +496,7 @@ class Command(BaseCommand):
for _ in range(100)
]
ForumTopic.objects.bulk_create(new_topics)
topics = list(ForumTopic.objects.all())
topics = list(ForumTopic.objects.values_list("id", flat=True))
def get_author():
if random.random() > 0.5:
@@ -436,7 +504,7 @@ class Command(BaseCommand):
return random.choice(forumers)
messages = []
for t in topics:
for topic_id in topics:
nb_messages = max(1, int(random.normalvariate(mu=90, sigma=50)))
dates = sorted(
[
@@ -448,7 +516,7 @@ class Command(BaseCommand):
messages.extend(
[
ForumMessage(
topic=t,
topic_id=topic_id,
author=get_author(),
date=d,
message="\n\n".join(

View File

@@ -356,27 +356,23 @@ class User(AbstractUser):
)
if group_id is None:
return False
return group_id in self.all_groups
if group_id == settings.SITH_GROUP_SUBSCRIBERS_ID:
return self.is_subscribed
if group_id == settings.SITH_GROUP_ROOT_ID:
return self.is_root
return any(g.id == group_id for g in self.cached_groups)
@cached_property
def all_groups(self) -> dict[int, Group]:
def cached_groups(self) -> list[Group]:
"""Get the list of groups this user is in."""
additional_groups = []
if self.is_subscribed:
additional_groups.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
if self.is_superuser:
additional_groups.append(settings.SITH_GROUP_ROOT_ID)
qs = self.groups.all()
if additional_groups:
# This is somewhat counter-intuitive, but this query runs way faster with
# a UNION rather than a OR (in average, 0.25ms vs 14ms).
# For the why, cf. https://dba.stackexchange.com/questions/293836/why-is-an-or-statement-slower-than-union
qs = qs.union(Group.objects.filter(id__in=additional_groups))
return {g.id: g for g in qs}
return list(self.groups.all())
@cached_property
def is_root(self) -> bool:
return self.is_superuser or settings.SITH_GROUP_ROOT_ID in self.all_groups
if self.is_superuser:
return True
root_id = settings.SITH_GROUP_ROOT_ID
return any(g.id == root_id for g in self.cached_groups)
@cached_property
def is_board_member(self) -> bool:
@@ -1103,7 +1099,10 @@ class PageQuerySet(models.QuerySet):
return self.filter(view_groups=settings.SITH_GROUP_PUBLIC_ID)
if user.has_perm("core.view_page"):
return self.all()
return self.filter(view_groups__in=user.all_groups)
groups_ids = [g.id for g in user.cached_groups]
if user.is_subscribed:
groups_ids.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
return self.filter(view_groups__in=groups_ids)
# This function prevents generating migration upon settings change
@@ -1377,7 +1376,7 @@ class PageRev(models.Model):
return self.page.can_be_edited_by(user)
def is_owned_by(self, user: User) -> bool:
return self.page.owner_group_id in user.all_groups
return any(g.id == self.page.owner_group_id for g in user.cached_groups)
def similarity_ratio(self, text: str) -> float:
"""Similarity ratio between this revision's content and the given text.

View File

@@ -1,9 +1,10 @@
import { morph } from "@alpinejs/morph";
import sort from "@alpinejs/sort";
import Alpine from "alpinejs";
import { limitedChoices } from "#core:alpine/limited-choices.ts";
import { alpinePlugin as notificationPlugin } from "#core:utils/notifications.ts";
Alpine.plugin([sort, limitedChoices]);
Alpine.plugin([sort, morph, limitedChoices]);
Alpine.magic("notifications", notificationPlugin);
window.Alpine = Alpine;

View File

@@ -26,6 +26,7 @@ export class NfcInput extends inheritHtmlElement("input") {
window.alert(gettext("Unsupported NFC card"));
});
// biome-ignore lint/correctness/noUndeclaredVariables: browser API
ndef.addEventListener("reading", (event: NDEFReadingEvent) => {
this.removeAttribute("scan");
this.node.value = event.serialNumber.replace(/:/g, "").toUpperCase();

View File

@@ -1,77 +0,0 @@
interface Config {
/**
* The prefix of the formset, in case it has been changed.
* See https://docs.djangoproject.com/fr/stable/topics/forms/formsets/#customizing-a-formset-s-prefix
*/
prefix?: string;
}
// biome-ignore lint/style/useNamingConvention: It's the DOM API naming
type HTMLFormInputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
document.addEventListener("alpine:init", () => {
/**
* Alpine data element to allow the dynamic addition of forms to a formset.
*
* To use this, you need :
* - an HTML element containing the existing forms, noted by `x-ref="formContainer"`
* - a template containing the empty form
* (that you can obtain jinja-side with `{{ formset.empty_form }}`),
* noted by `x-ref="formTemplate"`
* - a button with `@click="addForm"`
* - you may also have one or more buttons with `@click="removeForm(element)"`,
* where `element` is the HTML element containing the form.
*
* For an example of how this is used, you can have a look to
* `counter/templates/counter/product_form.jinja`
*/
Alpine.data("dynamicFormSet", (config?: Config) => ({
init() {
this.formContainer = this.$refs.formContainer as HTMLElement;
this.nbForms = this.formContainer.children.length as number;
this.template = this.$refs.formTemplate as HTMLTemplateElement;
const prefix = config?.prefix ?? "form";
this.$root
.querySelector(`#id_${prefix}-TOTAL_FORMS`)
.setAttribute(":value", "nbForms");
},
addForm() {
this.formContainer.appendChild(document.importNode(this.template.content, true));
const newForm = this.formContainer.lastElementChild;
const inputs: NodeListOf<HTMLFormInputElement> = newForm.querySelectorAll(
"input, select, textarea",
);
for (const el of inputs) {
el.name = el.name.replace("__prefix__", this.nbForms.toString());
el.id = el.id.replace("__prefix__", this.nbForms.toString());
}
const labels: NodeListOf<HTMLLabelElement> = newForm.querySelectorAll("label");
for (const el of labels) {
el.htmlFor = el.htmlFor.replace("__prefix__", this.nbForms.toString());
}
inputs[0].focus();
this.nbForms += 1;
},
removeForm(container: HTMLDivElement) {
container.remove();
this.nbForms -= 1;
// adjust the id of remaining forms
for (let i = 0; i < this.nbForms; i++) {
const form: HTMLDivElement = this.formContainer.children[i];
const inputs: NodeListOf<HTMLFormInputElement> = form.querySelectorAll(
"input, select, textarea",
);
for (const el of inputs) {
el.name = el.name.replace(/\d+/, i.toString());
el.id = el.id.replace(/\d+/, i.toString());
}
const labels: NodeListOf<HTMLLabelElement> = form.querySelectorAll("label");
for (const el of labels) {
el.htmlFor = el.htmlFor.replace(/\d+/, i.toString());
}
}
},
}));
});

View File

@@ -1,4 +1,5 @@
import htmx from "htmx.org";
import "htmx-ext-alpine-morph";
document.body.addEventListener("htmx:beforeRequest", (event) => {
event.detail.target.ariaBusy = true;

View File

@@ -115,6 +115,7 @@ blockquote:before,
blockquote:after,
q:before,
q:after {
content: "";
content: none;
}
table {

View File

@@ -16,16 +16,76 @@
--event-details-padding: 20px;
--event-details-border: 1px solid #EEEEEE;
--event-details-border-radius: 4px;
--event-details-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%);
--event-details-box-shadow: 0 6px 20px 4px rgb(0 0 0 / 16%);
--event-details-max-width: 600px;
--event-recurring-internal-color: #6f69cd;
--event-recurring-unpublished-color: orange;
}
ics-calendar {
ics-calendar,
room-scheduler {
border: none;
box-shadow: none;
a.fc-col-header-cell-cushion,
a.fc-col-header-cell-cushion:hover {
color: black;
}
a.fc-daygrid-day-number,
a.fc-daygrid-day-number:hover {
color: rgb(34, 34, 34);
}
td {
overflow: visible; // Show events on multiple days
}
td, th {
text-align: unset;
}
//Reset from style.scss
table {
box-shadow: none;
border-radius: 0;
-moz-border-radius: 0;
margin: 0;
}
// Reset from style.scss
thead {
background-color: white;
color: black;
}
// Reset from style.scss
tbody > tr {
&:nth-child(even):not(.highlight) {
background: white;
}
}
.fc .fc-toolbar.fc-footer-toolbar {
margin-bottom: 0.5em;
}
button.text-copy,
button.text-copy:focus,
button.text-copy:hover {
background-color: #67AE6E !important;
transition: 500ms ease-in;
}
button.text-copied,
button.text-copied:focus,
button.text-copied:hover {
transition: 500ms ease-out;
}
}
ics-calendar {
#event-details {
z-index: 10;
max-width: 1151px;
@@ -62,82 +122,60 @@ ics-calendar {
align-items: start;
flex-direction: row;
background-color: var(--event-details-background-color);
margin-top: 0px;
margin-top: 0;
margin-bottom: 4px;
}
}
}
a.fc-col-header-cell-cushion,
a.fc-col-header-cell-cushion:hover {
color: black;
// Reset from style.scss
thead {
background-color: white;
color: black;
}
// Reset from style.scss
tbody > tr {
&:nth-child(even):not(.highlight) {
background: white;
}
}
a.fc-daygrid-day-number,
a.fc-daygrid-day-number:hover {
color: rgb(34, 34, 34);
}
.fc .fc-toolbar.fc-footer-toolbar {
margin-bottom: 0.5em;
}
td {
overflow: visible; // Show events on multiple days
}
button.text-copy,
button.text-copy:focus,
button.text-copy:hover {
background-color: #67AE6E !important;
transition: 500ms ease-in;
}
//Reset from style.scss
table {
box-shadow: none;
border-radius: 0px;
-moz-border-radius: 0px;
margin: 0px;
}
button.text-copied,
button.text-copied:focus,
button.text-copied:hover {
transition: 500ms ease-out;
}
// Reset from style.scss
thead {
background-color: white;
color: black;
}
.fc .fc-getCalendarLink-button {
margin-right: 0.5rem;
}
// Reset from style.scss
tbody>tr {
&:nth-child(even):not(.highlight) {
background: white;
}
}
.fc .fc-toolbar.fc-footer-toolbar {
margin-bottom: 0.5em;
}
button.text-copy,
button.text-copy:focus,
button.text-copy:hover {
background-color: #67AE6E !important;
transition: 500ms ease-in;
}
button.text-copied,
button.text-copied:focus,
button.text-copied:hover {
transition: 500ms ease-out;
}
.fc .fc-getCalendarLink-button {
margin-right: 0.5rem;
}
.fc .fc-helpButton-button {
border-radius: 70%;
padding-left: 0.5rem;
padding-right: 0.5rem;
background-color: rgba(0, 0, 0, 0.8);
transition: 100ms ease-out;
width: 30px;
height: 30px;
font-size: 11px;
}
.fc .fc-helpButton-button {
border-radius: 70%;
padding-left: 0.5rem;
padding-right: 0.5rem;
background-color: rgba(0, 0, 0, 0.8);
transition: 100ms ease-out;
width: 30px;
height: 30px;
font-size: 11px;
}
.fc .fc-helpButton-button:hover {
background-color: rgba(20, 20, 20, 0.6);
}
.fc .fc-helpButton-button:hover {
background-color: rgba(20, 20, 20, 0.6);
}
.tooltip.calendar-copy-tooltip {

View File

@@ -16,6 +16,13 @@
}
}
.card-group {
display: flex;
gap: 15px;
margin-bottom: 30px;
flex-wrap: wrap;
}
.card {
background-color: $primary-neutral-light-color;
border-radius: 5px;
@@ -92,13 +99,23 @@
}
@media screen and (max-width: 765px) {
@include row-layout
@include row-layout;
}
// When combined with card, card-row display the card in a row layout,
// whatever the size of the screen.
&.card-row {
@include row-layout
@include row-layout;
&.card-row-m {
//width: 50%;
max-width: 50%;
}
&.card-row-s {
//width: 33%;
max-width: 33%;
}
}
}

View File

@@ -10,10 +10,9 @@
border-radius: 5px;
padding: 5px 10px;
position: absolute;
white-space: nowrap;
opacity: 0;
transition: opacity 500ms ease-out;
width: max-content;
white-space: normal;
left: 0;

View File

@@ -35,8 +35,8 @@
<noscript><link rel="stylesheet" href="{{ static('bundled/fontawesome-index.css') }}"></noscript>
<script src="{{ url('javascript-catalog') }}"></script>
<script type="module" src="{{ static("bundled/core/navbar-index.ts") }}"></script>
<script type="module" src="{{ static("bundled/core/components/include-index.ts") }}"></script>
<script type="module" src={{ static("bundled/core/navbar-index.ts") }}></script>
<script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script>
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>

View File

@@ -23,10 +23,10 @@
</tr>
</thead>
<tbody>
{% for m in profile.memberships.ongoing().select_related("role") %}
{% for m in profile.memberships.filter(end_date=None).all() %}
<tr>
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
<td>{{ m.role.name }}</td>
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
<td>{{ m.description }}</td>
<td>{{ m.start_date }}</td>
{% if m.can_be_edited_by(user) %}
@@ -65,10 +65,10 @@
</tr>
</thead>
<tbody>
{% for m in profile.memberships.ongoing().select_related("role") %}
{% for m in profile.memberships.exclude(end_date=None).all() %}
<tr>
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
<td>{{ m.role.name }}</td>
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
<td>{{ m.description }}</td>
<td>{{ m.start_date }}</td>
<td>{{ m.end_date }}</td>

View File

@@ -418,16 +418,16 @@ class TestUserIsInGroup(TestCase):
group_in = baker.make(Group)
self.public_user.groups.add(group_in)
# clear the cached property `User.all_groups`
self.public_user.__dict__.pop("all_groups", None)
# clear the cached property `User.cached_groups`
self.public_user.__dict__.pop("cached_groups", None)
# Test when the user is in the group
with self.assertNumQueries(2):
with self.assertNumQueries(1):
self.public_user.is_in_group(pk=group_in.id)
with self.assertNumQueries(0):
self.public_user.is_in_group(pk=group_in.id)
group_not_in = baker.make(Group)
self.public_user.__dict__.pop("all_groups", None)
self.public_user.__dict__.pop("cached_groups", None)
# Test when the user is not in the group
with self.assertNumQueries(1):
self.public_user.is_in_group(pk=group_not_in.id)

View File

@@ -11,7 +11,7 @@ from django.utils.timezone import now
from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects
from club.models import Club, Membership
from club.models import Club
from core.baker_recipes import board_user, subscriber_user
from core.markdown import markdown
from core.models import AnonymousUser, Page, PageRev, User
@@ -122,9 +122,6 @@ def test_page_revision_club_redirection(client: Client):
@pytest.mark.django_db
def test_viewable_by():
# remove existing pages to prevent side effect
# club pages are protected, so we must delete clubs first
Membership.objects.all().delete()
Club.objects.all().delete()
Page.objects.all().delete()
view_groups = [
[settings.SITH_GROUP_PUBLIC_ID],

View File

@@ -40,9 +40,8 @@ from django.forms import (
DateInput,
DateTimeInput,
TextInput,
Widget,
)
from django.utils.timezone import now
from django.utils.timezone import localtime, now
from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from PIL import Image
@@ -100,8 +99,8 @@ class FutureDateTimeField(forms.DateTimeField):
default_validators = [validate_future_timestamp]
def widget_attrs(self, widget: Widget) -> dict[str, str]:
return {"min": widget.format_value(now())}
def widget_attrs(self, widget: forms.Widget) -> dict[str, str]:
return {"min": widget.format_value(localtime())}
# Forms

View File

@@ -78,7 +78,7 @@ class FragmentMixin(TemplateResponseMixin, ContextMixin):
return render(
request,
"app/template.jinja",
context={"fragment": fragment(request)
context={"fragment": fragment(request)}
}
# in urls.py

View File

@@ -248,15 +248,14 @@ class UserTabsMixin(TabedViewMixin):
"name": _("Groups"),
}
)
can_view_account = (
if (
hasattr(user, "customer")
and user.customer
and (
user == self.request.user
or self.request.user.has_perm("counter.view_customer")
)
)
if can_view_account or user.preferences.show_my_stats:
):
tab_list.append(
{
"url": reverse("core:user_stats", kwargs={"user_id": user.id}),
@@ -264,7 +263,6 @@ class UserTabsMixin(TabedViewMixin):
"name": _("Stats"),
}
)
if can_view_account:
tab_list.append(
{
"url": reverse("core:user_account", kwargs={"user_id": user.id}),
@@ -351,7 +349,7 @@ class UserGodfathersTreeView(UserTabsMixin, CanViewMixin, DetailView):
return kwargs
class UserStatsView(UserTabsMixin, UserPassesTestMixin, DetailView):
class UserStatsView(UserTabsMixin, CanViewMixin, DetailView):
"""Display a user's stats."""
model = User
@@ -359,20 +357,15 @@ class UserStatsView(UserTabsMixin, UserPassesTestMixin, DetailView):
context_object_name = "profile"
template_name = "core/user_stats.jinja"
current_tab = "stats"
queryset = User.objects.exclude(customer=None).select_related(
"customer", "_preferences"
)
queryset = User.objects.exclude(customer=None).select_related("customer")
def test_func(self):
profile: User = self.get_object()
return (
profile == self.request.user
or self.request.user.has_perm("counter.view_customer")
or (
self.request.user.can_view(profile)
and profile.preferences.show_my_stats
)
)
def dispatch(self, request, *arg, **kwargs):
profile = self.get_object()
if not (
profile == request.user or request.user.has_perm("counter.view_customer")
):
raise PermissionDenied
return super().dispatch(request, *arg, **kwargs)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)

View File

@@ -5,7 +5,6 @@ from datetime import date, datetime, timezone
from dateutil.relativedelta import relativedelta
from django import forms
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator
from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet
@@ -16,7 +15,7 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget
from club.models import Club
from club.widgets.ajax_select import AutoCompleteSelectClub
from core.models import User, UserQuerySet
from core.models import User
from core.views.forms import (
FutureDateTimeField,
NFCTextInput,
@@ -33,7 +32,6 @@ from core.views.widgets.ajax_select import (
from counter.models import (
BillingInfo,
Counter,
CounterSellers,
Customer,
Eticket,
InvoiceCall,
@@ -172,39 +170,14 @@ class RefillForm(forms.ModelForm):
class CounterEditForm(forms.ModelForm):
class Meta:
model = Counter
fields = ["products"]
sellers_regular = forms.ModelMultipleChoiceField(
label=_("Regular barmen"),
help_text=_(
"Barmen having regular permanences "
"or frequently giving a hand throughout the semester."
),
queryset=User.objects.all(),
widget=AutoCompleteSelectMultipleUser,
required=False,
)
sellers_temporary = forms.ModelMultipleChoiceField(
label=_("Temporary barmen"),
help_text=_(
"Barmen who will be there only for a limited period (e.g. for one evening)"
),
queryset=User.objects.all(),
widget=AutoCompleteSelectMultipleUser,
required=False,
)
field_order = ["sellers_regular", "sellers_temporary", "products"]
fields = ["sellers", "products"]
widgets = {"sellers": AutoCompleteSelectMultipleUser}
def __init__(self, *args, user: User, instance: Counter, **kwargs):
super().__init__(*args, instance=instance, **kwargs)
# if the user is an admin, he will have access to all products,
# else only to active products owned by the counter's club
# or already on the counter
if user.has_perm("counter.change_counter"):
self.fields["products"].widget = AutoCompleteSelectMultipleProduct()
else:
# updating the queryset of the field also updates the choices of
# the widget, so it's important to set the queryset after the widget
self.fields["products"].widget = AutoCompleteSelectMultiple()
self.fields["products"].queryset = Product.objects.filter(
Q(club_id=instance.club_id) | Q(counters=instance), archived=False
@@ -213,61 +186,6 @@ class CounterEditForm(forms.ModelForm):
"If you want to add a product that is not owned by "
"your club to this counter, you should ask an admin."
)
self.fields["sellers_regular"].initial = self.instance.sellers.filter(
countersellers__is_regular=True
).all()
self.fields["sellers_temporary"].initial = self.instance.sellers.filter(
countersellers__is_regular=False
).all()
def clean(self):
regular: UserQuerySet = self.cleaned_data["sellers_regular"]
temporary: UserQuerySet = self.cleaned_data["sellers_temporary"]
duplicates = list(regular.intersection(temporary))
if duplicates:
raise ValidationError(
_(
"A user cannot be a regular and a temporary barman "
"at the same time, "
"but the following users have been defined as both : %(users)s"
)
% {"users": ", ".join([u.get_display_name() for u in duplicates])}
)
return self.cleaned_data
def save_sellers(self):
sellers = []
for users, is_regular in (
(self.cleaned_data["sellers_regular"], True),
(self.cleaned_data["sellers_temporary"], False),
):
sellers.extend(
[
CounterSellers(counter=self.instance, user=u, is_regular=is_regular)
for u in users
]
)
# start by deleting removed CounterSellers objects
user_ids = [seller.user.id for seller in sellers]
CounterSellers.objects.filter(
~Q(user_id__in=user_ids), counter=self.instance
).delete()
# then create or update the new barmen
CounterSellers.objects.bulk_create(
sellers,
update_conflicts=True,
update_fields=["is_regular"],
unique_fields=["user", "counter"],
)
def save(self, commit=True): # noqa: FBT002
self.instance = super().save(commit=commit)
if commit and any(
key in self.changed_data for key in ("sellers_regular", "sellers_temporary")
):
self.save_sellers()
return self.instance
class ScheduledProductActionForm(forms.ModelForm):
@@ -373,8 +291,7 @@ ScheduledProductActionFormSet = forms.modelformset_factory(
absolute_max=None,
can_delete=True,
can_delete_extra=False,
extra=0,
min_num=1,
extra=2,
)

View File

@@ -1,88 +0,0 @@
# Generated by Django 5.2.11 on 2026-03-04 15:26
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("counter", "0037_productformula"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
# cf. https://docs.djangoproject.com/fr/stable/howto/writing-migrations/#changing-a-manytomanyfield-to-use-a-through-model
migrations.SeparateDatabaseAndState(
database_operations=[
migrations.RunSQL(
sql="ALTER TABLE counter_counter_sellers RENAME TO counter_countersellers",
reverse_sql="ALTER TABLE counter_countersellers RENAME TO counter_counter_sellers",
),
],
state_operations=[
migrations.CreateModel(
name="CounterSellers",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"counter",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="counter.counter",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"constraints": [
models.UniqueConstraint(
fields=("counter", "user"),
name="counter_counter_sellers_counter_id_subscriber_id_key",
)
],
},
),
migrations.AlterField(
model_name="counter",
name="sellers",
field=models.ManyToManyField(
blank=True,
related_name="counters",
through="counter.CounterSellers",
to=settings.AUTH_USER_MODEL,
verbose_name="sellers",
),
),
],
),
migrations.AddField(
model_name="countersellers",
name="created_at",
field=models.DateTimeField(
auto_now_add=True,
default=django.utils.timezone.now,
verbose_name="created at",
),
preserve_default=False,
),
migrations.AddField(
model_name="countersellers",
name="is_regular",
field=models.BooleanField(default=False, verbose_name="regular barman"),
),
]

View File

@@ -551,11 +551,7 @@ class Counter(models.Model):
choices=[("BAR", _("Bar")), ("OFFICE", _("Office")), ("EBOUTIC", _("Eboutic"))],
)
sellers = models.ManyToManyField(
User,
verbose_name=_("sellers"),
related_name="counters",
blank=True,
through="CounterSellers",
User, verbose_name=_("sellers"), related_name="counters", blank=True
)
edit_groups = models.ManyToManyField(
Group, related_name="editable_counters", blank=True
@@ -583,7 +579,7 @@ class Counter(models.Model):
if user.is_anonymous:
return False
mem = self.club.get_membership_for(user)
if mem and mem.role.is_presidency:
if mem and mem.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
return True
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
@@ -747,26 +743,6 @@ class Counter(models.Model):
]
class CounterSellers(models.Model):
"""Custom through model for the counter-sellers M2M relationship."""
counter = models.ForeignKey(Counter, on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE)
is_regular = models.BooleanField(_("regular barman"), default=False)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["counter", "user"],
name="counter_counter_sellers_counter_id_subscriber_id_key",
)
]
def __str__(self):
return f"counter {self.counter_id} - user {self.user_id}"
class RefillingQuerySet(models.QuerySet):
def annotate_total(self) -> Self:
"""Annotate the Queryset with the total amount.

View File

@@ -64,7 +64,7 @@ document.addEventListener("alpine:init", () => {
checkFormulas() {
const products = new Set(
Object.keys(this.basket).map((i: string) => Number.parseInt(i, 10)),
Object.keys(this.basket).map((i: string) => Number.parseInt(i)),
);
const formula: ProductFormula = config.formulas.find((f: ProductFormula) => {
return f.products.every((p: number) => products.has(p));

View File

@@ -1,44 +1,5 @@
{% extends "core/base.jinja" %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/core/dynamic-formset-index.ts") }}"></script>
{% endblock %}
{% macro action_form(form) %}
<fieldset x-data="{action: '{{ form.task.initial }}'}">
{{ form.non_field_errors() }}
<div class="row gap-2x margin-bottom">
<div>
{{ form.task.errors }}
{{ form.task.label_tag() }}
{{ form.task|add_attr("x-model=action") }}
</div>
<div>{{ form.trigger_at.as_field_group() }}</div>
</div>
<div x-show="action==='counter.tasks.change_counters'" class="margin-bottom">
{{ form.counters.as_field_group() }}
</div>
{%- if form.DELETE -%}
<div class="row gap">
{{ form.DELETE.as_field_group() }}
</div>
{%- else -%}
<button
class="btn btn-grey"
@click.prevent="removeForm($event.target.closest('fieldset'))"
>
<i class="fa fa-minus"></i>{% trans %}Remove this action{% endtrans %}
</button>
{%- endif -%}
{%- for field in form.hidden_fields() -%}
{{ field }}
{%- endfor -%}
<hr />
</fieldset>
{% endmacro %}
{% block content %}
{% if object %}
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
@@ -64,20 +25,34 @@
</em>
</p>
<div x-data="dynamicFormSet" class="margin-bottom">
{{ form.action_formset.management_form }}
<div x-ref="formContainer">
{%- for f in form.action_formset.forms -%}
{{ action_form(f) }}
{{ form.action_formset.management_form }}
{%- for action_form in form.action_formset.forms -%}
<fieldset x-data="{action: '{{ action_form.task.initial }}'}">
{{ action_form.non_field_errors() }}
<div class="row gap-2x margin-bottom">
<div>
{{ action_form.task.errors }}
{{ action_form.task.label_tag() }}
{{ action_form.task|add_attr("x-model=action") }}
</div>
<div>{{ action_form.trigger_at.as_field_group() }}</div>
</div>
<div x-show="action==='counter.tasks.change_counters'" class="margin-bottom">
{{ action_form.counters.as_field_group() }}
</div>
{%- if action_form.DELETE -%}
<div class="row gap">
{{ action_form.DELETE.as_field_group() }}
</div>
{%- endif -%}
{%- for field in action_form.hidden_fields() -%}
{{ field }}
{%- endfor -%}
</div>
<template x-ref="formTemplate">
{{ action_form(form.action_formset.empty_form) }}
</template>
<button @click.prevent="addForm()" class="btn btn-grey">
<i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %}
</button>
</div>
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</fieldset>
{%- if not loop.last -%}
<hr class="margin-bottom">
{%- endif -%}
{%- endfor -%}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock %}

View File

@@ -32,7 +32,7 @@ from model_bakery import baker
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects
from club.models import ClubRole, Membership
from club.models import Membership
from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
from core.models import BanGroup, User
from counter.baker_recipes import product_recipe, sale_recipe
@@ -88,7 +88,7 @@ class TestFullClickBase(TestCase):
Membership,
start_date=now() - timedelta(days=30),
club=cls.club_counter.club,
role=baker.make(ClubRole, club=cls.club_counter.club, is_board=True),
role=settings.SITH_CLUB_ROLES_ID["Board member"],
user=cls.club_admin,
)
@@ -782,13 +782,7 @@ class TestClubCounterClickAccess(TestCase):
"counter:click",
kwargs={"counter_id": cls.counter.id, "user_id": cls.customer.id},
)
cls.board_role, cls.member_role = baker.make(
ClubRole,
club=cls.counter.club,
is_board=iter([True, False]),
_quantity=2,
_bulk_create=True,
)
cls.user = subscriber_user.make()
def setUp(self):
@@ -803,17 +797,13 @@ class TestClubCounterClickAccess(TestCase):
res = self.client.get(self.click_url)
assert res.status_code == 403
# being a member of the club, without being in the board, isn't enough
baker.make(
Membership, club=self.counter.club, user=self.user, role=self.member_role
)
baker.make(Membership, club=self.counter.club, user=self.user, role=1)
res = self.client.get(self.click_url)
assert res.status_code == 403
def test_board_member(self):
"""By default, board members should be able to click on office counters"""
baker.make(
Membership, club=self.counter.club, user=self.user, role=self.board_role
)
baker.make(Membership, club=self.counter.club, user=self.user, role=3)
self.client.force_login(self.user)
res = self.client.get(self.click_url)
assert res.status_code == 200
@@ -828,9 +818,7 @@ class TestClubCounterClickAccess(TestCase):
def test_both_barman_and_board_member(self):
"""If the user is barman and board member, he should be authorized as well."""
self.counter.sellers.add(self.user)
baker.make(
Membership, club=self.counter.club, user=self.user, role=self.board_role
)
baker.make(Membership, club=self.counter.club, user=self.user, role=3)
self.client.force_login(self.user)
res = self.client.get(self.click_url)
assert res.status_code == 200

View File

@@ -1,132 +1,13 @@
from django.conf import settings
from django.contrib.auth.models import Permission
from django.test import TestCase
from django.urls import reverse
from model_bakery import baker
from club.models import Membership
from core.baker_recipes import subscriber_user
from core.models import Group, User
from core.models import User
from counter.baker_recipes import product_recipe
from counter.forms import CounterEditForm
from counter.models import Counter, CounterSellers
class TestEditCounterSellers(TestCase):
@classmethod
def setUpTestData(cls):
cls.counter = baker.make(Counter, type="BAR")
cls.products = product_recipe.make(_quantity=2, _bulk_create=True)
cls.counter.products.add(*cls.products)
users = subscriber_user.make(_quantity=6, _bulk_create=True)
cls.regular_barmen = users[:2]
cls.tmp_barmen = users[2:4]
cls.not_barmen = users[4:]
CounterSellers.objects.bulk_create(
[
*baker.prepare(
CounterSellers,
counter=cls.counter,
user=iter(cls.regular_barmen),
is_regular=True,
_quantity=len(cls.regular_barmen),
),
*baker.prepare(
CounterSellers,
counter=cls.counter,
user=iter(cls.tmp_barmen),
is_regular=False,
_quantity=len(cls.tmp_barmen),
),
]
)
cls.operator = baker.make(
User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)]
)
def test_view_ok(self):
url = reverse("counter:admin", kwargs={"counter_id": self.counter.id})
self.client.force_login(self.operator)
res = self.client.get(url)
assert res.status_code == 200
res = self.client.post(
url,
data={
"sellers_regular": [u.id for u in self.regular_barmen],
"sellers_temporary": [u.id for u in self.tmp_barmen],
"products": [p.id for p in self.products],
},
)
self.assertRedirects(res, url)
def test_add_barmen(self):
form = CounterEditForm(
data={
"sellers_regular": [*self.regular_barmen, self.not_barmen[0]],
"sellers_temporary": [*self.tmp_barmen, self.not_barmen[1]],
"products": self.products,
},
instance=self.counter,
user=self.operator,
)
assert form.is_valid()
form.save()
assert set(self.counter.sellers.filter(countersellers__is_regular=True)) == {
*self.regular_barmen,
self.not_barmen[0],
}
assert set(self.counter.sellers.filter(countersellers__is_regular=False)) == {
*self.tmp_barmen,
self.not_barmen[1],
}
def test_barman_change_status(self):
"""Test when a barman goes from temporary to regular"""
form = CounterEditForm(
data={
"sellers_regular": [*self.regular_barmen, self.tmp_barmen[0]],
"sellers_temporary": [*self.tmp_barmen[1:]],
"products": self.products,
},
instance=self.counter,
user=self.operator,
)
assert form.is_valid()
form.save()
assert set(self.counter.sellers.filter(countersellers__is_regular=True)) == {
*self.regular_barmen,
self.tmp_barmen[0],
}
assert set(
self.counter.sellers.filter(countersellers__is_regular=False)
) == set(self.tmp_barmen[1:])
def test_barman_duplicate(self):
"""Test that a barman cannot be regular and temporary at the same time."""
form = CounterEditForm(
data={
"sellers_regular": [*self.regular_barmen, self.not_barmen[0]],
"sellers_temporary": [*self.tmp_barmen, self.not_barmen[0]],
"products": self.products,
},
instance=self.counter,
user=self.operator,
)
assert not form.is_valid()
assert form.errors == {
"__all__": [
"Un utilisateur ne peut pas être un barman "
"régulier et temporaire en même temps, "
"mais les utilisateurs suivants ont été définis "
f"comme les deux : {self.not_barmen[0].get_display_name()}"
],
}
assert set(self.counter.sellers.filter(countersellers__is_regular=True)) == set(
self.regular_barmen
)
assert set(
self.counter.sellers.filter(countersellers__is_regular=False)
) == set(self.tmp_barmen)
from counter.models import Counter
class TestEditCounterProducts(TestCase):

View File

@@ -3,13 +3,14 @@ import string
from datetime import timedelta
import pytest
from django.conf import settings
from django.contrib.auth.base_user import make_password
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker
from club.models import ClubRole, Membership
from club.models import Membership
from core.baker_recipes import board_user, subscriber_user
from core.models import User
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
@@ -41,12 +42,11 @@ class TestStudentCard(TestCase):
cls.counter.sellers.add(cls.barmen)
cls.club_counter = baker.make(Counter)
role = baker.make(ClubRole, club=cls.club_counter.club, is_board=True)
baker.make(
Membership,
start_date=now() - timedelta(days=30),
club=cls.club_counter.club,
role=role,
role=settings.SITH_CLUB_ROLES_ID["Board member"],
user=cls.club_admin,
)

View File

@@ -16,7 +16,6 @@ from datetime import datetime, timedelta
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.forms import CheckboxSelectMultiple
@@ -59,9 +58,7 @@ class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView):
current_tab = "counters"
class CounterEditView(
CounterAdminTabsMixin, UserPassesTestMixin, SuccessMessageMixin, UpdateView
):
class CounterEditView(CounterAdminTabsMixin, UserPassesTestMixin, UpdateView):
"""Edit a counter's main informations (for the counter's manager)."""
model = Counter
@@ -69,7 +66,6 @@ class CounterEditView(
pk_url_kwarg = "counter_id"
template_name = "core/edit.jinja"
current_tab = "counters"
success_message = _("Counter update done")
def test_func(self):
if self.request.user.has_perm("counter.change_counter"):

View File

@@ -6,8 +6,6 @@ from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects
from core.baker_recipes import subscriber_user
from core.models import Group, User
@@ -54,102 +52,6 @@ class TestElectionUpdateView(TestElection):
assert response.status_code == 403
class TestElectionForm(TestCase):
@classmethod
def setUpTestData(cls):
cls.election = baker.make(Election, end_date=now() + timedelta(days=1))
cls.group = baker.make(Group)
cls.election.vote_groups.add(cls.group)
cls.election.edit_groups.add(cls.group)
lists = baker.make(
ElectionList, election=cls.election, _quantity=2, _bulk_create=True
)
cls.roles = baker.make(
Role, election=cls.election, _quantity=2, _bulk_create=True
)
users = baker.make(User, _quantity=4, _bulk_create=True)
recipe = Recipe(Candidature)
cls.cand = [
recipe.prepare(role=cls.roles[0], user=users[0], election_list=lists[0]),
recipe.prepare(role=cls.roles[0], user=users[1], election_list=lists[1]),
recipe.prepare(role=cls.roles[1], user=users[2], election_list=lists[0]),
recipe.prepare(role=cls.roles[1], user=users[3], election_list=lists[1]),
]
Candidature.objects.bulk_create(cls.cand)
cls.vote_url = reverse("election:vote", kwargs={"election_id": cls.election.id})
cls.detail_url = reverse(
"election:detail", kwargs={"election_id": cls.election.id}
)
def test_election_good_form(self):
postes = (self.roles[0].title, self.roles[1].title)
votes = [
{postes[0]: "", postes[1]: str(self.cand[2].id)},
{postes[0]: "", postes[1]: ""},
{postes[0]: str(self.cand[0].id), postes[1]: str(self.cand[2].id)},
{postes[0]: str(self.cand[0].id), postes[1]: str(self.cand[3].id)},
]
voters = subscriber_user.make(_quantity=len(votes), _bulk_create=True)
self.group.users.set(voters)
for voter, vote in zip(voters, votes, strict=True):
assert self.election.can_vote(voter)
self.client.force_login(voter)
response = self.client.post(self.vote_url, data=vote)
assertRedirects(response, self.detail_url)
assert set(self.election.voters.all()) == set(voters)
assert self.election.results == {
postes[0]: {
self.cand[0].user.username: {"percent": 50.0, "vote": 2},
self.cand[1].user.username: {"percent": 0.0, "vote": 0},
"blank vote": {"percent": 50.0, "vote": 2},
"total vote": 4,
},
postes[1]: {
self.cand[2].user.username: {"percent": 50.0, "vote": 2},
self.cand[3].user.username: {"percent": 25.0, "vote": 1},
"blank vote": {"percent": 25.0, "vote": 1},
"total vote": 4,
},
}
def test_election_bad_form(self):
postes = (self.roles[0].title, self.roles[1].title)
votes = [
{postes[0]: "", postes[1]: str(self.cand[0].id)}, # wrong candidate
{postes[0]: ""},
{
postes[0]: "0123456789", # unknow users
postes[1]: str(subscriber_user.make().id), # not a candidate
},
{},
]
voters = subscriber_user.make(_quantity=len(votes), _bulk_create=True)
self.group.users.set(voters)
for voter, vote in zip(voters, votes, strict=True):
self.client.force_login(voter)
response = self.client.post(self.vote_url, data=vote)
assertRedirects(response, self.detail_url)
assert self.election.results == {
postes[0]: {
self.cand[0].user.username: {"percent": 0.0, "vote": 0},
self.cand[1].user.username: {"percent": 0.0, "vote": 0},
"blank vote": {"percent": 100.0, "vote": 2},
"total vote": 2,
},
postes[1]: {
self.cand[2].user.username: {"percent": 0.0, "vote": 0},
self.cand[3].user.username: {"percent": 0.0, "vote": 0},
"blank vote": {"percent": 100.0, "vote": 2},
"total vote": 2,
},
}
@pytest.mark.django_db
def test_election_create_list_permission(client: Client):
election = baker.make(Election, end_candidature=now() + timedelta(hours=1))

View File

@@ -1,6 +1,7 @@
from typing import TYPE_CHECKING
from cryptography.utils import cached_property
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import (
LoginRequiredMixin,
@@ -114,9 +115,16 @@ class VoteFormView(LoginRequiredMixin, UserPassesTestMixin, FormView):
def test_func(self):
if not self.election.can_vote(self.request.user):
return False
return self.election.vote_groups.filter(
id__in=self.request.user.all_groups
).exists()
groups = set(self.election.vote_groups.values_list("id", flat=True))
if (
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
def vote(self, election_data):
with transaction.atomic():
@@ -230,9 +238,15 @@ class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
return False
if self.request.user.has_perm("election.add_role"):
return True
return self.election.edit_groups.filter(
id__in=self.request.user.all_groups
).exists()
groups = set(self.election.edit_groups.values_list("id", flat=True))
if (
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
def get_initial(self):
return {"election": self.election}
@@ -265,7 +279,14 @@ class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView
.union(self.election.edit_groups.values("id"))
.values_list("id", flat=True)
)
return not groups.isdisjoint(self.request.user.all_groups.keys())
if (
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
def get_initial(self):
return {"election": self.election}

View File

@@ -183,7 +183,7 @@ class Forum(models.Model):
Forum._club_memberships[self.id] = {}
Forum._club_memberships[self.id][user.id] = m
if m:
return m.role.is_board
return m.role > settings.SITH_MAXIMUM_FREE_ROLE
return False
def check_loop(self):

View File

@@ -29,9 +29,8 @@ from django.conf import settings
from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand
from django.utils import timezone
from model_bakery import baker
from club.models import Club, ClubRole, Membership
from club.models import Club, Membership
from core.models import Group, Page, SithFile, User
from core.utils import RED_PIXEL_PNG
from sas.models import Album, PeoplePictureRelation, Picture
@@ -218,19 +217,11 @@ class Command(BaseCommand):
"The `make_clubs()` method must be called before `make_club_memberships()`"
)
memberships = []
roles = {
r.club_id: r.id
for r in baker.make(
ClubRole,
club=iter(self.clubs),
_quantity=len(self.clubs),
_bulk_create=True,
)
}
for i in range(1, 11): # users can be in up to 20 clubs
self.logger.info(f"Club membership, pass {i}")
for uid in range(i, self.NB_USERS, i):
# Pass #1 will make sure every user is at least in one club
for uid in range(
i, self.NB_USERS, i
): # Pass #1 will make sure every user is at least in one club
user = self.users[uid]
club = self.clubs[(uid + i**2) % self.NB_CLUBS]
@@ -245,7 +236,7 @@ class Command(BaseCommand):
Membership(
user=user,
club=club,
role_id=roles[club.id],
role=(uid + i) % 10 + 1, # spread the different roles
start_date=start,
end_date=end,
)
@@ -268,7 +259,7 @@ class Command(BaseCommand):
Membership(
user=user,
club=club,
role_id=roles[club.id],
role=((uid // 10) + i) % 10 + 1, # spread the different roles
start_date=start,
end_date=end,
)

View File

@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-22 11:17+0100\n"
"POT-Creation-Date: 2026-03-07 15:47+0100\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -217,58 +217,15 @@ msgstr "home"
msgid "You can not make loops in clubs"
msgstr "Vous ne pouvez pas faire de boucles dans les clubs"
#: club/models.py com/models.py counter/models.py trombi/models.py
msgid "club"
msgstr "club"
#: club/models.py
msgid "The club with which this role is associated"
msgstr "Le club auquel ce rôle est attaché."
#: club/models.py core/models.py counter/models.py election/models.py
#: forum/models.py
msgid "description"
msgstr "description"
#: club/models.py
msgid "Board role"
msgstr "Rôle du bureau"
#: club/models.py
msgid "Presidency role"
msgstr "Rôle de la présidence"
#: club/models.py
msgid ""
"If the role is inactive, people joining the club won't be able to get it."
msgstr ""
"Si ce rôle est inactif, il ne pourra pas être attribué aux gens qui rejoignent le club."
#: club/models.py
msgid "club role"
msgstr "rôle de club"
#: club/models.py
msgid "club roles"
msgstr "rôles de club"
#: club/models.py
#, python-format
msgid ""
"Role %(name)s was declared as a presidency role without being a board role"
msgstr ""
"Le rôle %(name)s a été déclaré comme rôle de présidence sans être un rôle du bureau."
#: club/models.py
#, python-format
msgid "Board role %(role)s cannot be placed below a member role"
msgstr "Le rôle du bureau %(role)s ne peut pas être placé en-dessous d'un rôle de membre."
#: club/models.py core/models.py counter/models.py eboutic/models.py
#: election/models.py pedagogy/models.py sas/models.py trombi/models.py
msgid "user"
msgstr "utilisateur"
#: club/models.py com/models.py counter/models.py trombi/models.py
msgid "club"
msgstr "club"
#: club/models.py counter/models.py election/models.py
msgid "start date"
msgstr "date de début"
@@ -281,6 +238,11 @@ msgstr "date de fin"
msgid "role"
msgstr "rôle"
#: club/models.py core/models.py counter/models.py election/models.py
#: forum/models.py reservation/models.py
msgid "description"
msgstr "description"
#: club/models.py
msgid "past member"
msgstr "ancien membre"
@@ -552,6 +514,18 @@ msgstr "Nouveau Trombi"
msgid "Posters"
msgstr "Affiches"
#: club/templates/club/club_tools.jinja
msgid "Reservable rooms"
msgstr "Salles réservables"
#: club/templates/club/club_tools.jinja
msgid "Add a room"
msgstr "Ajouter une salle"
#: club/templates/club/club_tools.jinja
msgid "This club manages no reservable room"
msgstr "Ce club ne gère pas de salle réservable"
#: club/templates/club/club_tools.jinja
msgid "Counters:"
msgstr "Comptoirs : "
@@ -830,7 +804,7 @@ msgstr "Une description plus détaillée et exhaustive de l'évènement."
msgid "The club which organizes the event."
msgstr "Le club qui organise l'évènement."
#: com/models.py pedagogy/models.py trombi/models.py
#: com/models.py pedagogy/models.py reservation/models.py trombi/models.py
msgid "author"
msgstr "auteur"
@@ -1127,6 +1101,11 @@ msgstr "Emploi du temps"
msgid "Matmatronch"
msgstr "Matmatronch"
#: com/templates/com/news_list.jinja
#: reservation/templates/reservation/schedule.jinja
msgid "Room reservation"
msgstr "Réservation de salle"
#: com/templates/com/news_list.jinja core/templates/core/base/navbar.jinja
#: core/templates/core/user_tools.jinja
msgid "Elections"
@@ -1987,6 +1966,7 @@ msgstr "Confirmation"
#: core/templates/core/file_delete_confirm.jinja
#: counter/templates/counter/counter_click.jinja
#: counter/templates/counter/fragments/delete_student_card.jinja
#: reservation/templates/reservation/fragments/create_reservation.jinja
#: sas/templates/sas/ask_picture_removal.jinja
msgid "Cancel"
msgstr "Annuler"
@@ -2975,29 +2955,6 @@ msgstr "Cet UID est invalide"
msgid "User not found"
msgstr "Utilisateur non trouvé"
#: counter/forms.py
msgid "Regular barmen"
msgstr "Barmen réguliers"
#: counter/forms.py
msgid ""
"Barmen having regular permanences or frequently giving a hand throughout the "
"semester."
msgstr ""
"Les barmen assurant des permanences régulières ou donnant régulièrement un "
"coup de main au cours du semestre."
#: counter/forms.py
msgid "Temporary barmen"
msgstr "Barmen temporaires"
#: counter/forms.py
msgid ""
"Barmen who will be there only for a limited period (e.g. for one evening)"
msgstr ""
"Les barmen qui seront là uniquement pour une durée limitée (par exemple, le "
"temps d'une soirée)"
#: counter/forms.py
msgid ""
"If you want to add a product that is not owned by your club to this counter, "
@@ -3006,16 +2963,6 @@ msgstr ""
"Si vous souhaitez ajouter sur ce comptoir un produit qui n'appartient pas à "
"votre club, vous devriez demander à un admin."
#: counter/forms.py
#, python-format
msgid ""
"A user cannot be a regular and a temporary barman at the same time, but the "
"following users have been defined as both : %(users)s"
msgstr ""
"Un utilisateur ne peut pas être un barman régulier et temporaire en même "
"temps, mais les utilisateurs suivants ont été définis comme les deux : "
"%(users)s"
#: counter/forms.py
msgid "Date and time of action"
msgstr "Date et heure de l'action"
@@ -3164,7 +3111,7 @@ msgstr "Mettre à True si le mail a reçu une erreur"
msgid "The operation that emptied the account."
msgstr "L'opération qui a vidé le compte."
#: counter/models.py pedagogy/models.py
#: counter/models.py pedagogy/models.py reservation/models.py
msgid "comment"
msgstr "commentaire"
@@ -3264,10 +3211,6 @@ msgstr "vendeurs"
msgid "token"
msgstr "jeton"
#: counter/models.py
msgid "regular barman"
msgstr "barman régulier"
#: counter/models.py sith/settings.py
msgid "Credit card"
msgstr "Carte bancaire"
@@ -3832,10 +3775,6 @@ msgstr ""
"votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura "
"aucune conséquence autre que le retrait de l'argent de votre compte."
#: counter/templates/counter/product_form.jinja
msgid "Remove this action"
msgstr "Retirer cette action"
#: counter/templates/counter/product_form.jinja
#, python-format
msgid "Edit product %(name)s"
@@ -3863,10 +3802,6 @@ msgstr ""
"Les actions automatiques vous permettent de planifier des modifications du "
"produit à l'avance."
#: counter/templates/counter/product_form.jinja
msgid "Add action"
msgstr "Ajouter une action"
#: counter/templates/counter/product_list.jinja
msgid "Product list"
msgstr "Liste des produits"
@@ -3980,10 +3915,6 @@ msgstr "Temps"
msgid "Top 100 barman %(counter_name)s (all semesters)"
msgstr "Top 100 barman %(counter_name)s (tous les semestres)"
#: counter/views/admin.py
msgid "Counter update done"
msgstr "Mise à jour du comptoir effectuée"
#: counter/views/admin.py
#, python-format
msgid "%(formula)s (formula)"
@@ -4893,6 +4824,73 @@ msgstr "Signaler ce commentaire"
msgid "Edit UE"
msgstr "Éditer l'UE"
#: reservation/forms.py
msgid "The start must be set before the end"
msgstr "Le début doit être placé avant la fin"
#: reservation/models.py
msgid "room name"
msgstr "Nom de la salle"
#: reservation/models.py
msgid "room owner"
msgstr "propriétaire de la salle"
#: reservation/models.py
msgid "The club which manages this room"
msgstr "Le club qui gère cette salle"
#: reservation/models.py
msgid "site"
msgstr "site"
#: reservation/models.py
msgid "reservable room"
msgstr "salle réservable"
#: reservation/models.py
msgid "reservable rooms"
msgstr "salles réservables"
#: reservation/models.py
msgid "reserved room"
msgstr "salle réservée"
#: reservation/models.py
msgid "slot start"
msgstr "début du créneau"
#: reservation/models.py
msgid "slot end"
msgstr "fin du créneau"
#: reservation/models.py
msgid "reservation slot"
msgstr "créneau de réservation"
#: reservation/models.py
msgid "reservation slots"
msgstr "créneaux de réservation"
#: reservation/models.py
msgid "There is already a reservation on this slot."
msgstr "Il y a déjà une réservation sur ce créneau."
#: reservation/templates/reservation/fragments/create_reservation.jinja
msgid "Book a room"
msgstr "Réserver une salle"
#: reservation/templates/reservation/schedule.jinja
msgid "You can book a room by selecting a free slot in the calendar."
msgstr ""
"Vous pouvez réserver une salle en sélectionnant un emplacement libre dans le "
"calendrier."
#: reservation/views.py
#, python-format
msgid "%(name)s was updated successfully"
msgstr "%(name)s a été mis à jour avec succès"
#: rootplace/forms.py
msgid "User that will be kept"
msgstr "Utilisateur qui sera conservé"
@@ -5332,6 +5330,8 @@ msgid "One day"
msgstr "Un jour"
#: sith/settings.py
#, fuzzy
#| msgid "GA staff member"
msgid "GA staff member"
msgstr "Membre staff GA"
@@ -5359,6 +5359,42 @@ msgstr "Cursus alternant (-20%)"
msgid "One year for free(CA offer)"
msgstr "Une année offerte (Offre CA)"
#: sith/settings.py
msgid "President"
msgstr "Président⸱e"
#: sith/settings.py
msgid "Vice-President"
msgstr "Vice-Président⸱e"
#: sith/settings.py
msgid "Treasurer"
msgstr "Trésorier⸱e"
#: sith/settings.py
msgid "Communication supervisor"
msgstr "Responsable communication"
#: sith/settings.py
msgid "Secretary"
msgstr "Secrétaire"
#: sith/settings.py
msgid "IT supervisor"
msgstr "Responsable info"
#: sith/settings.py
msgid "Board member"
msgstr "Membre du bureau"
#: sith/settings.py
msgid "Active member"
msgstr "Membre actif⸱ve"
#: sith/settings.py
msgid "Curious"
msgstr "Curieux⸱euse"
#: sith/settings.py
msgid "A new poster needs to be moderated"
msgstr "Une nouvelle affiche a besoin d'être modérée"

View File

@@ -255,6 +255,14 @@ msgstr "Types de produits réordonnés !"
msgid "Product type reorganisation failed with status code : %d"
msgstr "La réorganisation des types de produit a échoué avec le code : %d"
#: reservation/static/bundled/reservation/components/room-scheduler-index.ts
msgid "Rooms"
msgstr "Salles"
#: reservation/static/bundled/reservation/slot-reservation-index.ts
msgid "This slot has been successfully moved"
msgstr "Ce créneau a été bougé avec succès"
#: sas/static/bundled/sas/pictures-download-index.ts
msgid "pictures.%(extension)s"
msgstr "photos.%(extension)s"

2460
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,8 @@
"compile-dev": "vite build --mode development",
"serve": "vite build --mode development --watch --minify false",
"openapi": "openapi-ts",
"analyse-dev": "vite-bundle-visualizer --mode development",
"analyse-prod": "vite-bundle-visualizer --mode production",
"check": "tsc && biome check --write"
},
"keywords": [],
@@ -21,33 +23,39 @@
"#core:*": "./core/static/bundled/*",
"#pedagogy:*": "./pedagogy/static/bundled/*",
"#counter:*": "./counter/static/bundled/*",
"#com:*": "./com/static/bundled/*"
"#com:*": "./com/static/bundled/*",
"#reservation:*": "./reservation/static/bundled/*"
},
"devDependencies": {
"@babel/core": "^7.29.0",
"@babel/preset-env": "^7.29.0",
"@biomejs/biome": "^2.4.6",
"@hey-api/openapi-ts": "^0.94.0",
"@biomejs/biome": "^2.3.14",
"@hey-api/openapi-ts": "^0.92.4",
"@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.11",
"@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6",
"rollup-plugin-visualizer": "^7.0.1",
"typescript": "^5.9.3",
"vite": "^8.0.0"
"vite": "^7.3.1",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.2.0"
},
"dependencies": {
"@alpinejs/morph": "^3.14.9",
"@alpinejs/sort": "^3.15.8",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.7.6",
"@floating-ui/dom": "^1.7.5",
"@fortawesome/fontawesome-free": "^7.2.0",
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/icalendar": "^6.1.20",
"@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/list": "^6.1.20",
"@sentry/browser": "^10.43.0",
"@zip.js/zip.js": "^2.8.23",
"@fullcalendar/resource": "^6.1.19",
"@fullcalendar/resource-timeline": "^6.1.19",
"@sentry/browser": "^10.38.0",
"@zip.js/zip.js": "^2.8.20",
"3d-force-graph": "^1.79.1",
"alpinejs": "^3.15.8",
"chart.js": "^4.5.1",
@@ -57,14 +65,15 @@
"cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.6",
"easymde": "^2.20.0",
"glob": "^13.0.6",
"glob": "^13.0.2",
"html2canvas": "^1.4.1",
"htmx-ext-alpine-morph": "^2.0.1",
"htmx.org": "^2.0.8",
"js-cookie": "^3.0.5",
"lit-html": "^3.3.2",
"native-file-system-adapter": "^3.0.1",
"three": "^0.183.2",
"three": "^0.182.0",
"three-spritetext": "^1.10.0",
"tom-select": "^2.5.2"
"tom-select": "^2.5.1"
}
}

View File

@@ -19,7 +19,7 @@ authors = [
license = { text = "GPL-3.0-only" }
requires-python = "<4.0,>=3.12"
dependencies = [
"django>=5.2.12,<6.0.0",
"django>=5.2.11,<6.0.0",
"django-ninja>=1.5.3,<6.0.0",
"django-ninja-extra>=0.31.0",
"Pillow>=12.1.1,<13.0.0",
@@ -27,15 +27,15 @@ dependencies = [
"django-jinja<3.0.0,>=2.11.0",
"cryptography>=46.0.5,<47.0.0",
"django-phonenumber-field>=8.4.0,<9.0.0",
"phonenumbers>=9.0.25,<10.0.0",
"reportlab>=4.4.10,<5.0.0",
"phonenumbers>=9.0.23,<10.0.0",
"reportlab>=4.4.9,<5.0.0",
"django-haystack<4.0.0,>=3.3.0",
"xapian-haystack<4.0.0,>=3.1.0",
"libsass<1.0.0,>=0.23.0",
"django-ordered-model<4.0.0,>=3.7.4",
"django-simple-captcha<1.0.0,>=0.6.3",
"python-dateutil<3.0.0.0,>=2.9.0.post0",
"sentry-sdk>=2.54.0,<3.0.0",
"sentry-sdk>=2.52.0,<3.0.0",
"jinja2<4.0.0,>=3.1.6",
"django-countries>=8.2.0,<9.0.0",
"dict2xml>=1.7.8,<2.0.0",
@@ -51,7 +51,7 @@ dependencies = [
"psutil>=7.2.2,<8.0.0",
"celery[redis]>=5.6.2,<7",
"django-celery-results>=2.5.1",
"django-celery-beat>=2.9.0",
"django-celery-beat>=2.7.0",
]
[project.urls]
@@ -60,31 +60,31 @@ documentation = "https://sith-ae.readthedocs.io/"
[dependency-groups]
prod = [
"psycopg[c]>=3.3.3,<4.0.0",
"psycopg[c]>=3.3.2,<4.0.0",
]
dev = [
"django-debug-toolbar>=6.2.0,<7",
"ipython>=9.11.0,<10.0.0",
"ipython>=9.10.0,<10.0.0",
"pre-commit>=4.5.1,<5.0.0",
"ruff>=0.15.5,<1.0.0",
"ruff>=0.15.0,<1.0.0",
"djhtml>=3.0.10,<4.0.0",
"faker>=40.8.0,<41.0.0",
"faker>=40.4.0,<41.0.0",
"rjsmin>=1.2.5,<2.0.0",
]
tests = [
"freezegun>=1.5.5,<2.0.0",
"pytest>=9.0.2,<10.0.0",
"pytest-cov>=7.0.0,<8.0.0",
"pytest-django<5.0.0,>=4.12.0",
"model-bakery<2.0.0,>=1.23.3",
"pytest-django<5.0.0,>=4.10.0",
"model-bakery<2.0.0,>=1.23.2",
"beautifulsoup4>=4.14.3,<5",
"lxml>=6.0.2,<7",
]
docs = [
"mkdocs<2.0.0,>=1.6.1",
"mkdocs-material>=9.7.5,<10.0.0",
"mkdocs-material>=9.7.1,<10.0.0",
"mkdocstrings>=1.0.3,<2.0.0",
"mkdocstrings-python>=2.0.3,<3.0.0",
"mkdocstrings-python>=2.0.2,<3.0.0",
"mkdocs-include-markdown-plugin>=7.2.1,<8.0.0",
]

0
reservation/__init__.py Normal file
View File

19
reservation/admin.py Normal file
View File

@@ -0,0 +1,19 @@
from django.contrib import admin
from reservation.models import ReservationSlot, Room
@admin.register(Room)
class RoomAdmin(admin.ModelAdmin):
list_display = ("name", "club")
list_filter = (("club", admin.RelatedOnlyFieldListFilter), "location")
autocomplete_fields = ("club",)
search_fields = ("name",)
@admin.register(ReservationSlot)
class ReservationSlotAdmin(admin.ModelAdmin):
list_display = ("room", "start_at", "end_at", "author")
autocomplete_fields = ("author",)
list_filter = ("room",)
date_hierarchy = "start_at"

64
reservation/api.py Normal file
View File

@@ -0,0 +1,64 @@
from typing import Any, Literal
from django.core.exceptions import ValidationError
from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from api.permissions import HasPerm
from reservation.models import ReservationSlot, Room
from reservation.schemas import (
RoomFilterSchema,
RoomSchema,
SlotFilterSchema,
SlotSchema,
UpdateReservationSlotSchema,
)
@api_controller("/reservation/room")
class ReservableRoomController(ControllerBase):
@route.get(
"",
response=list[RoomSchema],
permissions=[HasPerm("reservation.view_room")],
url_name="fetch_reservable_rooms",
)
def fetch_rooms(self, filters: Query[RoomFilterSchema]):
return filters.filter(Room.objects.select_related("club"))
@api_controller("/reservation/slot")
class ReservationSlotController(ControllerBase):
@route.get(
"",
response=PaginatedResponseSchema[SlotSchema],
permissions=[HasPerm("reservation.view_reservationslot")],
url_name="fetch_reservation_slots",
)
@paginate(PageNumberPaginationExtra)
def fetch_slots(self, filters: Query[SlotFilterSchema]):
return filters.filter(
ReservationSlot.objects.select_related("author").order_by("start_at")
)
@route.patch(
"/reservation/slot/{int:slot_id}",
permissions=[HasPerm("reservation.change_reservationslot")],
response={
200: None,
409: dict[Literal["detail"], dict[str, list[str]]],
422: dict[Literal["detail"], list[dict[str, Any]]],
},
url_name="change_reservation_slot",
)
def update_slot(self, slot_id: int, params: UpdateReservationSlotSchema):
slot = self.get_object_or_exception(ReservationSlot, id=slot_id)
slot.start_at = params.start_at
slot.end_at = params.end_at
try:
slot.full_clean()
slot.save()
except ValidationError as e:
return self.create_response({"detail": dict(e)}, status_code=409)

6
reservation/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ReservationConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "reservation"

60
reservation/forms.py Normal file
View File

@@ -0,0 +1,60 @@
from django import forms
from django.core.exceptions import NON_FIELD_ERRORS
from django.utils.translation import gettext_lazy as _
from club.widgets.ajax_select import AutoCompleteSelectClub
from core.models import User
from core.views.forms import FutureDateTimeField, SelectDateTime
from reservation.models import ReservationSlot, Room
class RoomCreateForm(forms.ModelForm):
required_css_class = "required"
error_css_class = "error"
class Meta:
model = Room
fields = ["name", "club", "location", "description"]
widgets = {"club": AutoCompleteSelectClub}
class RoomUpdateForm(forms.ModelForm):
required_css_class = "required"
error_css_class = "error"
class Meta:
model = Room
fields = ["name", "club", "location", "description"]
widgets = {"club": AutoCompleteSelectClub}
def __init__(self, *args, request_user: User, **kwargs):
super().__init__(*args, **kwargs)
if not request_user.has_perm("reservation.change_room"):
# if the user doesn't have the global edition permission
# (i.e. it's a club board member, but not a sith admin)
# some fields aren't editable
del self.fields["club"]
class ReservationForm(forms.ModelForm):
required_css_class = "required"
error_css_class = "error"
class Meta:
model = ReservationSlot
fields = ["room", "start_at", "end_at", "comment"]
field_classes = {"start_at": FutureDateTimeField, "end_at": FutureDateTimeField}
widgets = {"start_at": SelectDateTime(), "end_at": SelectDateTime()}
error_messages = {
NON_FIELD_ERRORS: {
"start_after_end": _("The start must be set before the end")
}
}
def __init__(self, *args, author: User, **kwargs):
super().__init__(*args, **kwargs)
self.author = author
def save(self, commit: bool = True): # noqa FBT001
self.instance.author = self.author
return super().save(commit)

View File

@@ -0,0 +1,117 @@
# Generated by Django 5.2.1 on 2025-06-05 10:44
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Room",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100, verbose_name="room name")),
(
"description",
models.TextField(
blank=True, default="", verbose_name="description"
),
),
(
"location",
models.CharField(
blank=True,
choices=[
("BELFORT", "Belfort"),
("SEVENANS", "Sévenans"),
("MONTBELIARD", "Montbéliard"),
],
verbose_name="site",
),
),
(
"club",
models.ForeignKey(
help_text="The club which manages this room",
on_delete=django.db.models.deletion.CASCADE,
related_name="reservable_rooms",
to="club.club",
verbose_name="room owner",
),
),
],
options={
"verbose_name": "reservable room",
"verbose_name_plural": "reservable rooms",
},
),
migrations.CreateModel(
name="ReservationSlot",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"comment",
models.TextField(blank=True, default="", verbose_name="comment"),
),
(
"start_at",
models.DateTimeField(db_index=True, verbose_name="slot start"),
),
("end_at", models.DateTimeField(verbose_name="slot end")),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"author",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
verbose_name="author",
),
),
(
"room",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="slots",
to="reservation.room",
verbose_name="reserved room",
),
),
],
options={
"verbose_name": "reservation slot",
"verbose_name_plural": "reservation slots",
"constraints": [
models.CheckConstraint(
condition=models.Q(("end_at__gt", models.F("start_at"))),
name="reservation_slot_end_after_start",
violation_error_code="start_after_end",
)
],
},
),
]

View File

100
reservation/models.py Normal file
View File

@@ -0,0 +1,100 @@
from __future__ import annotations
from typing import Self
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Q
from django.utils.translation import gettext_lazy as _
from club.models import Club
from core.models import User
class Room(models.Model):
name = models.CharField(_("room name"), max_length=100)
description = models.TextField(_("description"), blank=True, default="")
club = models.ForeignKey(
Club,
on_delete=models.CASCADE,
related_name="reservable_rooms",
verbose_name=_("room owner"),
help_text=_("The club which manages this room"),
)
location = models.CharField(
_("site"),
blank=True,
choices=[
("BELFORT", "Belfort"),
("SEVENANS", "Sévenans"),
("MONTBELIARD", "Montbéliard"),
],
)
class Meta:
verbose_name = _("reservable room")
verbose_name_plural = _("reservable rooms")
def __str__(self):
return self.name
def can_be_edited_by(self, user: User) -> bool:
# a user may edit a room if it has the global perm
# or is in the owner club board
return user.has_perm("reservation.change_room") or self.club.board_group_id in [
g.id for g in user.cached_groups
]
class ReservationSlotQuerySet(models.QuerySet):
def overlapping_with(self, slot: ReservationSlot) -> Self:
return self.filter(
Q(start_at__lt=slot.start_at, end_at__gt=slot.start_at)
| Q(start_at__lt=slot.end_at, end_at__gt=slot.end_at)
)
class ReservationSlot(models.Model):
room = models.ForeignKey(
Room,
on_delete=models.CASCADE,
related_name="slots",
verbose_name=_("reserved room"),
)
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_("author"))
comment = models.TextField(_("comment"), blank=True, default="")
start_at = models.DateTimeField(_("slot start"), db_index=True)
end_at = models.DateTimeField(_("slot end"))
created_at = models.DateTimeField(auto_now_add=True)
objects = ReservationSlotQuerySet.as_manager()
class Meta:
verbose_name = _("reservation slot")
verbose_name_plural = _("reservation slots")
constraints = [
models.CheckConstraint(
condition=Q(end_at__gt=F("start_at")),
name="reservation_slot_end_after_start",
violation_error_code="start_after_end",
)
]
def __str__(self):
return f"{self.room.name} : {self.start_at} - {self.end_at}"
def clean(self):
super().clean()
if self.end_at is None or self.start_at is None:
# if there is no start or no end, then there is no
# point to check if this perm overlap with another,
# so in this case, don't do the overlap check and let
# Django manage the non-null constraint error.
return
overlapping = ReservationSlot.objects.overlapping_with(self).filter(
room_id=self.room_id
)
if self.id is not None:
overlapping = overlapping.exclude(id=self.id)
if overlapping.exists():
raise ValidationError(_("There is already a reservation on this slot."))

46
reservation/schemas.py Normal file
View File

@@ -0,0 +1,46 @@
from datetime import datetime
from ninja import FilterSchema, ModelSchema, Schema
from pydantic import Field, FutureDatetime
from club.schemas import SimpleClubSchema
from core.schemas import SimpleUserSchema
from reservation.models import ReservationSlot, Room
class RoomFilterSchema(FilterSchema):
club: set[int] | None = Field(None, q="club_id__in")
class RoomSchema(ModelSchema):
class Meta:
model = Room
fields = ["id", "name", "description", "location"]
club: SimpleClubSchema
@staticmethod
def resolve_location(obj: Room):
return obj.get_location_display()
class SlotFilterSchema(FilterSchema):
after: datetime = Field(default=None, q="end_at__gt")
before: datetime = Field(default=None, q="start_at__lt")
room: set[int] | None = None
club: set[int] | None = None
class SlotSchema(ModelSchema):
class Meta:
model = ReservationSlot
fields = ["id", "room", "comment"]
start: datetime = Field(alias="start_at")
end: datetime = Field(alias="end_at")
author: SimpleUserSchema
class UpdateReservationSlotSchema(Schema):
start_at: FutureDatetime
end_at: FutureDatetime

View File

@@ -0,0 +1,136 @@
import {
Calendar,
type DateSelectArg,
type EventDropArg,
type EventSourceFuncArg,
} from "@fullcalendar/core";
import enLocale from "@fullcalendar/core/locales/en-gb";
import frLocale from "@fullcalendar/core/locales/fr";
import interactionPlugin, { type EventResizeDoneArg } from "@fullcalendar/interaction";
import resourceTimelinePlugin from "@fullcalendar/resource-timeline";
import { paginated } from "#core:utils/api";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import {
type ReservationslotFetchSlotsData,
reservableroomFetchRooms,
reservationslotFetchSlots,
reservationslotUpdateSlot,
type SlotSchema,
} from "#openapi";
import type { SlotSelectedEventArg } from "#reservation:reservation/types";
@registerComponent("room-scheduler")
export class RoomScheduler extends inheritHtmlElement("div") {
static observedAttributes = ["locale", "can_edit_slot", "can_create_slot"];
private scheduler: Calendar;
private locale = "en";
private canEditSlot = false;
private canBookSlot = false;
private canDeleteSlot = false;
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
if (name === "locale") {
this.locale = newValue;
}
if (name === "can_edit_slot") {
this.canEditSlot = newValue.toLowerCase() === "true";
}
if (name === "can_create_slot") {
this.canBookSlot = newValue.toLowerCase() === "true";
}
if (name === "can_delete_slot") {
this.canDeleteSlot = newValue.toLowerCase() === "true";
}
}
/**
* Fetch the events displayed in the timeline.
* cf https://fullcalendar.io/docs/events-function
*/
async fetchEvents(fetchInfo: EventSourceFuncArg) {
const res: SlotSchema[] = await paginated(reservationslotFetchSlots, {
query: { after: fetchInfo.startStr, before: fetchInfo.endStr },
} as ReservationslotFetchSlotsData);
return res.map((i) =>
Object.assign(i, {
title: `${i.author.first_name} ${i.author.last_name}`,
resourceId: i.room,
editable: new Date(i.start) > new Date(),
}),
);
}
/**
* Fetch the resources which events are associated with.
* cf https://fullcalendar.io/docs/resources-function
*/
async fetchResources() {
const res = await reservableroomFetchRooms();
return res.data.map((i) => Object.assign(i, { title: i.name, group: i.location }));
}
/**
* Send a request to the API to change
* the start and the duration of a reservation slot
*/
async changeReservation(args: EventDropArg | EventResizeDoneArg) {
const response = await reservationslotUpdateSlot({
// biome-ignore lint/style/useNamingConvention: api is snake_case
path: { slot_id: Number.parseInt(args.event.id) },
// biome-ignore lint/style/useNamingConvention: api is snake_case
body: { start_at: args.event.startStr, end_at: args.event.endStr },
});
if (response.response.ok) {
document.dispatchEvent(new CustomEvent("reservationSlotChanged"));
this.scheduler.refetchEvents();
}
}
selectFreeSlot(infos: DateSelectArg) {
document.dispatchEvent(
new CustomEvent<SlotSelectedEventArg>("timeSlotSelected", {
detail: {
ressource: Number.parseInt(infos.resource.id),
start: infos.startStr,
end: infos.endStr,
},
}),
);
}
connectedCallback() {
super.connectedCallback();
this.scheduler = new Calendar(this.node, {
schedulerLicenseKey: "GPL-My-Project-Is-Open-Source",
initialView: "resourceTimelineDay",
headerToolbar: {
left: "prev,next today",
center: "title",
right: "resourceTimelineDay,resourceTimelineWeek",
},
plugins: [resourceTimelinePlugin, interactionPlugin],
locales: [frLocale, enLocale],
height: "auto",
locale: this.locale,
resourceGroupField: "group",
resourceAreaHeaderContent: gettext("Rooms"),
editable: this.canEditSlot,
snapDuration: "00:15",
eventConstraint: { start: new Date() }, // forbid edition of past events
eventOverlap: false,
eventResourceEditable: false,
refetchResourcesOnNavigate: true,
resourceAreaWidth: "20%",
resources: this.fetchResources,
events: this.fetchEvents,
select: this.selectFreeSlot,
selectOverlap: false,
selectable: this.canBookSlot,
selectConstraint: { start: new Date() },
nowIndicator: true,
eventDrop: this.changeReservation,
eventResize: this.changeReservation,
});
this.scheduler.render();
}
}

View File

@@ -0,0 +1,39 @@
import { AlertMessage } from "#core:utils/alert-message";
import type { SlotSelectedEventArg } from "#reservation:reservation/types";
document.addEventListener("alpine:init", () => {
Alpine.data("slotReservation", () => ({
start: null as string,
end: null as string,
room: null as number,
showForm: false,
init() {
document.addEventListener(
"timeSlotSelected",
(event: CustomEvent<SlotSelectedEventArg>) => {
this.start = event.detail.start.split("+")[0];
this.end = event.detail.end.split("+")[0];
this.room = event.detail.ressource;
this.showForm = true;
this.$nextTick(() => this.$el.scrollIntoView({ behavior: "smooth" })).then();
},
);
},
}));
/**
* Component that will catch events sent from the scheduler
* to display success messages accordingly.
*/
Alpine.data("scheduleMessages", () => ({
alertMessage: new AlertMessage({ defaultDuration: 2000 }),
init() {
document.addEventListener("reservationSlotChanged", (_event: CustomEvent) => {
this.alertMessage.display(gettext("This slot has been successfully moved"), {
success: true,
});
});
},
}));
});

View File

@@ -0,0 +1,5 @@
export interface SlotSelectedEventArg {
start: string;
end: string;
ressource: number;
}

View File

@@ -0,0 +1,39 @@
#slot-reservation {
margin-top: 3em;
display: flex;
flex-direction: column;
justify-content: center;
h3 {
display: block;
margin: auto;
text-align: left;
}
.alert, .error {
display: block;
margin: 1em auto auto;
max-width: 400px;
word-wrap: break-word;
text-wrap: wrap;
}
form {
display: flex;
flex-direction: column;
gap: .5em;
justify-content: center;
.buttons-row {
input[type="submit"], button {
margin: 0;
}
}
textarea {
max-width: unset;
width: 100%;
margin-top: unset;
}
}
}

View File

@@ -0,0 +1,51 @@
<section
id="slot-reservation"
x-data="slotReservation"
x-show="showForm"
hx-target="this"
hx-ext="alpine-morph"
hx-swap="morph"
>
<h3>{% trans %}Book a room{% endtrans %}</h3>
{% set non_field_errors = form.non_field_errors() %}
{% if non_field_errors %}
<div class="alert alert-red">
{% for error in non_field_errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% endif %}
<form
id="slot-reservation-form"
hx-post="{{ url("reservation:make_reservation") }}"
hx-disabled-elt="find input[type='submit']"
>
{% csrf_token %}
<div class="form-group">
{{ form.room.errors }}
{{ form.room.label_tag() }}
{{ form.room|add_attr("x-model=room") }}
</div>
<div class="form-group">
{{ form.start_at.errors }}
{{ form.start_at.label_tag() }}
{{ form.start_at|add_attr("x-model=start") }}
</div>
<div class="form-group">
{{ form.end_at.errors }}
{{ form.end_at.label_tag() }}
{{ form.end_at|add_attr("x-model=end") }}
</div>
<div class="form-group">
{{ form.comment.errors }}
{{ form.comment.label_tag() }}
{{ form.comment }}
</div>
<div class="row gap buttons-row">
<button class="btn btn-grey grow" @click.prevent="showForm = false">
{% trans %}Cancel{% endtrans %}
</button>
<input class="btn btn-blue grow" type="submit">
</div>
</form>
</section>

View File

@@ -0,0 +1,27 @@
{% macro room_detail(room, can_edit, can_delete) %}
<div class="card card-row card-row-m">
<div class="card-content">
<strong class="card-title">{{ room.name }}</strong>
<em>{{ room.get_location_display() }}</em>
<p>{{ room.description|truncate(250) }}</p>
</div>
<div class="card-top-left">
{% if can_edit %}
<a
class="btn btn-grey btn-no-text"
href="{{ url("reservation:room_edit", room_id=room.id) }}"
>
<i class="fa fa-edit"></i>
</a>
{% endif %}
{% if can_delete %}
<a
class="btn btn-red btn-no-text"
href="{{ url("reservation:room_delete", room_id=room.id) }}"
>
<i class="fa fa-trash"></i>
</a>
{% endif %}
</div>
</div>
{% endmacro %}

View File

@@ -0,0 +1,33 @@
{% extends "core/base.jinja" %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/reservation/components/room-scheduler-index.ts") }}"></script>
<script type="module" src="{{ static("bundled/reservation/slot-reservation-index.ts") }}"></script>
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('core/components/calendar.scss') }}">
<link rel="stylesheet" href="{{ static('reservation/reservation.scss') }}">
{% endblock %}
{% block content %}
<h2 class="margin-bottom">{% trans %}Room reservation{% endtrans %}</h2>
<p
x-data="scheduleMessages"
class="alert snackbar"
:class="alertMessage.success ? 'alert-green' : 'alert-red'"
x-show="alertMessage.open"
x-transition.duration.500ms
x-text="alertMessage.content"
></p>
<room-scheduler
locale="{{ LANGUAGE_CODE }}"
can_edit_slot="{{ user.has_perm("reservation.change_reservationslot") }}"
can_create_slot="{{ user.has_perm("reservation.add_reservationslot") }}"
></room-scheduler>
{% if user.has_perm("reservation.add_reservationslot") %}
<p><em>{% trans %}You can book a room by selecting a free slot in the calendar.{% endtrans %}</em></p>
{{ add_slot_fragment }}
{% endif %}
{% endblock %}

View File

View File

@@ -0,0 +1,113 @@
import pytest
from django.contrib.auth.models import Permission
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club
from core.models import User
from reservation.forms import RoomUpdateForm
from reservation.models import Room
@pytest.mark.django_db
class TestFetchRoom:
@pytest.fixture
def user(self):
return baker.make(
User,
user_permissions=[Permission.objects.get(codename="view_room")],
)
def test_fetch_simple(self, client: Client, user: User):
rooms = baker.make(Room, _quantity=3, _bulk_create=True)
client.force_login(user)
response = client.get(reverse("api:fetch_reservable_rooms"))
assert response.status_code == 200
assert response.json() == [
{
"id": room.id,
"name": room.name,
"description": room.description,
"location": room.location,
"club": {"id": room.club.id, "name": room.club.name},
}
for room in rooms
]
def test_nb_queries(self, client: Client, user: User):
client.force_login(user)
with assertNumQueries(5):
# 4 for authentication
# 1 to fetch the actual data
client.get(reverse("api:fetch_reservable_rooms"))
@pytest.mark.django_db
class TestCreateRoom:
def test_ok(self, client: Client):
perm = Permission.objects.get(codename="add_room")
club = baker.make(Club)
client.force_login(
baker.make(User, user_permissions=[perm], groups=[club.board_group])
)
response = client.post(
reverse("reservation:room_create"),
data={"club": club.id, "name": "test", "location": "BELFORT"},
)
assertRedirects(response, reverse("club:tools", kwargs={"club_id": club.id}))
room = Room.objects.last()
assert room is not None
assert room.club == club
assert room.name == "test"
assert room.location == "BELFORT"
def test_permission_denied(self, client: Client):
club = baker.make(Club)
client.force_login(baker.make(User))
response = client.get(reverse("reservation:room_create"))
assert response.status_code == 403
response = client.post(
reverse("reservation:room_create"),
data={"club": club.id, "name": "test", "location": "BELFORT"},
)
assert response.status_code == 403
@pytest.mark.django_db
class TestUpdateRoom:
def test_ok(self, client: Client):
club = baker.make(Club)
room = baker.make(Room, club=club)
client.force_login(baker.make(User, groups=[club.board_group]))
url = reverse("reservation:room_edit", kwargs={"room_id": room.id})
response = client.post(url, data={"name": "test", "location": "BELFORT"})
assertRedirects(response, url)
room.refresh_from_db()
assert room.club == club
assert room.name == "test"
assert room.location == "BELFORT"
def test_permission_denied(self, client: Client):
club = baker.make(Club)
room = baker.make(Room, club=club)
client.force_login(baker.make(User))
url = reverse("reservation:room_edit", kwargs={"room_id": room.id})
response = client.get(url)
assert response.status_code == 403
response = client.post(url, data={"name": "test", "location": "BELFORT"})
assert response.status_code == 403
@pytest.mark.django_db
class TestUpdateRoomForm:
def test_form_club_edition_rights(self):
"""The club field should appear only if the request user can edit it."""
room = baker.make(Room)
perm = Permission.objects.get(codename="change_room")
user_authorized = baker.make(User, user_permissions=[perm])
assert "club" in RoomUpdateForm(request_user=user_authorized).fields
user_forbidden = baker.make(User, groups=[room.club.board_group])
assert "club" not in RoomUpdateForm(request_user=user_forbidden).fields

View File

@@ -0,0 +1,207 @@
from datetime import timedelta
import pytest
from django.contrib.auth.models import Permission
from django.test import Client
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker
from pytest_django.asserts import assertNumQueries
from core.models import User
from reservation.forms import ReservationForm
from reservation.models import ReservationSlot, Room
@pytest.mark.django_db
class TestFetchReservationSlotsApi:
@pytest.fixture
def user(self):
perm = Permission.objects.get(codename="view_reservationslot")
return baker.make(User, user_permissions=[perm])
def test_fetch_simple(self, client: Client, user: User):
slots = baker.make(ReservationSlot, _quantity=5, _bulk_create=True)
client.force_login(user)
response = client.get(reverse("api:fetch_reservation_slots"))
assert response.json()["results"] == [
{
"id": slot.id,
"room": slot.room_id,
"comment": slot.comment,
"start": slot.start_at.isoformat(timespec="milliseconds").replace(
"+00:00", "Z"
),
"end": slot.end_at.isoformat(timespec="milliseconds").replace(
"+00:00", "Z"
),
"author": {
"id": slot.author.id,
"first_name": slot.author.first_name,
"last_name": slot.author.last_name,
"nick_name": slot.author.nick_name,
},
}
for slot in slots
]
def test_nb_queries(self, client: Client, user: User):
client.force_login(user)
with assertNumQueries(5):
# 4 for authentication
# 1 to fetch the actual data
client.get(reverse("api:fetch_reservation_slots"))
@pytest.mark.django_db
class TestUpdateReservationSlotApi:
@pytest.fixture
def user(self):
perm = Permission.objects.get(codename="change_reservationslot")
return baker.make(User, user_permissions=[perm])
@pytest.fixture
def slot(self):
return baker.make(
ReservationSlot,
start_at=now() + timedelta(hours=2),
end_at=now() + timedelta(hours=4),
)
def test_ok(self, client: Client, user: User, slot: ReservationSlot):
client.force_login(user)
new_start = (slot.start_at + timedelta(hours=1)).replace(microsecond=0)
response = client.patch(
reverse("api:change_reservation_slot", kwargs={"slot_id": slot.id}),
{"start_at": new_start, "end_at": new_start + timedelta(hours=2)},
content_type="application/json",
)
assert response.status_code == 200
slot.refresh_from_db()
assert slot.start_at.replace(microsecond=0) == new_start
assert slot.end_at.replace(microsecond=0) == new_start + timedelta(hours=2)
def test_change_past_event(self, client, user: User, slot: ReservationSlot):
"""Test that moving a slot that already began is impossible."""
client.force_login(user)
new_start = now() - timedelta(hours=1)
response = client.patch(
reverse("api:change_reservation_slot", kwargs={"slot_id": slot.id}),
{"start_at": new_start, "end_at": new_start + timedelta(hours=2)},
content_type="application/json",
)
assert response.status_code == 422
def test_move_event_to_occupied_slot(
self, client: Client, user: User, slot: ReservationSlot
):
client.force_login(user)
other_slot = baker.make(
ReservationSlot,
room=slot.room,
start_at=slot.end_at + timedelta(hours=1),
end_at=slot.end_at + timedelta(hours=3),
)
response = client.patch(
reverse("api:change_reservation_slot", kwargs={"slot_id": slot.id}),
{
"start_at": other_slot.start_at - timedelta(hours=1),
"end_at": other_slot.start_at + timedelta(hours=1),
},
content_type="application/json",
)
assert response.status_code == 409
@pytest.mark.django_db
class TestReservationForm:
def test_ok(self):
start = now() + timedelta(hours=2)
end = start + timedelta(hours=1)
form = ReservationForm(
author=baker.make(User),
data={"room": baker.make(Room), "start_at": start, "end_at": end},
)
assert form.is_valid()
@pytest.mark.parametrize(
("start_date", "end_date", "errors"),
[
(
now() - timedelta(hours=2),
now() + timedelta(hours=2),
{"start_at": ["Assurez-vous que cet horodatage est dans le futur"]},
),
(
now() + timedelta(hours=3),
now() + timedelta(hours=2),
{"__all__": ["Le début doit être placé avant la fin"]},
),
],
)
def test_invalid_timedates(self, start_date, end_date, errors):
form = ReservationForm(
author=baker.make(User),
data={"room": baker.make(Room), "start_at": start_date, "end_at": end_date},
)
assert not form.is_valid()
assert form.errors == errors
def test_unavailable_room(self):
room = baker.make(Room)
baker.make(
ReservationSlot,
room=room,
start_at=now() + timedelta(hours=2),
end_at=now() + timedelta(hours=4),
)
form = ReservationForm(
author=baker.make(User),
data={
"room": room,
"start_at": now() + timedelta(hours=1),
"end_at": now() + timedelta(hours=3),
},
)
assert not form.is_valid()
assert form.errors == {
"__all__": ["Il y a déjà une réservation sur ce créneau."]
}
@pytest.mark.django_db
class TestCreateReservationSlot:
@pytest.fixture
def user(self):
perms = Permission.objects.filter(
codename__in=["add_reservationslot", "view_reservationslot"]
)
return baker.make(User, user_permissions=list(perms))
def test_ok(self, client: Client, user: User):
client.force_login(user)
start = now() + timedelta(hours=2)
end = start + timedelta(hours=1)
room = baker.make(Room)
response = client.post(
reverse("reservation:make_reservation"),
{"room": room.id, "start_at": start, "end_at": end},
)
assert response.status_code == 200
assert response.headers.get("HX-Redirect", "") == reverse("reservation:main")
slot = ReservationSlot.objects.filter(room=room).last()
assert slot is not None
assert slot.start_at == start
assert slot.end_at == end
assert slot.author == user
def test_permissions_denied(self, client: Client):
client.force_login(baker.make(User))
start = now() + timedelta(hours=2)
end = start + timedelta(hours=1)
response = client.post(
reverse("reservation:make_reservation"),
{"room": baker.make(Room), "start_at": start, "end_at": end},
)
assert response.status_code == 403

19
reservation/urls.py Normal file
View File

@@ -0,0 +1,19 @@
from django.urls import path
from reservation.views import (
ReservationFragment,
ReservationScheduleView,
RoomCreateView,
RoomDeleteView,
RoomUpdateView,
)
urlpatterns = [
path("", ReservationScheduleView.as_view(), name="main"),
path("room/create/", RoomCreateView.as_view(), name="room_create"),
path("room/<int:room_id>/edit", RoomUpdateView.as_view(), name="room_edit"),
path("room/<int:room_id>/delete", RoomDeleteView.as_view(), name="room_delete"),
path(
"fragment/reservation", ReservationFragment.as_view(), name="make_reservation"
),
]

72
reservation/views.py Normal file
View File

@@ -0,0 +1,72 @@
# Create your views here.
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DeleteView, TemplateView, UpdateView
from club.models import Club
from core.auth.mixins import CanEditMixin
from core.views import UseFragmentsMixin
from core.views.mixins import FragmentMixin
from reservation.forms import ReservationForm, RoomCreateForm, RoomUpdateForm
from reservation.models import ReservationSlot, Room
class ReservationFragment(PermissionRequiredMixin, FragmentMixin, CreateView):
model = ReservationSlot
form_class = ReservationForm
permission_required = "reservation.add_reservationslot"
template_name = "reservation/fragments/create_reservation.jinja"
success_url = reverse_lazy("reservation:main")
reload_on_redirect = True
object = None
def get_form_kwargs(self):
return super().get_form_kwargs() | {"author": self.request.user}
class ReservationScheduleView(PermissionRequiredMixin, UseFragmentsMixin, TemplateView):
template_name = "reservation/schedule.jinja"
permission_required = "reservation.view_reservationslot"
fragments = {"add_slot_fragment": ReservationFragment}
class RoomCreateView(PermissionRequiredMixin, CreateView):
form_class = RoomCreateForm
template_name = "core/create.jinja"
permission_required = "reservation.add_room"
def get_initial(self):
init = super().get_initial()
if "club" in self.request.GET:
club_id = self.request.GET["club"]
if club_id.isdigit() and int(club_id) > 0:
init["club"] = Club.objects.filter(id=int(club_id)).first()
return init
def get_success_url(self):
return reverse("club:tools", kwargs={"club_id": self.object.club_id})
class RoomUpdateView(SuccessMessageMixin, CanEditMixin, UpdateView):
model = Room
pk_url_kwarg = "room_id"
form_class = RoomUpdateForm
template_name = "core/edit.jinja"
success_message = _("%(name)s was updated successfully")
def get_form_kwargs(self):
return super().get_form_kwargs() | {"request_user": self.request.user}
def get_success_url(self):
return self.request.path
class RoomDeleteView(PermissionRequiredMixin, DeleteView):
model = Room
pk_url_kwarg = "room_id"
template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("reservation:room_list")
permission_required = "reservation.delete_room"

View File

@@ -1,6 +1,7 @@
import type TomSelect from "tom-select";
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index.ts";
import { paginated } from "#core:utils/api.ts";
import { exportToHtml } from "#core:utils/globals.ts";
import { History } from "#core:utils/history.ts";
import {
type IdentifiedUserSchema,
@@ -108,225 +109,232 @@ interface ViewerConfig {
/** id of the first picture to load on the page */
firstPictureId: number;
/** if the user is sas admin */
userCanModerate: boolean;
userIsSasAdmin: boolean;
}
/**
* Load user picture page with a nice download bar
**/
document.addEventListener("alpine:init", () => {
Alpine.data("picture_viewer", (config: ViewerConfig) => ({
/**
* All the pictures that can be displayed on this picture viewer
**/
pictures: [] as PictureWithIdentifications[],
/**
* The currently displayed picture
* Default dummy data are pre-loaded to avoid javascript error
* when loading the page at the beginning
* @type PictureWithIdentifications
**/
currentPicture: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
is_moderated: true,
id: null as number,
name: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
display_name: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
compressed_url: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
profile_url: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
full_size_url: "",
owner: "",
date: new Date(),
identifications: [] as IdentifiedUserSchema[],
},
/**
* The picture which will be displayed next if the user press the "next" button
**/
nextPicture: null as PictureWithIdentifications,
/**
* The picture which will be displayed next if the user press the "previous" button
**/
previousPicture: null as PictureWithIdentifications,
/**
* The select2 component used to identify users
**/
selector: undefined as UserAjaxSelect,
/**
* Error message when a moderation operation fails
**/
moderationError: "",
/**
* Method of pushing new url to the browser history
* Used by popstate event and always reset to it's default value when used
**/
pushstate: History.Push,
exportToHtml("loadViewer", (config: ViewerConfig) => {
document.addEventListener("alpine:init", () => {
Alpine.data("picture_viewer", () => ({
/**
* All the pictures that can be displayed on this picture viewer
**/
pictures: [] as PictureWithIdentifications[],
/**
* The currently displayed picture
* Default dummy data are pre-loaded to avoid javascript error
* when loading the page at the beginning
* @type PictureWithIdentifications
**/
currentPicture: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
is_moderated: true,
id: null as number,
name: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
display_name: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
compressed_url: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
profile_url: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
full_size_url: "",
owner: "",
date: new Date(),
identifications: [] as IdentifiedUserSchema[],
},
/**
* The picture which will be displayed next if the user press the "next" button
**/
nextPicture: null as PictureWithIdentifications,
/**
* The picture which will be displayed next if the user press the "previous" button
**/
previousPicture: null as PictureWithIdentifications,
/**
* The select2 component used to identify users
**/
selector: undefined as UserAjaxSelect,
/**
* Error message when a moderation operation fails
**/
moderationError: "",
/**
* Method of pushing new url to the browser history
* Used by popstate event and always reset to it's default value when used
**/
pushstate: History.Push,
async init() {
this.pictures = (
await paginated(picturesFetchPictures, {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
query: { album_id: config.albumId },
} as PicturesFetchPicturesData)
).map(PictureWithIdentifications.fromPicture);
this.selector = this.$refs.search;
this.selector.setFilter((users: UserProfileSchema[]) => {
const resp: UserProfileSchema[] = [];
const ids = [
...(this.currentPicture.identifications || []).map(
(i: IdentifiedUserSchema) => i.user.id,
),
];
for (const user of users) {
if (!ids.includes(user.id)) {
resp.push(user);
async init() {
this.pictures = (
await paginated(picturesFetchPictures, {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
query: { album_id: config.albumId },
} as PicturesFetchPicturesData)
).map(PictureWithIdentifications.fromPicture);
this.selector = this.$refs.search;
this.selector.setFilter((users: UserProfileSchema[]) => {
const resp: UserProfileSchema[] = [];
const ids = [
...(this.currentPicture.identifications || []).map(
(i: IdentifiedUserSchema) => i.user.id,
),
];
for (const user of users) {
if (!ids.includes(user.id)) {
resp.push(user);
}
}
}
return resp;
});
this.currentPicture = this.pictures.find(
(i: PictureSchema) => i.id === config.firstPictureId,
);
this.$watch(
"currentPicture",
(current: PictureSchema, previous: PictureSchema) => {
if (current === previous) {
/* Avoid recursive updates */
return resp;
});
this.currentPicture = this.pictures.find(
(i: PictureSchema) => i.id === config.firstPictureId,
);
this.$watch(
"currentPicture",
(current: PictureSchema, previous: PictureSchema) => {
if (current === previous) {
/* Avoid recursive updates */
return;
}
this.updatePicture();
},
);
window.addEventListener("popstate", async (event) => {
if (!event.state || event.state.sasPictureId === undefined) {
return;
}
this.updatePicture();
},
);
window.addEventListener("popstate", async (event) => {
if (!event.state || event.state.sasPictureId === undefined) {
this.pushstate = History.Replace;
this.currentPicture = this.pictures.find(
(i: PictureSchema) =>
i.id === Number.parseInt(event.state.sasPictureId, 10),
);
});
this.pushstate = History.Replace; /* Avoid first url push */
await this.updatePicture();
},
/**
* Update the page.
* Called when the `currentPicture` property changes.
*
* The url is modified without reloading the page,
* and the previous picture, the next picture and
* the list of identified users are updated.
*/
async updatePicture(): Promise<void> {
const updateArgs = {
data: { sasPictureId: this.currentPicture.id },
unused: "",
url: this.currentPicture.sas_url,
};
if (this.pushstate === History.Replace) {
window.history.replaceState(
updateArgs.data,
updateArgs.unused,
updateArgs.url,
);
this.pushstate = History.Push;
} else {
window.history.pushState(updateArgs.data, updateArgs.unused, updateArgs.url);
}
this.moderationError = "";
const index: number = this.pictures.indexOf(this.currentPicture);
this.previousPicture = this.pictures[index - 1] || null;
this.nextPicture = this.pictures[index + 1] || null;
this.$refs.mainPicture?.addEventListener("load", () => {
// once the current picture is loaded,
// start preloading the next and previous pictures
this.nextPicture?.preload();
this.previousPicture?.preload();
});
if (this.currentPicture.asked_for_removal && config.userIsSasAdmin) {
await Promise.all([
this.currentPicture.loadIdentifications(),
this.currentPicture.loadModeration(),
]);
} else {
await this.currentPicture.loadIdentifications();
}
},
async moderatePicture() {
const res = await picturesModeratePicture({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.currentPicture.id },
});
if (res.error) {
this.moderationError = `${gettext("Couldn't moderate picture")} : ${(res.error as { detail: string }).detail}`;
return;
}
this.pushstate = History.Replace;
this.currentPicture = this.pictures.find(
(i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId, 10),
);
});
this.pushstate = History.Replace; /* Avoid first url push */
await this.updatePicture();
},
this.currentPicture.is_moderated = true;
this.currentPicture.asked_for_removal = false;
},
/**
* Update the page.
* Called when the `currentPicture` property changes.
*
* The url is modified without reloading the page,
* and the previous picture, the next picture and
* the list of identified users are updated.
*/
async updatePicture(): Promise<void> {
const updateArgs = {
data: { sasPictureId: this.currentPicture.id },
unused: "",
url: this.currentPicture.sas_url,
};
if (this.pushstate === History.Replace) {
window.history.replaceState(updateArgs.data, updateArgs.unused, updateArgs.url);
this.pushstate = History.Push;
} else {
window.history.pushState(updateArgs.data, updateArgs.unused, updateArgs.url);
}
this.moderationError = "";
const index: number = this.pictures.indexOf(this.currentPicture);
this.previousPicture = this.pictures[index - 1] || null;
this.nextPicture = this.pictures[index + 1] || null;
this.$refs.mainPicture?.addEventListener("load", () => {
// once the current picture is loaded,
// start preloading the next and previous pictures
this.nextPicture?.preload();
this.previousPicture?.preload();
});
if (this.currentPicture.asked_for_removal && config.userCanModerate) {
await Promise.all([
this.currentPicture.loadIdentifications(),
this.currentPicture.loadModeration(),
]);
} else {
await this.currentPicture.loadIdentifications();
}
},
async moderatePicture() {
const res = await picturesModeratePicture({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.currentPicture.id },
});
if (res.error) {
this.moderationError = `${gettext("Couldn't moderate picture")} : ${(res.error as { detail: string }).detail}`;
return;
}
this.currentPicture.is_moderated = true;
this.currentPicture.asked_for_removal = false;
},
async deletePicture() {
const res = await picturesDeletePicture({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.currentPicture.id },
});
if (res.error) {
this.moderationError = `${gettext("Couldn't delete picture")} : ${(res.error as { detail: string }).detail}`;
return;
}
this.pictures.splice(this.pictures.indexOf(this.currentPicture), 1);
if (this.pictures.length === 0) {
// The deleted picture was the only one in the list.
// As the album is now empty, go back to the parent page
document.location.href = config.albumUrl;
}
this.currentPicture = this.nextPicture || this.previousPicture;
},
/**
* Send the identification request and update the list of identified users.
*/
async submitIdentification(): Promise<void> {
const widget: TomSelect = this.selector.widget;
await picturesIdentifyUsers({
path: {
async deletePicture() {
const res = await picturesDeletePicture({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
picture_id: this.currentPicture.id,
},
body: widget.items.map((i: string) => Number.parseInt(i, 10)),
});
// refresh the identified users list
await this.currentPicture.loadIdentifications({ forceReload: true });
path: { picture_id: this.currentPicture.id },
});
if (res.error) {
this.moderationError = `${gettext("Couldn't delete picture")} : ${(res.error as { detail: string }).detail}`;
return;
}
this.pictures.splice(this.pictures.indexOf(this.currentPicture), 1);
if (this.pictures.length === 0) {
// The deleted picture was the only one in the list.
// As the album is now empty, go back to the parent page
document.location.href = config.albumUrl;
}
this.currentPicture = this.nextPicture || this.previousPicture;
},
// Clear selection and cache of retrieved user so they can be filtered again
widget.clear(false);
widget.clearOptions();
widget.setTextboxValue("");
},
/**
* Send the identification request and update the list of identified users.
*/
async submitIdentification(): Promise<void> {
const widget: TomSelect = this.selector.widget;
await picturesIdentifyUsers({
path: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
picture_id: this.currentPicture.id,
},
body: widget.items.map((i: string) => Number.parseInt(i, 10)),
});
// refresh the identified users list
await this.currentPicture.loadIdentifications({ forceReload: true });
/**
* Check if an identification can be removed by the currently logged user
*/
canBeRemoved(identification: IdentifiedUserSchema): boolean {
return config.userCanModerate || identification.user.id === config.userId;
},
// Clear selection and cache of retrieved user so they can be filtered again
widget.clear(false);
widget.clearOptions();
widget.setTextboxValue("");
},
/**
* Untag a user from the current picture
*/
async removeIdentification(identification: IdentifiedUserSchema): Promise<void> {
const res = await usersidentifiedDeleteRelation({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { relation_id: identification.id },
});
if (!res.error && Array.isArray(this.currentPicture.identifications)) {
this.currentPicture.identifications =
this.currentPicture.identifications.filter(
(i: IdentifiedUserSchema) => i.id !== identification.id,
);
}
},
}));
/**
* Check if an identification can be removed by the currently logged user
*/
canBeRemoved(identification: IdentifiedUserSchema): boolean {
return config.userIsSasAdmin || identification.user.id === config.userId;
},
/**
* Untag a user from the current picture
*/
async removeIdentification(identification: IdentifiedUserSchema): Promise<void> {
const res = await usersidentifiedDeleteRelation({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { relation_id: identification.id },
});
if (!res.error && Array.isArray(this.currentPicture.identifications)) {
this.currentPicture.identifications =
this.currentPicture.identifications.filter(
(i: IdentifiedUserSchema) => i.id !== identification.id,
);
}
},
}));
});
});

View File

@@ -17,8 +17,10 @@
{% from "sas/macros.jinja" import print_path %}
{% set user_is_sas_admin = user.is_root or user.is_in_group(pk = settings.SITH_GROUP_SAS_ADMIN_ID) %}
{% block content %}
<main x-data="picture_viewer(config)">
<main x-data="picture_viewer">
<code>
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album) }} <span x-text="currentPicture.name"></span>
</code>
@@ -48,13 +50,15 @@
It will be hidden to other users until it has been moderated.
{% endtrans %}
</p>
{% if user.has_perm("sas.moderate_sasfile") %}
{% if user_is_sas_admin %}
<template x-if="currentPicture.asked_for_removal">
<div>
<h5>{% trans %}The following issues have been raised:{% endtrans %}</h5>
<template x-for="req in (currentPicture.moderationRequests ?? [])" :key="req.id">
<div>
<h6 x-text="`${req.author.first_name} ${req.author.last_name}`"></h6>
<h6
x-text="`${req.author.first_name} ${req.author.last_name}`"
></h6>
<i x-text="Intl.DateTimeFormat(
'{{ LANGUAGE_CODE }}',
{dateStyle: 'long', timeStyle: 'short'}
@@ -66,7 +70,7 @@
</template>
{% endif %}
</div>
{% if user.has_perm("sas.moderate_sasfile") %}
{% if user_is_sas_admin %}
<div class="alert-aside">
<button class="btn btn-blue" @click="moderatePicture()">
{% trans %}Moderate{% endtrans %}
@@ -200,13 +204,16 @@
{% endblock %}
{% block script %}
{{ super() }}
<script>
const config = {
albumId: {{ album.id }},
albumUrl: "{{ album.get_absolute_url() }}",
firstPictureId: {{ picture.id }}, {# id of the first picture to show after page load #}
userId: {{ user.id }},
userCanModerate: {{ user.has_perm("sas.moderate_sasfile")|tojson }}
}
window.addEventListener("DOMContentLoaded", () => {
loadViewer({
albumId: {{ album.id }} ,
albumUrl: "{{ album.get_absolute_url() }}",
firstPictureId: {{ picture.id }}, {# id of the first picture to show after page load #}
userId: {{ user.id }},
userIsSasAdmin: {{ user_is_sas_admin|tojson }}
});
})
</script>
{% endblock %}

View File

@@ -161,22 +161,16 @@ class TestSasModeration(TestCase):
assert len(res.context_data["pictures"]) == 1
assert res.context_data["pictures"][0] == self.to_moderate
res = self.client.post(
reverse("sas:moderation"),
data={"album_id": self.to_moderate.id, "picture_id": self.to_moderate.id},
)
def test_moderation_page_forbidden(self):
self.client.force_login(self.simple_user)
res = self.client.get(reverse("sas:moderation"))
assert res.status_code == 403
def test_moderate_album(self):
self.client.force_login(self.moderator)
url = reverse("sas:moderation")
album = baker.make(
Album, is_moderated=False, parent_id=settings.SITH_SAS_ROOT_DIR_ID
)
res = self.client.post(url, data={"album_id": album.id, "moderate": ""})
assertRedirects(res, url)
album.refresh_from_db()
assert album.is_moderated
def test_moderate_picture(self):
self.client.force_login(self.moderator)
res = self.client.get(

View File

@@ -15,10 +15,10 @@
from typing import Any
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied
from django.db.models import Count, OuterRef, Subquery
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.utils.safestring import SafeString
from django.views.generic import CreateView, DetailView, TemplateView
@@ -191,21 +191,26 @@ class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView):
# Admin views
class ModerationView(PermissionRequiredMixin, TemplateView):
class ModerationView(TemplateView):
template_name = "sas/moderation.jinja"
permission_required = "sas.moderate_sasfile"
def get(self, request, *args, **kwargs):
if request.user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return super().get(request, *args, **kwargs)
raise PermissionDenied
def post(self, request, *args, **kwargs):
if "album_id" not in request.POST:
raise Http404
album = get_object_or_404(Album, pk=request.POST["album_id"])
if "moderate" in request.POST:
album.moderator = request.user
album.is_moderated = True
album.save()
elif "delete" in request.POST:
album.delete()
return redirect(self.request.path)
if request.user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
album = get_object_or_404(Album, pk=request.POST["album_id"])
if "moderate" in request.POST:
album.moderator = request.user
album.is_moderated = True
album.save()
elif "delete" in request.POST:
album.delete()
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)

View File

@@ -123,6 +123,7 @@ INSTALLED_APPS = (
"trombi",
"matmat",
"pedagogy",
"reservation",
"galaxy",
"antispam",
"timetable",
@@ -274,7 +275,7 @@ LOGGING = {
# Internationalization
# https://docs.djangoproject.com/en/1.8/topics/i18n/
LANGUAGE_CODE = "fr-FR"
LANGUAGE_CODE = "fr"
LANGUAGES = [("en", _("English")), ("fr", _("French"))]
@@ -355,6 +356,7 @@ SITH_TWITTER = "@ae_utbm"
# AE configuration
SITH_MAIN_CLUB_ID = env.int("SITH_MAIN_CLUB_ID", default=1)
SITH_PDF_CLUB_ID = env.int("SITH_PDF_CLUB_ID", default=2)
SITH_LAUNDERETTE_CLUB_ID = env.int("SITH_LAUNDERETTE_CLUB_ID", default=84)
# Main root for club pages
SITH_CLUB_ROOT_PAGE = "clubs"
@@ -482,6 +484,13 @@ SITH_LOG_OPERATION_TYPE = [
SITH_PEDAGOGY_UTBM_API = "https://extranet1.utbm.fr/gpedago/api/guide"
SITH_ECOCUP_CONS = env.int("SITH_ECOCUP_CONS", default=1151)
SITH_ECOCUP_DECO = env.int("SITH_ECOCUP_DECO", default=1152)
# The limit is the maximum difference between cons and deco possible for a customer
SITH_ECOCUP_LIMIT = 3
# Defines pagination for cash summary
SITH_COUNTER_CASH_SUMMARY_LENGTH = 50
@@ -504,6 +513,7 @@ SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER = env.int(
SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS = env.int(
"SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS", default=2
)
SITH_PRODUCTTYPE_SUBSCRIPTION = env.int("SITH_PRODUCTTYPE_SUBSCRIPTION", default=2)
# Number of weeks before the end of a subscription when the subscriber can resubscribe
SITH_SUBSCRIPTION_END = 10
@@ -542,27 +552,27 @@ SITH_SUBSCRIPTIONS = {
# Discount subscriptions
"un-semestre-reduction": {
"name": _("One semester (-20%)"),
"price": 16,
"price": 12,
"duration": 1,
},
"deux-semestres-reduction": {
"name": _("Two semesters (-20%)"),
"price": 28,
"price": 22,
"duration": 2,
},
"cursus-tronc-commun-reduction": {
"name": _("Common core cursus (-20%)"),
"price": 48,
"price": 36,
"duration": 4,
},
"cursus-branche-reduction": {
"name": _("Branch cursus (-20%)"),
"price": 48,
"price": 36,
"duration": 6,
},
"cursus-alternant-reduction": {
"name": _("Alternating cursus (-20%)"),
"price": 28,
"price": 24,
"duration": 6,
},
# CA special offer
@@ -574,6 +584,35 @@ SITH_SUBSCRIPTIONS = {
# To be completed....
}
SITH_CLUB_ROLES_ID = {
"President": 10,
"Vice-President": 9,
"Treasurer": 7,
"Communication supervisor": 5,
"Secretary": 4,
"IT supervisor": 3,
"Board member": 2,
"Active member": 1,
"Curious": 0,
}
SITH_CLUB_ROLES = {
10: _("President"),
9: _("Vice-President"),
7: _("Treasurer"),
5: _("Communication supervisor"),
4: _("Secretary"),
3: _("IT supervisor"),
2: _("Board member"),
1: _("Active member"),
0: _("Curious"),
}
# This corresponds to the maximum role a user can freely subscribe to
# In this case, SITH_MAXIMUM_FREE_ROLE=1 means that a user can
# set himself as "Membre actif" or "Curieux", but not higher
SITH_MAXIMUM_FREE_ROLE = 1
# Minutes to timeout the logged barmen
SITH_BARMAN_TIMEOUT = 30

View File

@@ -49,6 +49,10 @@ urlpatterns = [
path("trombi/", include(("trombi.urls", "trombi"), namespace="trombi")),
path("matmatronch/", include(("matmat.urls", "matmat"), namespace="matmat")),
path("pedagogy/", include(("pedagogy.urls", "pedagogy"), namespace="pedagogy")),
path(
"reservation/",
include(("reservation.urls", "reservation"), namespace="reservation"),
),
path("admin/", admin.site.urls),
path("i18n/", include("django.conf.urls.i18n")),
path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"),

View File

@@ -4,7 +4,7 @@ from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from club.models import Club, ClubRole, Membership
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
from core.models import User
@@ -15,8 +15,7 @@ class TestSubscriptionPermission(TestCase):
cls.user: User = subscriber_user.make()
cls.admin = baker.make(User, is_superuser=True)
cls.club = baker.make(Club)
role = baker.make(ClubRole, club=cls.club, is_board=True)
baker.make(Membership, user=cls.user, club=cls.club, role=role)
baker.make(Membership, user=cls.user, club=cls.club, role=7)
def test_give_permission(self):
self.client.force_login(self.admin)

View File

@@ -23,6 +23,7 @@
from datetime import date
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
@@ -151,12 +152,10 @@ class TrombiUser(models.Model):
def make_memberships(self):
self.memberships.all().delete()
for m in (
self.user.memberships.filter(role__is_board=True)
.select_related("role")
.order_by("end_date")
):
role = m.role.name
for m in self.user.memberships.filter(
role__gt=settings.SITH_MAXIMUM_FREE_ROLE
).order_by("end_date"):
role = str(settings.SITH_CLUB_ROLES[m.role])
if m.description:
role += " (%s)" % m.description
end_date = get_semester_code(m.end_date) if m.end_date else ""

View File

@@ -18,7 +18,8 @@
"#core:*": ["./core/static/bundled/*"],
"#pedagogy:*": ["./pedagogy/static/bundled/*"],
"#counter:*": ["./counter/static/bundled/*"],
"#com:*": ["./com/static/bundled/*"]
"#com:*": ["./com/static/bundled/*"],
"#reservation:*": ["./reservation/static/bundled/*"]
}
}
}

625
uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,14 @@
// biome-ignore lint/correctness/noNodejsModules: this is backend side
import { parse, resolve } from "node:path";
import inject from "@rollup/plugin-inject";
import { glob } from "glob";
import { visualizer } from "rollup-plugin-visualizer";
import {
type AliasOptions,
defineConfig,
type PluginOption,
type Rollup,
type UserConfig,
} from "vite";
import type { Rollup } from "vite";
import { type AliasOptions, defineConfig, type UserConfig } from "vite";
import tsconfig from "./tsconfig.json";
const outDir = resolve(__dirname, "./staticfiles/generated/bundled");
const vendored = resolve(outDir, "vendored");
const nodeModules = resolve(__dirname, "node_modules");
const collectedFiles = glob.sync(
"./!(static)/static/bundled/**/*?(-)index.?(m)[j|t]s?(x)",
);
@@ -45,6 +42,7 @@ function getRelativeAssetPath(path: string): string {
return relativePath.join("/");
}
// biome-ignore lint/style/noDefaultExport: this is recommended by documentation
export default defineConfig((config: UserConfig) => {
return {
base: "/static/bundled/",
@@ -88,7 +86,6 @@ export default defineConfig((config: UserConfig) => {
Alpine: "alpinejs",
htmx: "htmx.org",
}),
visualizer({ filename: ".bundle-size-report.html" }) as PluginOption,
],
} satisfies UserConfig;
});