6 Commits

Author SHA1 Message Date
imperosol
c76ff92c30 Better management of roles in ClubMemberForm 2025-09-13 18:38:59 +02:00
imperosol
63125edeaa feat: success message on membership creation 2025-09-13 18:38:59 +02:00
imperosol
6c81f5f4eb improve new member form style 2025-09-13 18:38:59 +02:00
imperosol
872e1df750 fix: N+1 queries on old club members view 2025-09-13 18:38:59 +02:00
imperosol
aaa3e39e88 HTMXify club members page 2025-09-13 18:38:59 +02:00
imperosol
984709f837 feat: MembershipQuerySet.editable_by method 2025-09-13 13:07:20 +02:00
18 changed files with 492 additions and 291 deletions

View File

@@ -26,12 +26,16 @@ from django import forms
from django.conf import settings from django.conf import settings
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from club.models import Club, Mailing, MailingSubscription, Membership from club.models import Club, Mailing, MailingSubscription, Membership
from core.models import User from core.models import User
from core.views.forms import SelectDate, SelectDateTime from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import AutoCompleteSelectMultipleUser from core.views.widgets.ajax_select import (
AutoCompleteSelectMultipleUser,
AutoCompleteSelectUser,
)
from counter.models import Counter, Selling from counter.models import Counter, Selling
@@ -188,70 +192,68 @@ class SellingsForm(forms.Form):
) )
class ClubMemberForm(forms.Form): class ClubOldMemberForm(forms.Form):
members_old = forms.ModelMultipleChoiceField(
Membership.objects.none(),
label=_("Mark as old"),
widget=forms.CheckboxSelectMultiple,
required=False,
)
def __init__(self, *args, user: User, club: Club, **kwargs):
super().__init__(*args, **kwargs)
self.fields["members_old"].queryset = (
Membership.objects.ongoing().filter(club=club).editable_by(user)
)
class ClubMemberForm(forms.ModelForm):
"""Form handling the members of a club.""" """Form handling the members of a club."""
error_css_class = "error" error_css_class = "error"
required_css_class = "required" required_css_class = "required"
users = forms.ModelMultipleChoiceField( class Meta:
label=_("Users to add"), model = Membership
help_text=_("Search users to add (one or more)."), fields = ["user", "role", "description"]
required=False, widgets = {"user": AutoCompleteSelectUser}
widget=AutoCompleteSelectMultipleUser,
queryset=User.objects.all(),
)
def __init__(self, *args, **kwargs): def __init__(self, *args, club: Club, request_user: User, **kwargs):
self.club = kwargs.pop("club") self.club = club
self.request_user = kwargs.pop("request_user") self.request_user = request_user
self.club_members = kwargs.pop("club_members", None)
if not self.club_members:
self.club_members = self.club.members.ongoing().order_by("-role").all()
self.request_user_membership = self.club.get_membership_for(self.request_user) self.request_user_membership = self.club.get_membership_for(self.request_user)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["role"].required = True
# Using a ModelForm binds too much the form with the model and we don't want that self.fields["role"].choices = [
# We want the view to process the model creation since they are multiple users (value, name)
# We also want the form to handle bulk deletion for value, name in settings.SITH_CLUB_ROLES.items()
self.fields.update( if value <= self.max_available_role
forms.fields_for_model(
Membership,
fields=("role", "start_date", "description"),
widgets={"start_date": SelectDate},
)
)
# Role is required only if users is specified
self.fields["role"].required = False
# Start date and description are never really required
self.fields["start_date"].required = False
self.fields["description"].required = False
self.fields["users_old"] = forms.ModelMultipleChoiceField(
User.objects.filter(
id__in=[
ms.user.id
for ms in self.club_members
if ms.can_be_edited_by(self.request_user)
] ]
).all(), self.instance.club = club
label=_("Mark as old"),
required=False,
widget=forms.CheckboxSelectMultiple,
)
if not self.request_user.is_root:
self.fields.pop("start_date")
def clean_users(self): @cached_property
"""Check that the user is not trying to add an user already in the club. def max_available_role(self):
"""The greatest role that will be obtainable with this form.
Admins and the club president can attribute any role.
Board members can attribute roles lower than their own.
Other users can attribute curious and member roles.
"""
if self.request_user.has_perm("club.add_subscription"):
return settings.SITH_CLUB_ROLES_ID["President"]
membership = self.request_user_membership
if membership is not None and membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
if membership.role == settings.SITH_CLUB_ROLES_ID["President"]:
return membership.role
return membership.role - 1
return settings.SITH_MAXIMUM_FREE_ROLE
def clean_user(self):
"""Check that the user is not trying to add a user already in the club.
Also check that the user is valid and has a valid subscription. Also check that the user is valid and has a valid subscription.
""" """
cleaned_data = super().clean() user = self.cleaned_data["user"]
users = []
for user in cleaned_data["users"]:
if not user.is_subscribed: if not user.is_subscribed:
raise forms.ValidationError( raise forms.ValidationError(
_("User must be subscriber to take part to a club"), code="invalid" _("User must be subscriber to take part to a club"), code="invalid"
@@ -260,33 +262,24 @@ class ClubMemberForm(forms.Form):
raise forms.ValidationError( raise forms.ValidationError(
_("You can not add the same user twice"), code="invalid" _("You can not add the same user twice"), code="invalid"
) )
users.append(user) return user
return users
def clean(self): def clean(self):
"""Check user rights for adding an user.""" """Check user rights for adding a user."""
cleaned_data = super().clean() cleaned_data = super().clean()
if "role" not in cleaned_data:
if "start_date" in cleaned_data and not cleaned_data["start_date"]:
# Drop start_date if allowed to edition but not specified
cleaned_data.pop("start_date")
if not cleaned_data.get("users"):
# No user to add equals no check needed
return cleaned_data return cleaned_data
if (
if cleaned_data.get("role", "") == "": self.request_user_membership is None
# Role is required if users exists or self.request_user_membership.role <= settings.SITH_MAXIMUM_FREE_ROLE
self.add_error("role", _("You should specify a role")) ) and not self.request_user.has_perm("club.add_membership"):
return cleaned_data raise forms.ValidationError(
_(
request_user = self.request_user "You cannot add other users to a club "
membership = self.request_user_membership "if you are not in the club board."
if not ( ),
cleaned_data["role"] <= settings.SITH_MAXIMUM_FREE_ROLE code="invalid",
or (membership is not None and membership.role >= cleaned_data["role"]) )
or request_user.is_board_member if cleaned_data["role"] > self.max_available_role:
or request_user.is_root
):
raise forms.ValidationError(_("You do not have the permission to do that")) raise forms.ValidationError(_("You do not have the permission to do that"))
return cleaned_data return cleaned_data

View File

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

View File

@@ -30,7 +30,8 @@ from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email from django.core.validators import RegexValidator, validate_email
from django.db import models, transaction 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.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
@@ -200,10 +201,6 @@ class Club(models.Model):
"""Method to see if that object can be edited by the given user.""" """Method to see if that object can be edited by the given user."""
return self.has_rights_in_club(user) return self.has_rights_in_club(user)
def can_be_viewed_by(self, user: User) -> bool:
"""Method to see if that object can be seen by the given user."""
return user.was_subscribed
def get_membership_for(self, user: User) -> Membership | None: def get_membership_for(self, user: User) -> Membership | None:
"""Return the current membership the given user. """Return the current membership the given user.
@@ -243,6 +240,41 @@ class MembershipQuerySet(models.QuerySet):
""" """
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE) return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
def editable_by(self, user: User) -> Self:
"""Filter Memberships that this user can edit.
Users with the `club.change_membership` permission can edit all Membership.
The other users can end :
- their own membership
- if they are board members, memberships with a role lower than their own
For example, let's suppose the following users :
- A : board member
- B : board member
- C : simple member
- D : curious
A will be able to end the memberships of A, C and D ;
C and D will be able to end only their own membership.
"""
if user.has_perm("club.change_membership"):
return self.all()
return self.filter(
Exists(
Membership.objects.filter(
Q(
role__gt=Greatest(
OuterRef("role"), Value(settings.SITH_MAXIMUM_FREE_ROLE)
)
)
| Q(pk=OuterRef("pk")),
user=user,
end_date=None,
club=OuterRef("club"),
)
)
)
def update(self, **kwargs) -> int: def update(self, **kwargs) -> int:
"""Refresh the cache and edit group ownership. """Refresh the cache and edit group ownership.
@@ -319,16 +351,12 @@ class Membership(models.Model):
User, User,
verbose_name=_("user"), verbose_name=_("user"),
related_name="memberships", related_name="memberships",
null=False,
blank=False,
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
club = models.ForeignKey( club = models.ForeignKey(
Club, Club,
verbose_name=_("club"), verbose_name=_("club"),
related_name="members", related_name="members",
null=False,
blank=False,
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
start_date = models.DateField(_("start date"), default=timezone.now) start_date = models.DateField(_("start date"), default=timezone.now)

View File

@@ -0,0 +1,24 @@
#club_members_table {
tbody label {
margin: 0;
padding: 0;
}
}
#add_club_members_form {
fieldset {
display: flex;
flex-direction: row;
column-gap: 2em;
row-gap: 1em;
flex-wrap: wrap;
@media (max-width: 1100px) {
justify-content: space-evenly;
}
.errorlist {
max-width: 300px;
}
}
}

View File

@@ -1,15 +1,30 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import user_profile_link, select_all_checkbox %} {% from 'core/macros.jinja' import user_profile_link, select_all_checkbox %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/core/components/ajax-select-index.ts") }}"></script>
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
<link rel="stylesheet" href="{{ static("core/components/ajax-select.scss") }}">
<link rel="stylesheet" href="{{ static("club/members.scss") }}">
{% endblock %}
{% block content %} {% block content %}
<h2>{% trans %}Club members{% endtrans %}</h2> <h2>{% trans %}Club members{% endtrans %}</h2>
{% if add_member_fragment %}
<br />
<h4>{% trans %}Add a new member{% endtrans %}</h4>
{{ add_member_fragment }}
<br />
{% endif %}
{% if members %} {% if members %}
<form action="{{ url('club:club_members', club_id=club.id) }}" id="users_old" method="post"> <form action="{{ url('club:club_members', club_id=club.id) }}" id="members_old" method="post">
{% csrf_token %} {% csrf_token %}
{% set users_old = dict(form.users_old | groupby("choice_label")) %} {% if can_end_membership %}
{% if users_old %} {{ select_all_checkbox("members_old") }}
{{ select_all_checkbox("users_old") }} <br />
<p></p>
{% endif %} {% endif %}
<table id="club_members_table"> <table id="club_members_table">
<thead> <thead>
@@ -18,7 +33,7 @@
<td>{% trans %}Role{% endtrans %}</td> <td>{% trans %}Role{% endtrans %}</td>
<td>{% trans %}Description{% endtrans %}</td> <td>{% trans %}Description{% endtrans %}</td>
<td>{% trans %}Since{% endtrans %}</td> <td>{% trans %}Since{% endtrans %}</td>
{% if users_old %} {% if can_end_membership %}
<td>{% trans %}Mark as old{% endtrans %}</td> <td>{% trans %}Mark as old{% endtrans %}</td>
{% endif %} {% endif %}
</tr> </tr>
@@ -30,20 +45,24 @@
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td> <td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
<td>{{ m.description }}</td> <td>{{ m.description }}</td>
<td>{{ m.start_date }}</td> <td>{{ m.start_date }}</td>
{% if users_old %} {%- if can_end_membership -%}
<td> <td>
{% set user_old = users_old[m.user.get_display_name()] %} {%- if m.is_editable -%}
{% if user_old %} <label for="id_members_old_{{ loop.index }}"></label>
{{ user_old[0].tag() }} <input
{% endif %} type="checkbox"
name="members_old"
value="{{ m.id }}"
id="id_members_old_{{ loop.index }}"
>
{%- endif -%}
</td> </td>
{% endif %} {%- endif -%}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{{ form.users_old.errors }} {% if can_end_membership %}
{% if users_old %}
<p></p> <p></p>
<input type="submit" name="submit" value="{% trans %}Mark as old{% endtrans %}"> <input type="submit" name="submit" value="{% trans %}Mark as old{% endtrans %}">
{% endif %} {% endif %}
@@ -51,32 +70,4 @@
{% else %} {% else %}
<p>{% trans %}There are no members in this club.{% endtrans %}</p> <p>{% trans %}There are no members in this club.{% endtrans %}</p>
{% endif %} {% endif %}
<form action="{{ url('club:club_members', club_id=club.id) }}" id="add_users" method="post">
{% csrf_token %}
{{ form.non_field_errors() }}
<p>
{{ form.users.errors }}
<label for="{{ form.users.id_for_label }}">{{ form.users.label }} :</label>
{{ form.users }}
<span class="helptext">{{ form.users.help_text }}</span>
</p>
<p>
{{ form.role.errors }}
<label for="{{ form.role.id_for_label }}">{{ form.role.label }} :</label>
{{ form.role }}
</p>
{% if form.start_date %}
<p>
{{ form.start_date.errors }}
<label for="{{ form.start_date.id_for_label }}">{{ form.start_date.label }} :</label>
{{ form.start_date }}
</p>
{% endif %}
<p>
{{ form.description.errors }}
<label for="{{ form.description.id_for_label }}">{{ form.description.label }} :</label>
{{ form.description }}
</p>
<p><input type="submit" value="{% trans %}Add{% endtrans %}" /></p>
</form>
{% endblock %} {% endblock %}

View File

@@ -5,20 +5,22 @@
<h2>{% trans %}Club old members{% endtrans %}</h2> <h2>{% trans %}Club old members{% endtrans %}</h2>
<table> <table>
<thead> <thead>
<tr>
<td>{% trans %}User{% endtrans %}</td> <td>{% trans %}User{% endtrans %}</td>
<td>{% trans %}Role{% endtrans %}</td> <td>{% trans %}Role{% endtrans %}</td>
<td>{% trans %}Description{% endtrans %}</td> <td>{% trans %}Description{% endtrans %}</td>
<td>{% trans %}From{% endtrans %}</td> <td>{% trans %}From{% endtrans %}</td>
<td>{% trans %}To{% endtrans %}</td> <td>{% trans %}To{% endtrans %}</td>
</tr>
</thead> </thead>
<tbody> <tbody>
{% for m in club.members.exclude(end_date=None).order_by('-role', 'description', '-end_date').all() %} {% for member in old_members %}
<tr> <tr>
<td>{{ user_profile_link(m.user) }}</td> <td>{{ user_profile_link(member.user) }}</td>
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td> <td>{{ settings.SITH_CLUB_ROLES[member.role] }}</td>
<td>{{ m.description }}</td> <td>{{ member.description }}</td>
<td>{{ m.start_date }}</td> <td>{{ member.start_date }}</td>
<td>{{ m.end_date }}</td> <td>{{ member.end_date }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@@ -0,0 +1,44 @@
{% if messages %}
<div x-data="{show_alert: true}" class="alert alert-green" x-show="show_alert" x-transition>
<span class="alert-main">
{% for message in messages %}
{% if message.level_tag == "success" %}
{{ message }}
{% endif %}
{% endfor %}
</span>
<span class="clickable" @click="show_alert = false">
<i class="fa fa-close"></i>
</span>
</div>
{% endif %}
<form
hx-post="{{ url('club:club_new_members', club_id=club.id) }}"
hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML"
id="add_club_members_form"
>
{% csrf_token %}
{{ form.non_field_errors() }}
<fieldset>
<div>
{{ form.user.label_tag()}}
<span class="helptext">{{ form.user.help_text }}</span>
{{ form.user }}
{{ form.user.errors }}
</div>
<div>
{{ form.role.label_tag()}}
{{ form.role }}
{{ form.role.errors }}
</div>
<div>
{{ form.description.label_tag()}}
{{ form.description }}
{{ form.description.errors }}
</div>
</fieldset>
<button type="submit" class="btn btn-blue">
<i class="fa fa-user-plus"></i> {% trans %}Add{% endtrans %}</button>
</form>

View File

@@ -43,6 +43,9 @@ class TestClub(TestCase):
cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID) cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID)
cls.club = baker.make(Club) cls.club = baker.make(Club)
cls.new_members_url = reverse(
"club:club_new_members", kwargs={"club_id": cls.club.id}
)
cls.members_url = reverse("club:club_members", kwargs={"club_id": cls.club.id}) cls.members_url = reverse("club:club_members", kwargs={"club_id": cls.club.id})
a_month_ago = now() - timedelta(days=30) a_month_ago = now() - timedelta(days=30)
yesterday = now() - timedelta(days=1) yesterday = now() - timedelta(days=1)

