mirror of
https://github.com/ae-utbm/sith.git
synced 2025-09-13 19:45:50 +00:00
Compare commits
11 Commits
htmx-club
...
invoice_ca
Author | SHA1 | Date | |
---|---|---|---|
|
0fef2e0071 | ||
|
c7231608a9 | ||
|
c1ff8a9684 | ||
|
494e90f614 | ||
|
efa9f35b45 | ||
|
5f17ecc739 | ||
|
9fed57de20 | ||
|
e903198384 | ||
|
e990b94941 | ||
|
b04fa90d6e | ||
|
e47e6df9f5 |
161
club/forms.py
161
club/forms.py
@@ -26,16 +26,12 @@ from django import forms
|
||||
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, Mailing, MailingSubscription, Membership
|
||||
from core.models import User
|
||||
from core.views.forms import SelectDateTime
|
||||
from core.views.widgets.ajax_select import (
|
||||
AutoCompleteSelectMultipleUser,
|
||||
AutoCompleteSelectUser,
|
||||
)
|
||||
from core.views.forms import SelectDate, SelectDateTime
|
||||
from core.views.widgets.ajax_select import AutoCompleteSelectMultipleUser
|
||||
from counter.models import Counter, Selling
|
||||
|
||||
|
||||
@@ -192,94 +188,105 @@ class SellingsForm(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):
|
||||
class ClubMemberForm(forms.Form):
|
||||
"""Form handling the members of a club."""
|
||||
|
||||
error_css_class = "error"
|
||||
required_css_class = "required"
|
||||
|
||||
class Meta:
|
||||
model = Membership
|
||||
fields = ["user", "role", "description"]
|
||||
widgets = {"user": AutoCompleteSelectUser}
|
||||
users = forms.ModelMultipleChoiceField(
|
||||
label=_("Users to add"),
|
||||
help_text=_("Search users to add (one or more)."),
|
||||
required=False,
|
||||
widget=AutoCompleteSelectMultipleUser,
|
||||
queryset=User.objects.all(),
|
||||
)
|
||||
|
||||
def __init__(self, *args, club: Club, request_user: User, **kwargs):
|
||||
self.club = club
|
||||
self.request_user = request_user
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.club = kwargs.pop("club")
|
||||
self.request_user = kwargs.pop("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)
|
||||
super().__init__(*args, **kwargs)
|
||||
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
|
||||
|
||||
@cached_property
|
||||
def max_available_role(self):
|
||||
"""The greatest role that will be obtainable with this form.
|
||||
# Using a ModelForm binds too much the form with the model and we don't want that
|
||||
# We want the view to process the model creation since they are multiple users
|
||||
# We also want the form to handle bulk deletion
|
||||
self.fields.update(
|
||||
forms.fields_for_model(
|
||||
Membership,
|
||||
fields=("role", "start_date", "description"),
|
||||
widgets={"start_date": SelectDate},
|
||||
)
|
||||
)
|
||||
|
||||
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
|
||||
# Role is required only if users is specified
|
||||
self.fields["role"].required = False
|
||||
|
||||
def clean_user(self):
|
||||
"""Check that the user is not trying to add a user already in the club.
|
||||
# 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(),
|
||||
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):
|
||||
"""Check that the user is not trying to add an user already in the club.
|
||||
|
||||
Also check that the user is valid and has a valid subscription.
|
||||
"""
|
||||
user = self.cleaned_data["user"]
|
||||
if not user.is_subscribed:
|
||||
raise forms.ValidationError(
|
||||
_("User must be subscriber to take part to a club"), code="invalid"
|
||||
)
|
||||
if self.club.get_membership_for(user):
|
||||
raise forms.ValidationError(
|
||||
_("You can not add the same user twice"), code="invalid"
|
||||
)
|
||||
return user
|
||||
cleaned_data = super().clean()
|
||||
users = []
|
||||
for user in cleaned_data["users"]:
|
||||
if not user.is_subscribed:
|
||||
raise forms.ValidationError(
|
||||
_("User must be subscriber to take part to a club"), code="invalid"
|
||||
)
|
||||
if self.club.get_membership_for(user):
|
||||
raise forms.ValidationError(
|
||||
_("You can not add the same user twice"), code="invalid"
|
||||
)
|
||||
users.append(user)
|
||||
return users
|
||||
|
||||
def clean(self):
|
||||
"""Check user rights for adding a user."""
|
||||
"""Check user rights for adding an user."""
|
||||
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
|
||||
if (
|
||||
self.request_user_membership is None
|
||||
or self.request_user_membership.role <= settings.SITH_MAXIMUM_FREE_ROLE
|
||||
) and not self.request_user.has_perm("club.add_membership"):
|
||||
raise forms.ValidationError(
|
||||
_(
|
||||
"You cannot add other users to a club "
|
||||
"if you are not in the club board."
|
||||
),
|
||||
code="invalid",
|
||||
)
|
||||
if cleaned_data["role"] > self.max_available_role:
|
||||
|
||||
if cleaned_data.get("role", "") == "":
|
||||
# Role is required if users exists
|
||||
self.add_error("role", _("You should specify a role"))
|
||||
return cleaned_data
|
||||
|
||||
request_user = self.request_user
|
||||
membership = self.request_user_membership
|
||||
if not (
|
||||
cleaned_data["role"] <= settings.SITH_MAXIMUM_FREE_ROLE
|
||||
or (membership is not None and membership.role >= cleaned_data["role"])
|
||||
or request_user.is_board_member
|
||||
or request_user.is_root
|
||||
):
|
||||
raise forms.ValidationError(_("You do not have the permission to do that"))
|
||||
return cleaned_data
|
||||
|
@@ -30,8 +30,7 @@ from django.core.cache import cache
|
||||
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, Value
|
||||
from django.db.models.functions import Greatest
|
||||
from django.db.models import Exists, F, OuterRef, Q
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
@@ -201,6 +200,10 @@ class Club(models.Model):
|
||||
"""Method to see if that object can be edited by the given 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:
|
||||
"""Return the current membership the given user.
|
||||
|
||||
@@ -240,41 +243,6 @@ class MembershipQuerySet(models.QuerySet):
|
||||
"""
|
||||
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:
|
||||
"""Refresh the cache and edit group ownership.
|
||||
|
||||
@@ -351,12 +319,16 @@ class Membership(models.Model):
|
||||
User,
|
||||
verbose_name=_("user"),
|
||||
related_name="memberships",
|
||||
null=False,
|
||||
blank=False,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
club = models.ForeignKey(
|
||||
Club,
|
||||
verbose_name=_("club"),
|
||||
related_name="members",
|
||||
null=False,
|
||||
blank=False,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
start_date = models.DateField(_("start date"), default=timezone.now)
|
||||
|
@@ -1,24 +0,0 @@
|
||||
#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;
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,30 +1,15 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
{% 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 %}
|
||||
<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 %}
|
||||
<form action="{{ url('club:club_members', club_id=club.id) }}" id="members_old" method="post">
|
||||
<form action="{{ url('club:club_members', club_id=club.id) }}" id="users_old" method="post">
|
||||
{% csrf_token %}
|
||||
{% if can_end_membership %}
|
||||
{{ select_all_checkbox("members_old") }}
|
||||
<br />
|
||||
{% set users_old = dict(form.users_old | groupby("choice_label")) %}
|
||||
{% if users_old %}
|
||||
{{ select_all_checkbox("users_old") }}
|
||||
<p></p>
|
||||
{% endif %}
|
||||
<table id="club_members_table">
|
||||
<thead>
|
||||
@@ -33,7 +18,7 @@
|
||||
<td>{% trans %}Role{% endtrans %}</td>
|
||||
<td>{% trans %}Description{% endtrans %}</td>
|
||||
<td>{% trans %}Since{% endtrans %}</td>
|
||||
{% if can_end_membership %}
|
||||
{% if users_old %}
|
||||
<td>{% trans %}Mark as old{% endtrans %}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
@@ -45,24 +30,20 @@
|
||||
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
|
||||
<td>{{ m.description }}</td>
|
||||
<td>{{ m.start_date }}</td>
|
||||
{%- if can_end_membership -%}
|
||||
{% if users_old %}
|
||||
<td>
|
||||
{%- if m.is_editable -%}
|
||||
<label for="id_members_old_{{ loop.index }}"></label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="members_old"
|
||||
value="{{ m.id }}"
|
||||
id="id_members_old_{{ loop.index }}"
|
||||
>
|
||||
{%- endif -%}
|
||||
{% set user_old = users_old[m.user.get_display_name()] %}
|
||||
{% if user_old %}
|
||||
{{ user_old[0].tag() }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{%- endif -%}
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if can_end_membership %}
|
||||
{{ form.users_old.errors }}
|
||||
{% if users_old %}
|
||||
<p></p>
|
||||
<input type="submit" name="submit" value="{% trans %}Mark as old{% endtrans %}">
|
||||
{% endif %}
|
||||
@@ -70,4 +51,32 @@
|
||||
{% else %}
|
||||
<p>{% trans %}There are no members in this club.{% endtrans %}</p>
|
||||
{% 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 %}
|
||||
|
@@ -5,22 +5,20 @@
|
||||
<h2>{% trans %}Club old members{% endtrans %}</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}User{% endtrans %}</td>
|
||||
<td>{% trans %}Role{% endtrans %}</td>
|
||||
<td>{% trans %}Description{% endtrans %}</td>
|
||||
<td>{% trans %}From{% endtrans %}</td>
|
||||
<td>{% trans %}To{% endtrans %}</td>
|
||||
</tr>
|
||||
<td>{% trans %}User{% endtrans %}</td>
|
||||
<td>{% trans %}Role{% endtrans %}</td>
|
||||
<td>{% trans %}Description{% endtrans %}</td>
|
||||
<td>{% trans %}From{% endtrans %}</td>
|
||||
<td>{% trans %}To{% endtrans %}</td>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for member in old_members %}
|
||||
{% for m in club.members.exclude(end_date=None).order_by('-role', 'description', '-end_date').all() %}
|
||||
<tr>
|
||||
<td>{{ user_profile_link(member.user) }}</td>
|
||||
<td>{{ settings.SITH_CLUB_ROLES[member.role] }}</td>
|
||||
<td>{{ member.description }}</td>
|
||||
<td>{{ member.start_date }}</td>
|
||||
<td>{{ member.end_date }}</td>
|
||||
<td>{{ user_profile_link(m.user) }}</td>
|
||||
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
|
||||
<td>{{ m.description }}</td>
|
||||
<td>{{ m.start_date }}</td>
|
||||
<td>{{ m.end_date }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
@@ -1,44 +0,0 @@
|
||||
{% 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>
|
@@ -43,9 +43,6 @@ class TestClub(TestCase):
|
||||
|
||||
cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID)
|
||||
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})
|
||||
a_month_ago = now() - timedelta(days=30)
|
||||
yesterday = now() - timedelta(days=1)
|
||||
|
@@ -1,16 +1,13 @@
|
||||
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
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import localdate, localtime, now
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.forms import ClubMemberForm
|
||||
from club.models import Club, Membership
|
||||
from club.models import Membership
|
||||
from club.tests.base import TestClub
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import AnonymousUser, User
|
||||
@@ -140,38 +137,6 @@ class TestMembershipQuerySet(TestClub):
|
||||
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):
|
||||
def assert_membership_started_today(self, user: User, role: int):
|
||||
"""Assert that the given membership is active and started today."""
|
||||
@@ -186,7 +151,7 @@ class TestMembership(TestClub):
|
||||
|
||||
def assert_membership_ended_today(self, user: User):
|
||||
"""Assert that the given user have a membership which ended today."""
|
||||
today = localdate()
|
||||
today = localtime(now()).date()
|
||||
assert user.memberships.filter(club=self.club, end_date=today).exists()
|
||||
assert self.club.get_membership_for(user) is None
|
||||
|
||||
@@ -195,9 +160,7 @@ class TestMembership(TestClub):
|
||||
cannot see the page.
|
||||
"""
|
||||
response = self.client.post(self.members_url)
|
||||
assertRedirects(
|
||||
response, reverse("core:login", query={"next": self.members_url})
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
self.client.force_login(self.public)
|
||||
response = self.client.post(self.members_url)
|
||||
@@ -208,9 +171,7 @@ class TestMembership(TestClub):
|
||||
information are displayed.
|
||||
"""
|
||||
self.client.force_login(self.simple_board_member)
|
||||
response = self.client.get(
|
||||
reverse("club:club_members", kwargs={"club_id": self.club.id})
|
||||
)
|
||||
response = self.client.get(self.members_url)
|
||||
assert response.status_code == 200
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
table = soup.find("table", id="club_members_table")
|
||||
@@ -236,45 +197,59 @@ class TestMembership(TestClub):
|
||||
assert cols[2].text == membership.description
|
||||
assert cols[3].text == str(membership.start_date)
|
||||
|
||||
if membership.role < 3 or membership.user_id == self.simple_board_member.id:
|
||||
# 3 is the role of simple_board_member
|
||||
if membership.role <= 3: # 3 is the role of simple_board_member
|
||||
form_input = cols[4].find("input")
|
||||
expected_attrs = {
|
||||
"type": "checkbox",
|
||||
"name": "members_old",
|
||||
"value": str(membership.id),
|
||||
"name": "users_old",
|
||||
"value": str(user.id),
|
||||
}
|
||||
assert form_input.attrs.items() >= expected_attrs.items()
|
||||
else:
|
||||
assert cols[4].find_all() == []
|
||||
|
||||
def test_root_add_one_club_member(self):
|
||||
"""Test that root users can add members to clubs"""
|
||||
"""Test that root users can add members to clubs, one at a time."""
|
||||
self.client.force_login(self.root)
|
||||
response = self.client.post(
|
||||
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.members_url,
|
||||
{"users": [self.subscriber.id], "role": 3},
|
||||
)
|
||||
self.assertRedirects(response, self.members_url)
|
||||
self.subscriber.refresh_from_db()
|
||||
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):
|
||||
"""Test that users who are not currently subscribed
|
||||
cannot be members of clubs.
|
||||
"""
|
||||
for user in self.public, self.old_subscriber:
|
||||
form = ClubMemberForm(
|
||||
data={"user": user.id, "role": 1},
|
||||
data={"users": [user.id], "role": 1},
|
||||
request_user=self.root,
|
||||
club=self.club,
|
||||
)
|
||||
|
||||
assert not form.is_valid()
|
||||
assert form.errors == {
|
||||
"user": ["L'utilisateur doit être cotisant pour faire partie d'un club"]
|
||||
"users": [
|
||||
"L'utilisateur doit être cotisant pour faire partie d'un club"
|
||||
]
|
||||
}
|
||||
|
||||
def test_add_members_already_members(self):
|
||||
@@ -307,15 +282,15 @@ 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 = ClubMemberForm(
|
||||
data={"user": members, "role": 1},
|
||||
data={"users": members, "role": 1},
|
||||
request_user=self.root,
|
||||
club=self.club,
|
||||
)
|
||||
assert not form.is_valid()
|
||||
assert form.errors == {
|
||||
"user": [
|
||||
"users": [
|
||||
"Sélectionnez un choix valide. "
|
||||
"Ce choix ne fait pas partie de ceux disponibles."
|
||||
f"{max_id + 1} n\u2019en fait pas partie."
|
||||
]
|
||||
}
|
||||
self.club.refresh_from_db()
|
||||
@@ -328,12 +303,10 @@ class TestMembership(TestClub):
|
||||
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": 9}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers.get("HX-Redirect", "") == reverse(
|
||||
"club:club_members", kwargs={"club_id": self.club.id}
|
||||
self.members_url,
|
||||
{"users": self.subscriber.id, "role": 9},
|
||||
)
|
||||
self.assertRedirects(response, self.members_url)
|
||||
self.club.refresh_from_db()
|
||||
self.subscriber.refresh_from_db()
|
||||
assert self.club.members.count() == nb_club_membership + 1
|
||||
@@ -345,7 +318,7 @@ class TestMembership(TestClub):
|
||||
a membership with a greater role than its own.
|
||||
"""
|
||||
form = ClubMemberForm(
|
||||
data={"user": self.subscriber.id, "role": 10},
|
||||
data={"users": [self.subscriber.id], "role": 10},
|
||||
request_user=self.simple_board_member,
|
||||
club=self.club,
|
||||
)
|
||||
@@ -353,7 +326,7 @@ class TestMembership(TestClub):
|
||||
|
||||
assert not form.is_valid()
|
||||
assert form.errors == {
|
||||
"role": ["Sélectionnez un choix valide. 10 n\u2019en fait pas partie."]
|
||||
"__all__": ["Vous n'avez pas la permission de faire cela"]
|
||||
}
|
||||
self.club.refresh_from_db()
|
||||
assert nb_memberships == self.club.members.count()
|
||||
@@ -361,18 +334,23 @@ class TestMembership(TestClub):
|
||||
|
||||
def test_add_member_without_role(self):
|
||||
"""Test that trying to add members without specifying their role fails."""
|
||||
self.client.force_login(self.root)
|
||||
form = ClubMemberForm(
|
||||
data={"user": self.subscriber.id}, request_user=self.root, club=self.club
|
||||
data={"users": [self.subscriber.id]},
|
||||
request_user=self.simple_board_member,
|
||||
club=self.club,
|
||||
)
|
||||
|
||||
assert not form.is_valid()
|
||||
assert form.errors == {"role": ["Ce champ est obligatoire."]}
|
||||
assert form.errors == {"role": ["Vous devez choisir un rôle"]}
|
||||
|
||||
def test_end_membership_self(self):
|
||||
"""Test that a member can end its own membership."""
|
||||
self.client.force_login(self.simple_board_member)
|
||||
membership = self.club.members.get(end_date=None, user=self.simple_board_member)
|
||||
self.client.post(self.members_url, {"members_old": [membership.id]})
|
||||
self.client.post(
|
||||
self.members_url,
|
||||
{"users_old": self.simple_board_member.id},
|
||||
)
|
||||
self.simple_board_member.refresh_from_db()
|
||||
self.assert_membership_ended_today(self.simple_board_member)
|
||||
|
||||
@@ -380,13 +358,15 @@ 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
|
||||
# remainder : simple_board_member has role 3, president has role 10, richard has role 1
|
||||
self.client.force_login(self.simple_board_member)
|
||||
membership = baker.make(Membership, club=self.club, role=2, end_date=None)
|
||||
response = self.client.post(self.members_url, {"members_old": [membership.id]})
|
||||
response = self.client.post(
|
||||
self.members_url,
|
||||
{"users_old": self.richard.id},
|
||||
)
|
||||
self.assertRedirects(response, self.members_url)
|
||||
self.club.refresh_from_db()
|
||||
self.assert_membership_ended_today(membership.user)
|
||||
self.assert_membership_ended_today(self.richard)
|
||||
|
||||
def test_end_membership_higher_role(self):
|
||||
"""Test that board members of the club cannot end memberships
|
||||
@@ -394,30 +374,46 @@ class TestMembership(TestClub):
|
||||
"""
|
||||
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.client.post(
|
||||
self.members_url,
|
||||
{"users_old": self.president.id},
|
||||
)
|
||||
self.club.refresh_from_db()
|
||||
new_membership = self.club.get_membership_for(self.president)
|
||||
assert new_membership is not None
|
||||
assert new_membership == membership
|
||||
|
||||
membership.refresh_from_db()
|
||||
membership = self.president.memberships.filter(club=self.club).first()
|
||||
assert membership.end_date is None
|
||||
|
||||
def test_end_membership_with_permission(self):
|
||||
"""Test that users with permission can end any membership."""
|
||||
def test_end_membership_as_main_club_board(self):
|
||||
"""Test that board members of the main club can end the membership
|
||||
of anyone.
|
||||
"""
|
||||
# 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()
|
||||
self.client.force_login(
|
||||
subscriber_user.make(
|
||||
user_permissions=[Permission.objects.get(codename="change_membership")]
|
||||
)
|
||||
)
|
||||
president_membership = self.club.president
|
||||
self.client.force_login(subscriber)
|
||||
response = self.client.post(
|
||||
self.members_url, {"members_old": [president_membership.id]}
|
||||
self.members_url,
|
||||
{"users_old": self.president.id},
|
||||
)
|
||||
self.assertRedirects(response, self.members_url)
|
||||
self.assert_membership_ended_today(president_membership.user)
|
||||
self.assert_membership_ended_today(self.president)
|
||||
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
|
||||
|
||||
def test_end_membership_as_foreigner(self):
|
||||
@@ -425,11 +421,14 @@ class TestMembership(TestClub):
|
||||
nb_memberships = self.club.members.count()
|
||||
membership = self.richard.memberships.filter(club=self.club).first()
|
||||
self.client.force_login(self.subscriber)
|
||||
self.client.post(self.members_url, {"members_old": [self.richard.id]})
|
||||
self.client.post(
|
||||
self.members_url,
|
||||
{"users_old": [self.richard.id]},
|
||||
)
|
||||
# nothing should have changed
|
||||
membership.refresh_from_db()
|
||||
new_mem = self.club.get_membership_for(self.richard)
|
||||
assert self.club.members.count() == nb_memberships
|
||||
assert membership.end_date is None
|
||||
assert membership == new_mem
|
||||
|
||||
def test_remove_from_club_group(self):
|
||||
"""Test that when a membership ends, the user is removed from club groups."""
|
||||
|
@@ -25,7 +25,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from club.views import (
|
||||
ClubAddMembersFragment,
|
||||
ClubCreateView,
|
||||
ClubEditView,
|
||||
ClubListView,
|
||||
@@ -61,11 +60,6 @@ urlpatterns = [
|
||||
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>/members/", ClubMembersView.as_view(), name="club_members"),
|
||||
path(
|
||||
"fragment/<int:club_id>/members/",
|
||||
ClubAddMembersFragment.as_view(),
|
||||
name="club_new_members",
|
||||
),
|
||||
path(
|
||||
"<int:club_id>/elderlies/",
|
||||
ClubOldMembersView.as_view(),
|
||||
|
146
club/views.py
146
club/views.py
@@ -23,14 +23,12 @@
|
||||
#
|
||||
|
||||
import csv
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
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.paginator import InvalidPage, Paginator
|
||||
from django.db.models import Q, Sum
|
||||
from django.db.models import Sum
|
||||
from django.http import (
|
||||
Http404,
|
||||
HttpResponseRedirect,
|
||||
@@ -39,8 +37,7 @@ from django.http import (
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.safestring import SafeString
|
||||
from django.utils.timezone import now
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext as _t
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView, View
|
||||
@@ -50,26 +47,20 @@ from club.forms import (
|
||||
ClubAdminEditForm,
|
||||
ClubEditForm,
|
||||
ClubMemberForm,
|
||||
ClubOldMemberForm,
|
||||
MailingForm,
|
||||
SellingsForm,
|
||||
)
|
||||
from club.models import (
|
||||
Club,
|
||||
Mailing,
|
||||
MailingSubscription,
|
||||
Membership,
|
||||
)
|
||||
from club.models import Club, Mailing, MailingSubscription, Membership
|
||||
from com.views import (
|
||||
PosterCreateBaseView,
|
||||
PosterDeleteBaseView,
|
||||
PosterEditBaseView,
|
||||
PosterListBaseView,
|
||||
)
|
||||
from core.auth.mixins import CanCreateMixin, CanEditMixin
|
||||
from core.auth.mixins import CanCreateMixin, CanEditMixin, CanViewMixin
|
||||
from core.models import PageRev
|
||||
from core.views import DetailFormView, PageEditViewBase, UseFragmentsMixin
|
||||
from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin
|
||||
from core.views import DetailFormView, PageEditViewBase
|
||||
from core.views.mixins import TabedViewMixin
|
||||
from counter.models import Selling
|
||||
|
||||
|
||||
@@ -88,7 +79,7 @@ class ClubTabsMixin(TabedViewMixin):
|
||||
"name": _("Infos"),
|
||||
}
|
||||
]
|
||||
if self.request.user.has_perm("club.view_club"):
|
||||
if self.request.user.can_view(self.object):
|
||||
tab_list.extend(
|
||||
[
|
||||
{
|
||||
@@ -107,16 +98,16 @@ class ClubTabsMixin(TabedViewMixin):
|
||||
},
|
||||
]
|
||||
)
|
||||
if self.object.page:
|
||||
tab_list.append(
|
||||
{
|
||||
"url": reverse(
|
||||
"club:club_hist", kwargs={"club_id": self.object.id}
|
||||
),
|
||||
"slug": "history",
|
||||
"name": _("History"),
|
||||
}
|
||||
)
|
||||
if self.object.page:
|
||||
tab_list.append(
|
||||
{
|
||||
"url": reverse(
|
||||
"club:club_hist", kwargs={"club_id": self.object.id}
|
||||
),
|
||||
"slug": "history",
|
||||
"name": _("History"),
|
||||
}
|
||||
)
|
||||
if self.request.user.can_edit(self.object):
|
||||
tab_list.extend(
|
||||
[
|
||||
@@ -237,14 +228,13 @@ class ClubPageEditView(ClubTabsMixin, PageEditViewBase):
|
||||
return reverse_lazy("club:club_view", kwargs={"club_id": self.club.id})
|
||||
|
||||
|
||||
class ClubPageHistView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
|
||||
class ClubPageHistView(ClubTabsMixin, CanViewMixin, DetailView):
|
||||
"""Modification hostory of the page."""
|
||||
|
||||
model = Club
|
||||
pk_url_kwarg = "club_id"
|
||||
template_name = "club/page_history.jinja"
|
||||
current_tab = "history"
|
||||
permission_required = "club.view_club"
|
||||
|
||||
|
||||
class ClubToolsView(ClubTabsMixin, CanEditMixin, DetailView):
|
||||
@@ -256,109 +246,57 @@ class ClubToolsView(ClubTabsMixin, CanEditMixin, DetailView):
|
||||
current_tab = "tools"
|
||||
|
||||
|
||||
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
|
||||
):
|
||||
class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
|
||||
"""View of a club's members."""
|
||||
|
||||
model = Club
|
||||
pk_url_kwarg = "club_id"
|
||||
form_class = ClubOldMemberForm
|
||||
form_class = ClubMemberForm
|
||||
template_name = "club/club_members.jinja"
|
||||
current_tab = "members"
|
||||
permission_required = "club.view_club"
|
||||
|
||||
def get_fragments(self) -> dict[str, type[FragmentMixin] | FragmentRenderer]:
|
||||
membership = self.object.get_membership_for(self.request.user)
|
||||
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}}
|
||||
@cached_property
|
||||
def members(self) -> list[Membership]:
|
||||
return list(self.object.members.ongoing().order_by("-role"))
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["user"] = self.request.user
|
||||
kwargs["request_user"] = self.request.user
|
||||
kwargs["club"] = self.object
|
||||
kwargs["club_members"] = self.members
|
||||
return kwargs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
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
|
||||
kwargs["members"] = self.members
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
for membership in form.cleaned_data.get("members_old"):
|
||||
membership.end_date = now()
|
||||
"""Check user rights."""
|
||||
resp = super().form_valid(form)
|
||||
|
||||
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()
|
||||
return super().form_valid(form)
|
||||
return resp
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return self.request.path
|
||||
|
||||
|
||||
class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
|
||||
class ClubOldMembersView(ClubTabsMixin, CanViewMixin, DetailView):
|
||||
"""Old members of a club."""
|
||||
|
||||
model = Club
|
||||
pk_url_kwarg = "club_id"
|
||||
template_name = "club/club_old_members.jinja"
|
||||
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):
|
||||
@@ -748,11 +686,9 @@ class MailingAutoGenerationView(View):
|
||||
return redirect("club:mailing", club_id=club.id)
|
||||
|
||||
|
||||
class PosterListView(ClubTabsMixin, PermissionRequiredMixin, PosterListBaseView):
|
||||
class PosterListView(ClubTabsMixin, PosterListBaseView, CanViewMixin):
|
||||
"""List communication posters."""
|
||||
|
||||
permission_required = "club.view_club"
|
||||
|
||||
def get_object(self):
|
||||
return self.club
|
||||
|
||||
@@ -768,7 +704,7 @@ class PosterCreateView(PosterCreateBaseView, CanCreateMixin):
|
||||
|
||||
pk_url_kwarg = "club_id"
|
||||
|
||||
def get_object(self, *args, **kwargs):
|
||||
def get_object(self):
|
||||
obj = super().get_object()
|
||||
if not obj:
|
||||
return self.club
|
||||
|
@@ -560,7 +560,7 @@ class User(AbstractUser):
|
||||
"""Determine if the object is owned by the user."""
|
||||
if hasattr(obj, "is_owned_by") and obj.is_owned_by(self):
|
||||
return True
|
||||
if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group_id):
|
||||
if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group.id):
|
||||
return True
|
||||
return self.is_root
|
||||
|
||||
@@ -569,15 +569,9 @@ class User(AbstractUser):
|
||||
if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self):
|
||||
return True
|
||||
if hasattr(obj, "edit_groups"):
|
||||
if (
|
||||
hasattr(obj, "_prefetched_objects_cache")
|
||||
and "edit_groups" in obj._prefetched_objects_cache
|
||||
):
|
||||
pks = [g.id for g in obj.edit_groups.all()]
|
||||
else:
|
||||
pks = list(obj.edit_groups.values_list("id", flat=True))
|
||||
if any(self.is_in_group(pk=pk) for pk in pks):
|
||||
return True
|
||||
for pk in obj.edit_groups.values_list("pk", flat=True):
|
||||
if self.is_in_group(pk=pk):
|
||||
return True
|
||||
if isinstance(obj, User) and obj == self:
|
||||
return True
|
||||
return self.is_owner(obj)
|
||||
@@ -587,18 +581,9 @@ class User(AbstractUser):
|
||||
if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self):
|
||||
return True
|
||||
if hasattr(obj, "view_groups"):
|
||||
# if "view_groups" has already been prefetched, use
|
||||
# the prefetch cache, else fetch only the ids, to make
|
||||
# the query lighter.
|
||||
if (
|
||||
hasattr(obj, "_prefetched_objects_cache")
|
||||
and "view_groups" in obj._prefetched_objects_cache
|
||||
):
|
||||
pks = [g.id for g in obj.view_groups.all()]
|
||||
else:
|
||||
pks = list(obj.view_groups.values_list("id", flat=True))
|
||||
if any(self.is_in_group(pk=pk) for pk in pks):
|
||||
return True
|
||||
for pk in obj.view_groups.values_list("pk", flat=True):
|
||||
if self.is_in_group(pk=pk):
|
||||
return True
|
||||
return self.can_edit(obj)
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
@@ -1399,9 +1384,9 @@ class Page(models.Model):
|
||||
|
||||
@cached_property
|
||||
def is_club_page(self):
|
||||
return (
|
||||
self.name == settings.SITH_CLUB_ROOT_PAGE
|
||||
or settings.SITH_CLUB_ROOT_PAGE in [p.name for p in self.get_parent_list()]
|
||||
club_root_page = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first()
|
||||
return club_root_page is not None and (
|
||||
self == club_root_page or club_root_page in self.get_parent_list()
|
||||
)
|
||||
|
||||
@cached_property
|
||||
|
@@ -36,7 +36,6 @@
|
||||
> .ts-control {
|
||||
box-shadow: none;
|
||||
max-width: 300px;
|
||||
width: 300px;
|
||||
background-color: var(--nf-input-background-color);
|
||||
|
||||
&::after {
|
||||
|
@@ -47,7 +47,6 @@
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea[type="text"],
|
||||
[type="number"],
|
||||
.ts-control {
|
||||
@@ -241,23 +240,6 @@ 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="checkbox"],
|
||||
|
@@ -514,13 +514,13 @@ th {
|
||||
text-align: center;
|
||||
padding: 5px 10px;
|
||||
|
||||
>ul {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
>input[type="checkbox"] {
|
||||
padding: unset;
|
||||
}
|
||||
|
||||
>ul {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
|
@@ -5,12 +5,16 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h3>{% trans %}Page list{% endtrans %}</h3>
|
||||
<ul>
|
||||
{% for p in page_list %}
|
||||
<li><a href="{{ p.get_absolute_url() }}">{{ p.display_name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if page_list %}
|
||||
<h3>{% trans %}Page list{% endtrans %}</h3>
|
||||
<ul>
|
||||
{% for p in page_list %}
|
||||
<li><a href="{{ p.get_absolute_url() }}">{{ p.get_display_name() }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{% trans %}There is no page in this website.{% endtrans %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
@@ -12,10 +12,7 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.db.models import F, OuterRef, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# This file contains all the views that concern the page model
|
||||
from django.forms.models import modelform_factory
|
||||
@@ -46,20 +43,6 @@ class CanEditPagePropMixin(CanEditPropMixin):
|
||||
class PageListView(CanViewMixin, ListView):
|
||||
model = Page
|
||||
template_name = "core/page_list.jinja"
|
||||
queryset = (
|
||||
Page.objects.annotate(
|
||||
display_name=Coalesce(
|
||||
Subquery(
|
||||
PageRev.objects.filter(page=OuterRef("id"))
|
||||
.order_by("-date")
|
||||
.values("title")[:1]
|
||||
),
|
||||
F("name"),
|
||||
)
|
||||
)
|
||||
.prefetch_related("view_groups")
|
||||
.select_related("parent")
|
||||
)
|
||||
|
||||
|
||||
class PageView(CanViewMixin, DetailView):
|
||||
|
@@ -5,6 +5,7 @@ from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||
|
||||
from club.models import Club
|
||||
from club.widgets.ajax_select import AutoCompleteSelectClub
|
||||
from core.models import User
|
||||
from core.views.forms import NFCTextInput, SelectDate, SelectDateTime
|
||||
@@ -19,6 +20,7 @@ from counter.models import (
|
||||
Counter,
|
||||
Customer,
|
||||
Eticket,
|
||||
InvoiceCall,
|
||||
Product,
|
||||
Refilling,
|
||||
ReturnableProduct,
|
||||
@@ -373,3 +375,39 @@ class BaseBasketForm(forms.BaseFormSet):
|
||||
BasketForm = forms.formset_factory(
|
||||
ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
|
||||
)
|
||||
|
||||
|
||||
class InvoiceCallForm(forms.Form):
|
||||
def __init__(self, *args, month, clubs: list[Club] | None = None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.month = month
|
||||
self.clubs = clubs
|
||||
|
||||
if self.clubs is None:
|
||||
self.clubs = []
|
||||
|
||||
invoices = {
|
||||
i["club_id"]: i["is_validated"]
|
||||
for i in InvoiceCall.objects.filter(
|
||||
club__in=self.clubs, month=self.month
|
||||
).values("club_id", "is_validated")
|
||||
}
|
||||
|
||||
for club in self.clubs:
|
||||
is_validated = invoices.get(club.id, False)
|
||||
|
||||
self.fields[f"club_{club.id}"] = forms.BooleanField(
|
||||
required=False, initial=is_validated
|
||||
)
|
||||
|
||||
def save(self):
|
||||
for club in self.clubs:
|
||||
field_name = f"club_{club.id}"
|
||||
is_validated = self.cleaned_data.get(field_name, False)
|
||||
|
||||
InvoiceCall.objects.update_or_create(
|
||||
month=self.month, club=club, defaults={"is_validated": is_validated}
|
||||
)
|
||||
|
||||
def get_club_name(self, club_id):
|
||||
return f"club_{club_id}"
|
||||
|
45
counter/migrations/0032_invoicecall.py
Normal file
45
counter/migrations/0032_invoicecall.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 5.2.3 on 2025-09-09 10:24
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import counter.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"),
|
||||
("counter", "0031_alter_counter_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="InvoiceCall",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_validated",
|
||||
models.BooleanField(default=False, verbose_name="is validated"),
|
||||
),
|
||||
("month", counter.models.MonthField(verbose_name="invoice date")),
|
||||
(
|
||||
"club",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="club.club"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Invoice call",
|
||||
"verbose_name_plural": "Invoice calls",
|
||||
},
|
||||
),
|
||||
]
|
@@ -1362,3 +1362,45 @@ class ReturnableProductBalance(models.Model):
|
||||
f"return balance of {self.customer} "
|
||||
f"for {self.returnable.product_id} : {self.balance}"
|
||||
)
|
||||
|
||||
|
||||
class MonthField(models.DateField):
|
||||
description = _("Year + month field (day forced to 1)")
|
||||
default_error_messages = {
|
||||
"invalid": _(
|
||||
"“%(value)s” value has an invalid date format. It must be "
|
||||
"in YYYY-MM format."
|
||||
),
|
||||
"invalid_date": _(
|
||||
"“%(value)s” value has the correct format (YYYY-MM) "
|
||||
"but it is an invalid date."
|
||||
),
|
||||
}
|
||||
|
||||
def to_python(self, value):
|
||||
if isinstance(value, date):
|
||||
return value.replace(day=1)
|
||||
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
year, month = map(int, value.split("-"))
|
||||
return date(year, month, 1)
|
||||
except (ValueError, TypeError) as err:
|
||||
raise ValueError(
|
||||
self.error_messages["invalid"] % {"value": value}
|
||||
) from err
|
||||
|
||||
return super().to_python(value)
|
||||
|
||||
|
||||
class InvoiceCall(models.Model):
|
||||
is_validated = models.BooleanField(verbose_name=_("is validated"), default=False)
|
||||
club = models.ForeignKey(Club, on_delete=models.CASCADE)
|
||||
month = MonthField(verbose_name=_("invoice date"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Invoice call")
|
||||
verbose_name_plural = _("Invoice calls")
|
||||
|
||||
def __str__(self):
|
||||
return f"invoice call of {self.month} made by {self.club}"
|
||||
|
@@ -15,24 +15,32 @@
|
||||
</select>
|
||||
<input type="submit" value="{% trans %}Go{% endtrans %}" />
|
||||
</form>
|
||||
<br>
|
||||
<p>{% trans %}CB Payments{% endtrans %} : {{ sum_cb }} €</p>
|
||||
<br>
|
||||
<table>
|
||||
<thead>
|
||||
<td>{% trans %}Club{% endtrans %}</td>
|
||||
<td>{% trans %}Sum{% endtrans %}</td>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for i in sums %}
|
||||
<tr>
|
||||
<td>{{ i['club__name'] }}</td>
|
||||
<td>{{ i['selling_sum'] }} €</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
<form method="post" action="">
|
||||
{% csrf_token %}
|
||||
<br>
|
||||
<p>{% trans %}CB Payments{% endtrans %} : {{ sum_cb }} €</p>
|
||||
<br>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<td>{% trans %}Club{% endtrans %}</td>
|
||||
<td>{% trans %}Sum{% endtrans %}</td>
|
||||
<td>{% trans %}Validated{% endtrans %}</td>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for data in club_data %}
|
||||
<tr>
|
||||
<td>{{ data.club.name }}</td>
|
||||
<td>{{"%.2f"|format(data.sum)}} €</td>
|
||||
<td>
|
||||
{{ form[form.get_club_name(data.club.id)] }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<input type="hidden" name="month" value="{{ start_date|date('Y-m') }}">
|
||||
<button type="submit">{% trans %}Save validation{% endtrans %}</button>
|
||||
</form>
|
||||
{% endblock %}
|
@@ -12,15 +12,17 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import timezone as tz
|
||||
|
||||
from django.db.models import F
|
||||
from django.db.models import Exists, F, OuterRef
|
||||
from django.shortcuts import redirect
|
||||
from django.utils import timezone
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from counter.fields import CurrencyField
|
||||
from counter.models import Refilling, Selling
|
||||
from counter.forms import InvoiceCallForm
|
||||
from counter.models import Club, InvoiceCall, Refilling, Selling
|
||||
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
|
||||
|
||||
|
||||
@@ -28,12 +30,30 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
|
||||
template_name = "counter/invoices_call.jinja"
|
||||
current_tab = "invoices_call"
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
month_str = request.GET.get("month")
|
||||
|
||||
if month_str:
|
||||
try:
|
||||
start_date = datetime.strptime(month_str, "%Y-%m").date()
|
||||
today = timezone.now().date().replace(day=1)
|
||||
if start_date > today:
|
||||
return redirect("counter:invoices_call")
|
||||
except ValueError:
|
||||
return redirect("counter:invoices_call")
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add sums to the context."""
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["months"] = Selling.objects.datetimes("date", "month", order="DESC")
|
||||
if "month" in self.request.GET:
|
||||
start_date = datetime.strptime(self.request.GET["month"], "%Y-%m")
|
||||
month_str = self.request.GET.get("month")
|
||||
|
||||
if month_str:
|
||||
try:
|
||||
start_date = datetime.strptime(self.request.GET["month"], "%Y-%m")
|
||||
except ValueError:
|
||||
return redirect("counter:invoices_call")
|
||||
else:
|
||||
start_date = datetime(
|
||||
year=timezone.now().year,
|
||||
@@ -46,30 +66,23 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
|
||||
)
|
||||
from django.db.models import Case, Sum, When
|
||||
|
||||
kwargs["sum_cb"] = sum(
|
||||
[
|
||||
r.amount
|
||||
for r in Refilling.objects.filter(
|
||||
payment_method="CARD",
|
||||
is_validated=True,
|
||||
date__gte=start_date,
|
||||
date__lte=end_date,
|
||||
)
|
||||
]
|
||||
)
|
||||
kwargs["sum_cb"] += sum(
|
||||
[
|
||||
s.quantity * s.unit_price
|
||||
for s in Selling.objects.filter(
|
||||
payment_method="CARD",
|
||||
is_validated=True,
|
||||
date__gte=start_date,
|
||||
date__lte=end_date,
|
||||
)
|
||||
]
|
||||
)
|
||||
kwargs["sum_cb"] = Refilling.objects.filter(
|
||||
payment_method="CARD",
|
||||
is_validated=True,
|
||||
date__gte=start_date,
|
||||
date__lte=end_date,
|
||||
).aggregate(amount=Sum(F("amount"), default=0))["amount"]
|
||||
|
||||
kwargs["sum_cb"] += Selling.objects.filter(
|
||||
payment_method="CARD",
|
||||
is_validated=True,
|
||||
date__gte=start_date,
|
||||
date__lte=end_date,
|
||||
).aggregate(amount=Sum(F("quantity") * F("unit_price"), default=0))["amount"]
|
||||
|
||||
kwargs["start_date"] = start_date
|
||||
kwargs["sums"] = (
|
||||
|
||||
kwargs["sums"] = list(
|
||||
Selling.objects.values("club__name")
|
||||
.annotate(
|
||||
selling_sum=Sum(
|
||||
@@ -86,4 +99,56 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
|
||||
.exclude(selling_sum=None)
|
||||
.order_by("-selling_sum")
|
||||
)
|
||||
|
||||
club_names = [i["club__name"] for i in kwargs["sums"]]
|
||||
clubs = Club.objects.filter(name__in=club_names)
|
||||
|
||||
invoice_calls = InvoiceCall.objects.filter(month=month_str, club__in=clubs)
|
||||
invoice_statuses = {ic.club.name: ic.is_validated for ic in invoice_calls}
|
||||
|
||||
kwargs["form"] = InvoiceCallForm(clubs=clubs, month=month_str)
|
||||
|
||||
kwargs["club_data"] = []
|
||||
for club in clubs:
|
||||
selling_sum = next(
|
||||
(
|
||||
item["selling_sum"]
|
||||
for item in kwargs["sums"]
|
||||
if item["club__name"] == club.name
|
||||
),
|
||||
0,
|
||||
)
|
||||
kwargs["club_data"].append(
|
||||
{
|
||||
"club": club,
|
||||
"sum": selling_sum,
|
||||
"validated": invoice_statuses.get(club.name, False),
|
||||
}
|
||||
)
|
||||
|
||||
return kwargs
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
month_str = request.POST.get("month")
|
||||
if not month_str:
|
||||
return self.get(request, *args, **kwargs)
|
||||
try:
|
||||
start_date = datetime.strptime(month_str, "%Y-%m")
|
||||
start_date = date(start_date.year, start_date.month, 1)
|
||||
except ValueError:
|
||||
return redirect(request.path)
|
||||
|
||||
selling_subquery = Selling.objects.filter(
|
||||
club=OuterRef("pk"),
|
||||
date__year=start_date.year,
|
||||
date__month=start_date.month,
|
||||
)
|
||||
|
||||
clubs = Club.objects.filter(Exists(selling_subquery))
|
||||
|
||||
form = InvoiceCallForm(request.POST, clubs=clubs, month=month_str)
|
||||
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
|
||||
return redirect(f"{request.path}?month={request.POST.get('month', '')}")
|
||||
|
@@ -17,8 +17,7 @@ document.addEventListener("alpine:init", () => {
|
||||
this.$watch("basket", () => {
|
||||
this.saveBasket();
|
||||
});
|
||||
console.log(lastPurchaseTime);
|
||||
console.log(localStorage.basketTimestamp);
|
||||
|
||||
// Invalidate basket if a purchase was made
|
||||
if (lastPurchaseTime !== null && localStorage.basketTimestamp !== undefined) {
|
||||
if (
|
||||
|
@@ -6,7 +6,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-09-13 18:32+0200\n"
|
||||
"POT-Creation-Date: 2025-09-01 18:18+0200\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"
|
||||
@@ -174,11 +174,8 @@ msgid "You can not add the same user twice"
|
||||
msgstr "Vous ne pouvez pas ajouter deux fois le même utilisateur"
|
||||
|
||||
#: club/forms.py
|
||||
msgid ""
|
||||
"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."
|
||||
msgid "You should specify a role"
|
||||
msgstr "Vous devez choisir un rôle"
|
||||
|
||||
#: club/forms.py sas/forms.py
|
||||
msgid "You do not have the permission to do that"
|
||||
@@ -329,10 +326,6 @@ msgstr "Il n'y a pas de club dans ce site web."
|
||||
msgid "Club members"
|
||||
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_old_members.jinja
|
||||
#: core/templates/core/user_clubs.jinja
|
||||
@@ -357,6 +350,11 @@ msgstr "Depuis"
|
||||
msgid "There are no members in this 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
|
||||
msgid "Club old members"
|
||||
msgstr "Anciens membres du club"
|
||||
@@ -571,12 +569,6 @@ msgstr ""
|
||||
msgid "Save"
|
||||
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
|
||||
msgid "Mailing lists"
|
||||
msgstr "Mailing listes"
|
||||
@@ -687,11 +679,6 @@ msgstr "Listes de diffusion"
|
||||
msgid "Posters list"
|
||||
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
|
||||
msgid "Format: 16:9 | Resolution: 1920x1080"
|
||||
msgstr "Format : 16:9 | Résolution : 1920x1080"
|
||||
@@ -1726,8 +1713,8 @@ msgid ""
|
||||
"AE UTBM is a voluntary organisation run by UTBM students. It organises "
|
||||
"student life at UTBM and manages its student facilities."
|
||||
msgstr ""
|
||||
"L'AE UTBM est une association bénévole gérée par les étudiants de l'UTBM. "
|
||||
"Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie."
|
||||
"L'AE UTBM est une association bénévole gérée par les étudiants de "
|
||||
"l'UTBM. Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie."
|
||||
|
||||
#: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja
|
||||
msgid "Contacts"
|
||||
@@ -2170,6 +2157,10 @@ msgstr ""
|
||||
msgid "Page history"
|
||||
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
|
||||
msgid "Page properties"
|
||||
msgstr "Propriétés de la page"
|
||||
|
Reference in New Issue
Block a user