View File

@@ -1,13 +1,16 @@
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Max from django.db.models import Max
from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import localdate, localtime, now from django.utils.timezone import localdate, localtime, now
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertRedirects
from club.forms import ClubMemberForm from club.forms import ClubMemberForm
from club.models import Membership from club.models import Club, Membership
from club.tests.base import TestClub from club.tests.base import TestClub
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User from core.models import AnonymousUser, User
@@ -137,6 +140,38 @@ class TestMembershipQuerySet(TestClub):
assert set(user.groups.all()).isdisjoint(club_groups) assert set(user.groups.all()).isdisjoint(club_groups)
class TestMembershipEditableBy(TestCase):
@classmethod
def setUpTestData(cls):
Membership.objects.all().delete()
cls.club_a, cls.club_b = baker.make(Club, _quantity=2)
cls.memberships = [
*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):
perm = Permission.objects.get(codename="change_membership")
user = baker.make(User, user_permissions=[perm])
qs = Membership.objects.editable_by(user).values_list("id", flat=True)
assert set(qs) == set(Membership.objects.values_list("id", flat=True))
def test_simple_subscriber_user(self):
user = subscriber_user.make()
assert not Membership.objects.editable_by(user).exists()
def test_board_member(self):
# a board member can end lower memberships and its own one
user = self.memberships[2].user
qs = Membership.objects.editable_by(user).values_list("id", flat=True)
expected = {self.memberships[2].id, self.memberships[3].id}
assert set(qs) == expected
class TestMembership(TestClub): class TestMembership(TestClub):
def assert_membership_started_today(self, user: User, role: int): def assert_membership_started_today(self, user: User, role: int):
"""Assert that the given membership is active and started today.""" """Assert that the given membership is active and started today."""
@@ -151,7 +186,7 @@ class TestMembership(TestClub):
def assert_membership_ended_today(self, user: User): def assert_membership_ended_today(self, user: User):
"""Assert that the given user have a membership which ended today.""" """Assert that the given user have a membership which ended today."""
today = localtime(now()).date() today = localdate()
assert user.memberships.filter(club=self.club, end_date=today).exists() assert user.memberships.filter(club=self.club, end_date=today).exists()
assert self.club.get_membership_for(user) is None assert self.club.get_membership_for(user) is None
@@ -160,7 +195,9 @@ class TestMembership(TestClub):
cannot see the page. cannot see the page.
""" """
response = self.client.post(self.members_url) response = self.client.post(self.members_url)
assert response.status_code == 403 assertRedirects(
response, reverse("core:login", query={"next": self.members_url})
)
self.client.force_login(self.public) self.client.force_login(self.public)
response = self.client.post(self.members_url) response = self.client.post(self.members_url)
@@ -171,7 +208,9 @@ class TestMembership(TestClub):
information are displayed. information are displayed.
""" """
self.client.force_login(self.simple_board_member) self.client.force_login(self.simple_board_member)
response = self.client.get(self.members_url) response = self.client.get(
reverse("club:club_members", kwargs={"club_id": self.club.id})
)
assert response.status_code == 200 assert response.status_code == 200
soup = BeautifulSoup(response.text, "lxml") soup = BeautifulSoup(response.text, "lxml")
table = soup.find("table", id="club_members_table") table = soup.find("table", id="club_members_table")
@@ -197,59 +236,45 @@ class TestMembership(TestClub):
assert cols[2].text == membership.description assert cols[2].text == membership.description
assert cols[3].text == str(membership.start_date) assert cols[3].text == str(membership.start_date)
if membership.role <= 3: # 3 is the role of simple_board_member 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") form_input = cols[4].find("input")
expected_attrs = { expected_attrs = {
"type": "checkbox", "type": "checkbox",
"name": "users_old", "name": "members_old",
"value": str(user.id), "value": str(membership.id),
} }
assert form_input.attrs.items() >= expected_attrs.items() assert form_input.attrs.items() >= expected_attrs.items()
else: else:
assert cols[4].find_all() == [] assert cols[4].find_all() == []
def test_root_add_one_club_member(self): def test_root_add_one_club_member(self):
"""Test that root users can add members to clubs, one at a time.""" """Test that root users can add members to clubs"""
self.client.force_login(self.root) self.client.force_login(self.root)
response = self.client.post( response = self.client.post(
self.members_url, self.new_members_url, {"user": self.subscriber.id, "role": 3}
{"users": [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.assertRedirects(response, self.members_url)
self.subscriber.refresh_from_db() self.subscriber.refresh_from_db()
self.assert_membership_started_today(self.subscriber, role=3) self.assert_membership_started_today(self.subscriber, role=3)
def test_root_add_multiple_club_member(self):
"""Test that root users can add multiple members at once to clubs."""
self.client.force_login(self.root)
response = self.client.post(
self.members_url,
{
"users": (self.subscriber.id, self.krophil.id),
"role": 3,
},
)
self.assertRedirects(response, self.members_url)
self.subscriber.refresh_from_db()
self.assert_membership_started_today(self.subscriber, role=3)
self.assert_membership_started_today(self.krophil, role=3)
def test_add_unauthorized_members(self): def test_add_unauthorized_members(self):
"""Test that users who are not currently subscribed """Test that users who are not currently subscribed
cannot be members of clubs. cannot be members of clubs.
""" """
for user in self.public, self.old_subscriber: for user in self.public, self.old_subscriber:
form = ClubMemberForm( form = ClubMemberForm(
data={"users": [user.id], "role": 1}, data={"user": user.id, "role": 1},
request_user=self.root, request_user=self.root,
club=self.club, club=self.club,
) )
assert not form.is_valid() assert not form.is_valid()
assert form.errors == { assert form.errors == {
"users": [ "user": ["L'utilisateur doit être cotisant pour faire partie d'un club"]
"L'utilisateur doit être cotisant pour faire partie d'un club"
]
} }
def test_add_members_already_members(self): def test_add_members_already_members(self):
@@ -282,15 +307,15 @@ class TestMembership(TestClub):
max_id = User.objects.aggregate(id=Max("id"))["id"] max_id = User.objects.aggregate(id=Max("id"))["id"]
for members in [max_id + 1], [max_id + 1, self.subscriber.id]: for members in [max_id + 1], [max_id + 1, self.subscriber.id]:
form = ClubMemberForm( form = ClubMemberForm(
data={"users": members, "role": 1}, data={"user": members, "role": 1},
request_user=self.root, request_user=self.root,
club=self.club, club=self.club,
) )
assert not form.is_valid() assert not form.is_valid()
assert form.errors == { assert form.errors == {
"users": [ "user": [
"Sélectionnez un choix valide. " "Sélectionnez un choix valide. "
f"{max_id + 1} n\u2019en fait pas partie." "Ce choix ne fait pas partie de ceux disponibles."
] ]
} }
self.club.refresh_from_db() self.club.refresh_from_db()
@@ -303,10 +328,12 @@ class TestMembership(TestClub):
nb_subscriber_memberships = self.subscriber.memberships.count() nb_subscriber_memberships = self.subscriber.memberships.count()
self.client.force_login(president) self.client.force_login(president)
response = self.client.post( response = self.client.post(
self.members_url, self.new_members_url, {"user": self.subscriber.id, "role": 9}
{"users": self.subscriber.id, "role": 9}, )
assert response.status_code == 200
assert response.headers.get("HX-Redirect", "") == reverse(
"club:club_members", kwargs={"club_id": self.club.id}
) )
self.assertRedirects(response, self.members_url)
self.club.refresh_from_db() self.club.refresh_from_db()
self.subscriber.refresh_from_db() self.subscriber.refresh_from_db()
assert self.club.members.count() == nb_club_membership + 1 assert self.club.members.count() == nb_club_membership + 1
@@ -318,7 +345,7 @@ class TestMembership(TestClub):
a membership with a greater role than its own. a membership with a greater role than its own.
""" """
form = ClubMemberForm( form = ClubMemberForm(
data={"users": [self.subscriber.id], "role": 10}, data={"user": self.subscriber.id, "role": 10},
request_user=self.simple_board_member, request_user=self.simple_board_member,
club=self.club, club=self.club,
) )
@@ -326,7 +353,7 @@ class TestMembership(TestClub):
assert not form.is_valid() assert not form.is_valid()
assert form.errors == { assert form.errors == {
"__all__": ["Vous n'avez pas la permission de faire cela"] "role": ["Sélectionnez un choix valide. 10 n\u2019en fait pas partie."]
} }
self.club.refresh_from_db() self.club.refresh_from_db()
assert nb_memberships == self.club.members.count() assert nb_memberships == self.club.members.count()
@@ -334,23 +361,18 @@ class TestMembership(TestClub):
def test_add_member_without_role(self): def test_add_member_without_role(self):
"""Test that trying to add members without specifying their role fails.""" """Test that trying to add members without specifying their role fails."""
self.client.force_login(self.root)
form = ClubMemberForm( form = ClubMemberForm(
data={"users": [self.subscriber.id]}, data={"user": self.subscriber.id}, request_user=self.root, club=self.club
request_user=self.simple_board_member,
club=self.club,
) )
assert not form.is_valid() assert not form.is_valid()
assert form.errors == {"role": ["Vous devez choisir un rôle"]} assert form.errors == {"role": ["Ce champ est obligatoire."]}
def test_end_membership_self(self): def test_end_membership_self(self):
"""Test that a member can end its own membership.""" """Test that a member can end its own membership."""
self.client.force_login(self.simple_board_member) self.client.force_login(self.simple_board_member)
self.client.post( membership = self.club.members.get(end_date=None, user=self.simple_board_member)
self.members_url, self.client.post(self.members_url, {"members_old": [membership.id]})
{"users_old": self.simple_board_member.id},
)
self.simple_board_member.refresh_from_db() self.simple_board_member.refresh_from_db()
self.assert_membership_ended_today(self.simple_board_member) self.assert_membership_ended_today(self.simple_board_member)
@@ -358,15 +380,13 @@ class TestMembership(TestClub):
"""Test that board members of the club can end memberships """Test that board members of the club can end memberships
of users with lower roles. of users with lower roles.
""" """
# remainder : simple_board_member has role 3, president has role 10, richard has role 1 # reminder : simple_board_member has role 3
self.client.force_login(self.simple_board_member) self.client.force_login(self.simple_board_member)
response = self.client.post( membership = baker.make(Membership, club=self.club, role=2, end_date=None)
self.members_url, response = self.client.post(self.members_url, {"members_old": [membership.id]})
{"users_old": self.richard.id},
)
self.assertRedirects(response, self.members_url) self.assertRedirects(response, self.members_url)
self.club.refresh_from_db() self.club.refresh_from_db()
self.assert_membership_ended_today(self.richard) self.assert_membership_ended_today(membership.user)
def test_end_membership_higher_role(self): def test_end_membership_higher_role(self):
"""Test that board members of the club cannot end memberships """Test that board members of the club cannot end memberships
@@ -374,46 +394,30 @@ class TestMembership(TestClub):
""" """
membership = self.president.memberships.filter(club=self.club).first() membership = self.president.memberships.filter(club=self.club).first()
self.client.force_login(self.simple_board_member) self.client.force_login(self.simple_board_member)
self.client.post( self.client.post(self.members_url, {"members_old": [membership.id]})
self.members_url,
{"users_old": self.president.id},
)
self.club.refresh_from_db() self.club.refresh_from_db()
new_membership = self.club.get_membership_for(self.president) new_membership = self.club.get_membership_for(self.president)
assert new_membership is not None assert new_membership is not None
assert new_membership == membership assert new_membership == membership
membership = self.president.memberships.filter(club=self.club).first() membership.refresh_from_db()
assert membership.end_date is None assert membership.end_date is None
def test_end_membership_as_main_club_board(self): def test_end_membership_with_permission(self):
"""Test that board members of the main club can end the membership """Test that users with permission can end any membership."""
of anyone.
"""
# make subscriber a board member # make subscriber a board member
subscriber = subscriber_user.make()
Membership.objects.create(club=self.ae, user=subscriber, role=3)
nb_memberships = self.club.members.ongoing().count() nb_memberships = self.club.members.ongoing().count()
self.client.force_login(subscriber) self.client.force_login(
subscriber_user.make(
user_permissions=[Permission.objects.get(codename="change_membership")]
)
)
president_membership = self.club.president
response = self.client.post( response = self.client.post(
self.members_url, self.members_url, {"members_old": [president_membership.id]}
{"users_old": self.president.id},
) )
self.assertRedirects(response, self.members_url) self.assertRedirects(response, self.members_url)
self.assert_membership_ended_today(self.president) self.assert_membership_ended_today(president_membership.user)
assert self.club.members.ongoing().count() == nb_memberships - 1
def test_end_membership_as_root(self):
"""Test that root users can end the membership of anyone."""
nb_memberships = self.club.members.ongoing().count()
self.client.force_login(self.root)
response = self.client.post(
self.members_url,
{"users_old": [self.president.id]},
)
self.assertRedirects(response, self.members_url)
self.assert_membership_ended_today(self.president)
assert self.club.members.ongoing().count() == nb_memberships - 1 assert self.club.members.ongoing().count() == nb_memberships - 1
def test_end_membership_as_foreigner(self): def test_end_membership_as_foreigner(self):
@@ -421,14 +425,11 @@ class TestMembership(TestClub):
nb_memberships = self.club.members.count() nb_memberships = self.club.members.count()
membership = self.richard.memberships.filter(club=self.club).first() membership = self.richard.memberships.filter(club=self.club).first()
self.client.force_login(self.subscriber) self.client.force_login(self.subscriber)
self.client.post( self.client.post(self.members_url, {"members_old": [self.richard.id]})
self.members_url,
{"users_old": [self.richard.id]},
)
# nothing should have changed # nothing should have changed
new_mem = self.club.get_membership_for(self.richard) membership.refresh_from_db()
assert self.club.members.count() == nb_memberships assert self.club.members.count() == nb_memberships
assert membership == new_mem assert membership.end_date is None
def test_remove_from_club_group(self): def test_remove_from_club_group(self):
"""Test that when a membership ends, the user is removed from club groups.""" """Test that when a membership ends, the user is removed from club groups."""

View File

@@ -25,6 +25,7 @@
from django.urls import path from django.urls import path
from club.views import ( from club.views import (
ClubAddMembersFragment,
ClubCreateView, ClubCreateView,
ClubEditView, ClubEditView,
ClubListView, ClubListView,
@@ -60,6 +61,11 @@ urlpatterns = [
path("<int:club_id>/edit/", ClubEditView.as_view(), name="club_edit"), path("<int:club_id>/edit/", ClubEditView.as_view(), name="club_edit"),
path("<int:club_id>/edit/page/", ClubPageEditView.as_view(), name="club_edit_page"), path("<int:club_id>/edit/page/", ClubPageEditView.as_view(), name="club_edit_page"),
path("<int:club_id>/members/", ClubMembersView.as_view(), name="club_members"), path("<int:club_id>/members/", ClubMembersView.as_view(), name="club_members"),
path(
"fragment/<int:club_id>/members/",
ClubAddMembersFragment.as_view(),
name="club_new_members",
),
path( path(
"<int:club_id>/elderlies/", "<int:club_id>/elderlies/",
ClubOldMembersView.as_view(), ClubOldMembersView.as_view(),

View File

@@ -23,12 +23,14 @@
# #
import csv import csv
from typing import Any
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator from django.core.paginator import InvalidPage, Paginator
from django.db.models import Sum from django.db.models import Q, Sum
from django.http import ( from django.http import (
Http404, Http404,
HttpResponseRedirect, HttpResponseRedirect,
@@ -37,7 +39,8 @@ from django.http import (
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.safestring import SafeString
from django.utils.timezone import now
from django.utils.translation import gettext as _t from django.utils.translation import gettext as _t
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, View from django.views.generic import DetailView, ListView, View
@@ -47,20 +50,26 @@ from club.forms import (
ClubAdminEditForm, ClubAdminEditForm,
ClubEditForm, ClubEditForm,
ClubMemberForm, ClubMemberForm,
ClubOldMemberForm,
MailingForm, MailingForm,
SellingsForm, SellingsForm,
) )
from club.models import Club, Mailing, MailingSubscription, Membership from club.models import (
Club,
Mailing,
MailingSubscription,
Membership,
)
from com.views import ( from com.views import (
PosterCreateBaseView, PosterCreateBaseView,
PosterDeleteBaseView, PosterDeleteBaseView,
PosterEditBaseView, PosterEditBaseView,
PosterListBaseView, PosterListBaseView,
) )
from core.auth.mixins import CanCreateMixin, CanEditMixin, CanViewMixin from core.auth.mixins import CanCreateMixin, CanEditMixin
from core.models import PageRev from core.models import PageRev
from core.views import DetailFormView, PageEditViewBase from core.views import DetailFormView, PageEditViewBase, UseFragmentsMixin
from core.views.mixins import TabedViewMixin from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin
from counter.models import Selling from counter.models import Selling
@@ -79,7 +88,7 @@ class ClubTabsMixin(TabedViewMixin):
"name": _("Infos"), "name": _("Infos"),
} }
] ]
if self.request.user.can_view(self.object): if self.request.user.has_perm("club.view_club"):
tab_list.extend( tab_list.extend(
[ [
{ {
@@ -228,13 +237,14 @@ class ClubPageEditView(ClubTabsMixin, PageEditViewBase):
return reverse_lazy("club:club_view", kwargs={"club_id": self.club.id}) return reverse_lazy("club:club_view", kwargs={"club_id": self.club.id})
class ClubPageHistView(ClubTabsMixin, CanViewMixin, DetailView): class ClubPageHistView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
"""Modification hostory of the page.""" """Modification hostory of the page."""
model = Club model = Club
pk_url_kwarg = "club_id" pk_url_kwarg = "club_id"
template_name = "club/page_history.jinja" template_name = "club/page_history.jinja"
current_tab = "history" current_tab = "history"
permission_required = "club.view_club"
class ClubToolsView(ClubTabsMixin, CanEditMixin, DetailView): class ClubToolsView(ClubTabsMixin, CanEditMixin, DetailView):
@@ -246,57 +256,109 @@ class ClubToolsView(ClubTabsMixin, CanEditMixin, DetailView):
current_tab = "tools" current_tab = "tools"
class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView): class ClubAddMembersFragment(
FragmentMixin, PermissionRequiredMixin, SuccessMessageMixin, CreateView
):
template_name = "club/fragments/add_member.jinja"
form_class = ClubMemberForm
model = Membership
object = None
reload_on_redirect = True
permission_required = "club.view_club"
success_message = _("%(user)s has been added to club.")
def dispatch(self, *args, **kwargs):
club_id = self.kwargs.get("club_id")
if not club_id:
raise Http404
self.club = get_object_or_404(Club, pk=kwargs.get("club_id"))
return super().dispatch(*args, **kwargs)
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"request_user": self.request.user,
"club": self.club,
}
def render_fragment(self, request, **kwargs) -> SafeString:
self.club = kwargs.get("club")
return super().render_fragment(request, **kwargs)
def get_success_url(self):
return reverse("club:club_members", kwargs={"club_id": self.club.id})
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"club": self.club}
class ClubMembersView(
ClubTabsMixin, UseFragmentsMixin, PermissionRequiredMixin, DetailFormView
):
"""View of a club's members.""" """View of a club's members."""
model = Club model = Club
pk_url_kwarg = "club_id" pk_url_kwarg = "club_id"
form_class = ClubMemberForm form_class = ClubOldMemberForm
template_name = "club/club_members.jinja" template_name = "club/club_members.jinja"
current_tab = "members" current_tab = "members"
permission_required = "club.view_club"
@cached_property def get_fragments(self) -> dict[str, type[FragmentMixin] | FragmentRenderer]:
def members(self) -> list[Membership]: membership = self.object.get_membership_for(self.request.user)
return list(self.object.members.ongoing().order_by("-role")) if membership and membership.role <= settings.SITH_MAXIMUM_FREE_ROLE:
return {}
return {"add_member_fragment": ClubAddMembersFragment}
def get_fragment_data(self) -> dict[str, Any]:
return {"add_member_fragment": {"club": self.object}}
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
kwargs["request_user"] = self.request.user kwargs["user"] = self.request.user
kwargs["club"] = self.object kwargs["club"] = self.object
kwargs["club_members"] = self.members
return kwargs return kwargs
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["members"] = self.members editable = list(
kwargs["form"].fields["members_old"].queryset.values_list("id", flat=True)
)
kwargs["members"] = list(
self.object.members.ongoing()
.annotate(is_editable=Q(id__in=editable))
.order_by("-role")
.select_related("user")
)
kwargs["can_end_membership"] = len(editable) > 0
return kwargs return kwargs
def form_valid(self, form): def form_valid(self, form):
"""Check user rights.""" for membership in form.cleaned_data.get("members_old"):
resp = super().form_valid(form) membership.end_date = now()
data = form.clean()
users = data.pop("users", [])
users_old = data.pop("users_old", [])
for user in users:
Membership(club=self.object, user=user, **data).save()
for user in users_old:
membership = self.object.get_membership_for(user)
membership.end_date = timezone.now()
membership.save() membership.save()
return resp return super().form_valid(form)
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return self.request.path return self.request.path
class ClubOldMembersView(ClubTabsMixin, CanViewMixin, DetailView): class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
"""Old members of a club.""" """Old members of a club."""
model = Club model = Club
pk_url_kwarg = "club_id" pk_url_kwarg = "club_id"
template_name = "club/club_old_members.jinja" template_name = "club/club_old_members.jinja"
current_tab = "elderlies" current_tab = "elderlies"
permission_required = "club.view_club"
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"old_members": (
self.object.members.exclude(end_date=None)
.order_by("-role", "description", "-end_date")
.select_related("user")
)
}
class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView): class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
@@ -686,9 +748,11 @@ class MailingAutoGenerationView(View):
return redirect("club:mailing", club_id=club.id) return redirect("club:mailing", club_id=club.id)
class PosterListView(ClubTabsMixin, PosterListBaseView, CanViewMixin): class PosterListView(ClubTabsMixin, PermissionRequiredMixin, PosterListBaseView):
"""List communication posters.""" """List communication posters."""
permission_required = "club.view_club"
def get_object(self): def get_object(self):
return self.club return self.club
@@ -704,7 +768,7 @@ class PosterCreateView(PosterCreateBaseView, CanCreateMixin):
pk_url_kwarg = "club_id" pk_url_kwarg = "club_id"
def get_object(self): def get_object(self, *args, **kwargs):
obj = super().get_object() obj = super().get_object()
if not obj: if not obj:
return self.club return self.club

View File

@@ -36,6 +36,7 @@
> .ts-control { > .ts-control {
box-shadow: none; box-shadow: none;
max-width: 300px; max-width: 300px;
width: 300px;
background-color: var(--nf-input-background-color); background-color: var(--nf-input-background-color);
&::after { &::after {

View File

@@ -47,6 +47,7 @@
} }
input, input,
select,
textarea[type="text"], textarea[type="text"],
[type="number"], [type="number"],
.ts-control { .ts-control {
@@ -240,6 +241,23 @@ form {
} }
} }
} }
input[type="text"],
input[type="email"],
input[type="tel"],
input[type="url"],
input[type="password"],
input[type="number"],
input[type="date"],
input[type="datetime-local"],
input[type="week"],
input[type="time"],
input[type="month"],
input[type="search"],
textarea,
select,
.ts-control {
min-height: calc(var(--nf-input-size) * 2.5);
}
input[type="text"], input[type="text"],
input[type="checkbox"], input[type="checkbox"],

View File

@@ -517,6 +517,10 @@ th {
>ul { >ul {
margin-top: 0; margin-top: 0;
} }
>input[type="checkbox"] {
padding: unset;
}
} }
td { td {

View File

@@ -535,6 +535,13 @@ class Counter(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def __getattribute__(self, name: str):
if name == "edit_groups":
return Group.objects.filter(
name=self.club.unix_name + settings.SITH_BOARD_SUFFIX
).all()
return object.__getattribute__(self, name)
def get_absolute_url(self) -> str: def get_absolute_url(self) -> str:
if self.type == "EBOUTIC": if self.type == "EBOUTIC":
return reverse("eboutic:main") return reverse("eboutic:main")

View File

@@ -17,7 +17,8 @@ document.addEventListener("alpine:init", () => {
this.$watch("basket", () => { this.$watch("basket", () => {
this.saveBasket(); this.saveBasket();
}); });
console.log(lastPurchaseTime);
console.log(localStorage.basketTimestamp);
// Invalidate basket if a purchase was made // Invalidate basket if a purchase was made
if (lastPurchaseTime !== null && localStorage.basketTimestamp !== undefined) { if (lastPurchaseTime !== null && localStorage.basketTimestamp !== undefined) {
if ( if (

View File

@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-01 18:18+0200\n" "POT-Creation-Date: 2025-09-13 18:32+0200\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -174,8 +174,11 @@ msgid "You can not add the same user twice"
msgstr "Vous ne pouvez pas ajouter deux fois le même utilisateur" msgstr "Vous ne pouvez pas ajouter deux fois le même utilisateur"
#: club/forms.py #: club/forms.py
msgid "You should specify a role" msgid ""
msgstr "Vous devez choisir un rôle" "You cannot add other users to a club if you are not in the club board."
msgstr ""
"Vous ne pouvez pas ajouter d'autres utilisateurs dans un club si vous "
"ne faites pas partie de son bureau."
#: club/forms.py sas/forms.py #: club/forms.py sas/forms.py
msgid "You do not have the permission to do that" msgid "You do not have the permission to do that"
@@ -326,6 +329,10 @@ msgstr "Il n'y a pas de club dans ce site web."
msgid "Club members" msgid "Club members"
msgstr "Membres du club" msgstr "Membres du club"
#: club/templates/club/club_members.jinja
msgid "Add a new member"
msgstr "Ajouter un nouveau membre"
#: club/templates/club/club_members.jinja #: club/templates/club/club_members.jinja
#: club/templates/club/club_old_members.jinja #: club/templates/club/club_old_members.jinja
#: core/templates/core/user_clubs.jinja #: core/templates/core/user_clubs.jinja
@@ -350,11 +357,6 @@ msgstr "Depuis"
msgid "There are no members in this club." msgid "There are no members in this club."
msgstr "Il n'y a pas de membres dans ce club." msgstr "Il n'y a pas de membres dans ce club."
#: club/templates/club/club_members.jinja core/templates/core/file_detail.jinja
#: core/views/forms.py trombi/templates/trombi/detail.jinja
msgid "Add"
msgstr "Ajouter"
#: club/templates/club/club_old_members.jinja #: club/templates/club/club_old_members.jinja
msgid "Club old members" msgid "Club old members"
msgstr "Anciens membres du club" msgstr "Anciens membres du club"
@@ -569,6 +571,12 @@ msgstr ""
msgid "Save" msgid "Save"
msgstr "Sauver" msgstr "Sauver"
#: club/templates/club/fragments/add_member.jinja
#: core/templates/core/file_detail.jinja core/views/forms.py
#: trombi/templates/trombi/detail.jinja
msgid "Add"
msgstr "Ajouter"
#: club/templates/club/mailing.jinja #: club/templates/club/mailing.jinja
msgid "Mailing lists" msgid "Mailing lists"
msgstr "Mailing listes" msgstr "Mailing listes"
@@ -679,6 +687,11 @@ msgstr "Listes de diffusion"
msgid "Posters list" msgid "Posters list"
msgstr "Liste d'affiches" msgstr "Liste d'affiches"
#: club/views.py
#, python-format
msgid "%(user)s has been added to club."
msgstr "%(user)s a été ajouté au club."
#: com/forms.py #: com/forms.py
msgid "Format: 16:9 | Resolution: 1920x1080" msgid "Format: 16:9 | Resolution: 1920x1080"
msgstr "Format : 16:9 | Résolution : 1920x1080" msgstr "Format : 16:9 | Résolution : 1920x1080"
@@ -1713,8 +1726,8 @@ msgid ""
"AE UTBM is a voluntary organisation run by UTBM students. It organises " "AE UTBM is a voluntary organisation run by UTBM students. It organises "
"student life at UTBM and manages its student facilities." "student life at UTBM and manages its student facilities."
msgstr "" msgstr ""
"L'AE UTBM est une association bénévole gérée par les étudiants de " "L'AE UTBM est une association bénévole gérée par les étudiants de l'UTBM. "
"l'UTBM. Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie." "Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie."
#: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja #: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja
msgid "Contacts" msgid "Contacts"
@@ -2157,10 +2170,6 @@ msgstr ""
msgid "Page history" msgid "Page history"
msgstr "Historique de la page" msgstr "Historique de la page"
#: core/templates/core/page_list.jinja
msgid "There is no page in this website."
msgstr "Il n'y a pas de page sur ce site web."
#: core/templates/core/page_prop.jinja #: core/templates/core/page_prop.jinja
msgid "Page properties" msgid "Page properties"
msgstr "Propriétés de la page" msgstr "Propriétés de la page"

View File

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