Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
aa74b999bb [UPDATE] Update cryptography requirement
Updates the requirements on [cryptography](https://github.com/pyca/cryptography) to permit the latest version.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/45.0.3...46.0.1)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-22 08:16:38 +00:00
110 changed files with 1089 additions and 3454 deletions

View File

@@ -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,81 +188,70 @@ 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):
"""Form to add a member to the club, as a board member."""
class ClubMemberForm(forms.Form):
"""Form handling the members of a club."""
error_css_class = "error"
required_css_class = "required"
class Meta:
model = Membership
fields = ["role", "description"]
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
# 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},
)
)
# 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)
]
self.instance.club = club
).all(),
label=_("Mark as old"),
required=False,
widget=forms.CheckboxSelectMultiple,
)
if not self.request_user.is_root:
self.fields.pop("start_date")
@property
def max_available_role(self):
"""The greatest role that will be obtainable with this form."""
# this is unreachable, because it will be overridden by subclasses
return -1 # pragma: no cover
class ClubAddMemberForm(ClubMemberForm):
"""Form to add a member to the club, as a board member."""
class Meta(ClubMemberForm.Meta):
fields = ["user", *ClubMemberForm.Meta.fields]
widgets = {"user": AutoCompleteSelectUser}
@cached_property
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 cannot attribute roles with this form
"""
if self.request_user.has_perm("club.add_subscription"):
return settings.SITH_CLUB_ROLES_ID["President"]
membership = self.request_user_membership
if membership is None or membership.role <= settings.SITH_MAXIMUM_FREE_ROLE:
return -1
if membership.role == settings.SITH_CLUB_ROLES_ID["President"]:
return membership.role
return membership.role - 1
def clean_user(self):
"""Check that the user is not trying to add a user already in the club.
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"]
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"
@@ -275,30 +260,33 @@ class ClubAddMemberForm(ClubMemberForm):
raise forms.ValidationError(
_("You can not add the same user twice"), code="invalid"
)
return user
class JoinClubForm(ClubMemberForm):
"""Form to join a club."""
def __init__(self, *args, club: Club, request_user: User, **kwargs):
super().__init__(*args, club=club, request_user=request_user, **kwargs)
# this form doesn't manage the user who will join the club,
# so we must set this here to avoid errors
self.instance.user = self.request_user
@cached_property
def max_available_role(self):
return settings.SITH_MAXIMUM_FREE_ROLE
users.append(user)
return users
def clean(self):
"""Check that the user is subscribed and isn't already in the club."""
if not self.request_user.is_subscribed:
raise forms.ValidationError(
_("You must be subscribed to join a club"), code="invalid"
)
if self.club.get_membership_for(self.request_user):
raise forms.ValidationError(
_("You are already a member of this club"), code="invalid"
)
return super().clean()
"""Check user rights for adding an user."""
cleaned_data = super().clean()
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 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

View File

@@ -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
@@ -210,6 +209,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.
@@ -249,44 +252,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 edit :
- their own membership
- if they are board members, ongoing 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
- E : old member
A will be able to edit the memberships of A, C and D ;
C and D will be able to edit only their own membership ;
nobody will be able to edit E's membership.
"""
if user.has_perm("club.change_membership"):
return self.all()
return self.filter(
Q(user=user)
| Exists(
Membership.objects.filter(
Q(
role__gt=Greatest(
OuterRef("role"), Value(settings.SITH_MAXIMUM_FREE_ROLE)
)
),
user=user,
end_date=None,
club=OuterRef("club"),
)
),
end_date=None,
)
def update(self, **kwargs) -> int:
"""Refresh the cache and edit group ownership.
@@ -363,12 +328,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)

View File

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

View File

@@ -1,33 +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("club/members.scss") }}">
{% endblock %}
{% block content %}
{% block notifications %}
{# Notifications are moved a little bit below #}
{% endblock %}
<h2>{% trans %}Club members{% endtrans %}</h2>
{% if add_member_fragment %}
<br />
{{ add_member_fragment }}
<br />
{% endif %}
{% include "core/base/notifications.jinja" %}
{% 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>
@@ -36,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>
@@ -48,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 %}
@@ -73,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 %}

View File

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

View File

@@ -83,10 +83,9 @@ TODO : rewrite the pagination used in this template an Alpine one
</table>
<script type="text/javascript">
function formPagination(link){
const form = document.getElementById("form")
form.action = link.href;
$("form").attr("action", link.href);
link.href = "javascript:void(0)"; // block link action
form.submit();
$("form").submit();
}
</script>
{{ paginate(paginated_result, paginator, "formPagination(this)") }}

View File

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

View File

@@ -1,46 +0,0 @@
<section id="member-fragment-container">
{% if form.user %}
<h4>{% trans %}Add a new member{% endtrans %}</h4>
{% else %}
<h4>{% trans %}Join club{% endtrans %}</h4>
{% endif %}
<form
hx-post="{{ url('club:club_new_members', club_id=club.id) }}"
hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML"
hx-target="#member-fragment-container"
id="add_club_members_form"
>
{% csrf_token %}
{{ form.non_field_errors() }}
<fieldset>
{% if form.user %}
<div>
{{ form.user.label_tag() }}
<span class="helptext">{{ form.user.help_text }}</span>
{{ form.user }}
{{ form.user.errors }}
</div>
{% endif %}
<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>
{%- if form.user -%}
{% trans %}Add{% endtrans %}
{%- else -%}
{% trans %}Join{% endtrans %}
{%- endif -%}
</button>
</form>
</section>

View File

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

View File

@@ -1,20 +1,13 @@
from collections.abc import Callable
from datetime import timedelta
import pytest
from bs4 import BeautifulSoup
from django.conf import settings
from django.contrib.auth.models import Permission
from django.core.cache import cache
from django.db.models import Max
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 ClubAddMemberForm, JoinClubForm
from club.models import Club, Membership
from club.forms import ClubMemberForm
from club.models import Membership
from club.tests.base import TestClub
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User
@@ -144,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."""
@@ -190,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
@@ -199,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)
@@ -212,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")
@@ -240,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 = ClubAddMemberForm(
data={"user": user.id, "role": 1},
form = ClubMemberForm(
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):
@@ -310,16 +281,16 @@ class TestMembership(TestClub):
nb_memberships = self.club.members.count()
max_id = User.objects.aggregate(id=Max("id"))["id"]
for members in [max_id + 1], [max_id + 1, self.subscriber.id]:
form = ClubAddMemberForm(
data={"user": members, "role": 1},
form = ClubMemberForm(
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()
@@ -332,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
@@ -348,8 +317,8 @@ class TestMembership(TestClub):
"""Test that a member of the club member cannot create
a membership with a greater role than its own.
"""
form = ClubAddMemberForm(
data={"user": self.subscriber.id, "role": 10},
form = ClubMemberForm(
data={"users": [self.subscriber.id], "role": 10},
request_user=self.simple_board_member,
club=self.club,
)
@@ -357,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()
@@ -365,53 +334,23 @@ class TestMembership(TestClub):
def test_add_member_without_role(self):
"""Test that trying to add members without specifying their role fails."""
form = ClubAddMemberForm(
data={"user": self.subscriber.id}, request_user=self.root, club=self.club
)
assert not form.is_valid()
assert form.errors == {"role": ["Ce champ est obligatoire."]}
def test_add_member_already_there(self):
form = ClubAddMemberForm(
data={"user": self.simple_board_member, "role": 3},
request_user=self.root,
self.client.force_login(self.root)
form = ClubMemberForm(
data={"users": [self.subscriber.id]},
request_user=self.simple_board_member,
club=self.club,
)
assert not form.is_valid()
assert form.errors == {
"user": ["Vous ne pouvez pas ajouter deux fois le même utilisateur"]
}
def test_add_other_member_forbidden(self):
non_member = subscriber_user.make()
simple_member = baker.make(Membership, club=self.club, role=1).user
for user in non_member, simple_member:
form = ClubAddMemberForm(
data={"user": subscriber_user.make(), "role": 1},
request_user=user,
club=self.club,
)
assert not form.is_valid()
assert form.errors == {
"role": ["Sélectionnez un choix valide. 1 n\u2019en fait pas partie."]
}
def test_simple_members_dont_see_form_anymore(self):
"""Test that simple club members don't see the form to add members"""
user = subscriber_user.make()
baker.make(Membership, club=self.club, user=user, role=1)
self.client.force_login(user)
res = self.client.get(self.members_url)
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
assert not soup.find(id="add_club_members_form")
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)
@@ -419,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
@@ -433,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):
@@ -464,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."""
@@ -530,85 +490,3 @@ class TestMembership(TestClub):
new_board = set(self.club.board_group.users.values_list("id", flat=True))
assert new_members == initial_members
assert new_board == initial_board
@pytest.mark.django_db
class TestJoinClub:
@pytest.fixture(autouse=True)
def clear_cache(self):
cache.clear()
@pytest.mark.parametrize(
("user_factory", "role", "errors"),
[
(
subscriber_user.make,
2,
{
"role": [
"Sélectionnez un choix valide. 2 n\u2019en fait pas partie."
]
},
),
(
lambda: baker.make(User),
1,
{"__all__": ["Vous devez être cotisant pour faire partie d'un club"]},
),
],
)
def test_join_club_errors(
self, user_factory: Callable[[], User], role: int, errors: dict
):
club = baker.make(Club)
user = user_factory()
form = JoinClubForm(club=club, request_user=user, data={"role": role})
assert not form.is_valid()
assert form.errors == errors
def test_user_already_in_club(self):
club = baker.make(Club)
user = subscriber_user.make()
baker.make(Membership, user=user, club=club)
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
assert not form.is_valid()
assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]}
def test_ok(self):
club = baker.make(Club)
user = subscriber_user.make()
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
assert form.is_valid()
form.save()
assert Membership.objects.ongoing().filter(user=user, club=club).exists()
class TestOldMembersView(TestCase):
@classmethod
def setUpTestData(cls):
club = baker.make(Club)
roles = [1, 1, 1, 2, 2, 4, 4, 5, 7, 9, 10]
cls.memberships = baker.make(
Membership,
role=iter(roles),
club=club,
start_date=now() - timedelta(days=14),
end_date=now() - timedelta(days=7),
_quantity=len(roles),
_bulk_create=True,
)
cls.url = reverse("club:club_old_members", kwargs={"club_id": club.id})
def test_ok(self):
user = subscriber_user.make()
self.client.force_login(user)
res = self.client.get(self.url)
assert res.status_code == 200
def test_access_forbidden(self):
res = self.client.get(self.url)
assertRedirects(res, reverse("core:login", query={"next": self.url}))
self.client.force_login(baker.make(User))
res = self.client.get(self.url)
assert res.status_code == 403

View File

@@ -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(),

View File

@@ -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,28 +37,20 @@ 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
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.forms import (
ClubAddMemberForm,
ClubAdminEditForm,
ClubEditForm,
ClubOldMemberForm,
JoinClubForm,
ClubMemberForm,
MailingForm,
SellingsForm,
)
from club.models import (
Club,
Mailing,
MailingSubscription,
Membership,
)
from club.models import Club, Mailing, MailingSubscription, Membership
from com.models import Poster
from com.views import (
PosterCreateBaseView,
@@ -70,10 +60,11 @@ from com.views import (
)
from core.auth.mixins import (
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
@@ -95,7 +86,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(
[
{
@@ -244,14 +235,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):
@@ -262,128 +252,58 @@ class ClubToolsView(ClubTabsMixin, CanEditMixin, DetailView):
template_name = "club/club_tools.jinja"
current_tab = "tools"
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"reservable_rooms": list(self.object.reservable_rooms.all()),
"counters": list(self.object.counters.filter(type="OFFICE")),
}
class ClubAddMembersFragment(
FragmentMixin, PermissionRequiredMixin, SuccessMessageMixin, CreateView
):
template_name = "club/fragments/add_member.jinja"
model = Membership
object = None
reload_on_redirect = True
permission_required = "club.view_club"
def dispatch(self, *args, **kwargs):
self.club = get_object_or_404(Club, pk=kwargs.get("club_id"))
return super().dispatch(*args, **kwargs)
def get_form_class(self):
user = self.request.user
if user.has_perm("club.add_membership") or self.club.get_membership_for(user):
return ClubAddMemberForm
return JoinClubForm
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}
def get_success_message(self, cleaned_data):
if "user" not in cleaned_data or cleaned_data["user"] == self.request.user:
return _("You are now a member of this club.")
return _("%(user)s has been added to club.") % cleaned_data
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
and not self.request.user.has_perm("club.add_membership")
):
# Simple club members won't see the form anymore.
# Even if they saw it, they couldn't add anyone to the club anyway
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):
return super().get_form_kwargs() | {
"user": self.request.user,
"club": self.object,
}
kwargs = super().get_form_kwargs()
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):
@@ -424,7 +344,7 @@ class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
form = self.get_form()
if form.is_valid():
if not len([v for v in form.cleaned_data.values() if v is not None]):
qs = Selling.objects.none()
qs = Selling.objects.filter(id=-1)
if form.cleaned_data["begin_date"]:
qs = qs.filter(date__gte=form.cleaned_data["begin_date"])
if form.cleaned_data["end_date"]:
@@ -442,9 +362,7 @@ class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
if len(selected_products) > 0:
qs = qs.filter(product__in=selected_products)
kwargs["result"] = qs.select_related(
"counter", "counter__club", "customer", "customer__user", "seller"
).order_by("-id")
kwargs["result"] = qs.all().order_by("-id")
kwargs["total"] = sum([s.quantity * s.unit_price for s in kwargs["result"]])
total_quantity = qs.all().aggregate(Sum("quantity"))
if total_quantity["quantity__sum"]:

View File

@@ -1,49 +0,0 @@
const INTERVAL = 10;
interface Poster {
url: string; // URL of the poster
displayTime: number; // Number of seconds to display that poster
}
document.addEventListener("alpine:init", () => {
Alpine.data("slideshow", (posters: Poster[]) => ({
posters: posters,
progress: 0,
elapsed: 0,
current: 0,
previous: 0,
init() {
this.$watch("elapsed", () => {
const displayTime = this.posters[this.current].displayTime * 1000;
if (this.elapsed > displayTime) {
this.previous = this.current;
this.current = this.getNext();
this.elapsed = 0;
}
if (displayTime === 0) {
this.progress = 100;
} else {
this.progress = (100 * this.elapsed) / displayTime;
}
});
setInterval(() => {
this.elapsed += INTERVAL;
}, INTERVAL);
},
getNext() {
return (this.current + 1) % this.posters.length;
},
async toggleFullScreen(event: Event) {
if (document.fullscreenElement) {
await document.exitFullscreen();
return;
}
const target = event.target as HTMLElement;
await target.requestFullscreen();
},
}));
});

View File

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

View File

@@ -81,9 +81,9 @@
}
#links_content {
overflow: auto;
box-shadow: $shadow-color 1px 1px 1px;
min-height: 20em;
padding-bottom: 1em;
height: 20em;
h4 {
margin-left: 5px;

View File

@@ -111,7 +111,7 @@
top: 0;
left: 0;
z-index: 10;
content: attr(hover);
content: "Click to expand";
color: white;
background-color: rgba(black, 0.5);
}

View File

@@ -0,0 +1,23 @@
$(document).ready(() => {
$("#poster_list #view").click(() => {
$("#view").removeClass("active");
});
$("#poster_list .poster .image").click((e) => {
let el = $(e.target);
if (el.hasClass("image")) {
el = el.find("img");
}
$("#poster_list #view #placeholder").html(el.clone());
$("#view").addClass("active");
});
$(document).keyup((e) => {
if (e.keyCode === 27) {
// escape key maps to keycode `27`
e.preventDefault();
$("#view").removeClass("active");
}
});
});

View File

@@ -0,0 +1,98 @@
$(document).ready(() => {
const transitionTime = 1000;
let i = 0;
const max = $("#slideshow .slide").length;
function enterFullscreen() {
const element = document.getElementById("slideshow");
$(element).addClass("fullscreen");
if (element.requestFullscreen) {
element.requestFullscreen();
} else if (element.mozRequestFullScreen) {
element.mozRequestFullScreen();
} else if (element.webkitRequestFullscreen) {
element.webkitRequestFullscreen();
} else if (element.msRequestFullscreen) {
element.msRequestFullscreen();
}
}
function exitFullscreen() {
const element = document.getElementById("slideshow");
$(element).removeClass("fullscreen");
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
}
}
function initProgressBar() {
$("#slideshow #progress_bar").css("transition", "none");
$("#slideshow #progress_bar").removeClass("progress");
$("#slideshow #progress_bar").addClass("init");
}
function startProgressBar(displayTime) {
$("#slideshow #progress_bar").removeClass("init");
$("#slideshow #progress_bar").addClass("progress");
$("#slideshow #progress_bar").css("transition", `width ${displayTime}s linear`);
}
function next() {
initProgressBar();
const slide = $($("#slideshow .slide").get(i % max));
slide.removeClass("center");
slide.addClass("left");
const nextSlide = $($("#slideshow .slide").get((i + 1) % max));
nextSlide.removeClass("right");
nextSlide.addClass("center");
const displayTime = nextSlide.attr("display_time") || 2;
$("#slideshow .bullet").removeClass("active");
const bullet = $("#slideshow .bullet")[(i + 1) % max];
$(bullet).addClass("active");
i = (i + 1) % max;
setTimeout(() => {
const othersLeft = $("#slideshow .slide.left");
othersLeft.removeClass("left");
othersLeft.addClass("right");
startProgressBar(displayTime);
setTimeout(next, displayTime * 1000);
}, transitionTime);
}
const displayTime = $("#slideshow .center").attr("display_time");
initProgressBar();
setTimeout(() => {
if (max > 1) {
startProgressBar(displayTime);
setTimeout(next, displayTime * 1000);
}
}, 10);
$("#slideshow").click(() => {
if ($("#slideshow").hasClass("fullscreen")) {
exitFullscreen();
} else {
enterFullscreen();
}
});
$(document).keyup((e) => {
if (e.keyCode === 27) {
// escape key maps to keycode `27`
e.preventDefault();
exitFullscreen();
}
});
});

View File

@@ -1,4 +1,4 @@
body {
body{
position: absolute;
width: 100vw;
height: 100vh;
@@ -7,22 +7,22 @@ body {
margin: 0;
}
#slideshow {
#slideshow{
position: relative;
background-color: lightgrey;
height: 100%;
* {
*{
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
&:hover {
&:hover{
&::before {
&::before{
position: absolute;
width: 100%;
@@ -34,7 +34,7 @@ body {
z-index: 10;
content: attr(hover);
content: "Click to expand";
color: white;
background-color: rgba(black, 0.5);
@@ -43,7 +43,7 @@ body {
}
&:fullscreen {
&.fullscreen{
position: fixed;
width: 100%;
height: 100%;
@@ -51,78 +51,57 @@ body {
left: 0;
background: none;
&:before {
display: none;
&:before{
display:none;
}
#slides {
#slides{
height: 100vh;
}
}
#slides {
#slides{
position: relative;
height: 100%;
overflow: hidden;
background-color: grey;
.slide {
.slide{
position: absolute;
width: 100%;
height: 100%;
display: none;
display: inline-flex;
justify-content: center;
top: 0px;
left: 0%;
img {
background-color: grey;
transition: left 1s ease-out;
img{
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
&.current {
display: inline-flex;
left: 0%;
animation: scrolling-in 1s linear;
}
&.previous {
display: inline-flex;
animation: scrolling-out 1s linear;
opacity: 0;
transition: opacity 0.1s;
transition-delay: 0.9s;
.slide.left{
left: -100%;
}
@keyframes scrolling-in {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(0%);
}
}
@keyframes scrolling-out {
0% {
transform: translateX(0%);
}
100% {
transform: translateX(-100%);
}
.slide.center{
left: 0px;
}
.slide.right{
left: 100%;
transition: none;
}
}
#progress_bullets {
#progress_bullets{
position: absolute;
bottom: 10px;
width: 100%;
@@ -133,7 +112,7 @@ body {
margin-bottom: 10px;
.bullet {
.bullet{
height: 10px;
width: 10px;
@@ -144,33 +123,27 @@ body {
background-color: grey;
&.active {
&.active{
background-color: #c99836;
}
}
}
progress {
--color: #304c83;
#progress_bar{
position: absolute;
bottom: 0px;
height: 10px;
color: var(--color);
background-color: #304c83;
&.init{
width: 0px;
transition: none;
}
&.progress{
width: 100%;
margin-bottom: 0px;
border: none;
&::-moz-progress-bar {
background: var(--color);
}
&::-webkit-progress-value {
background: var(--color);
}
&[value] {
background-color: transparent;
transition: width 10s linear;
}
}
}

View File

@@ -76,20 +76,18 @@
It will stay hidden for other users until it has been published.
{% endtrans %}
</p>
{%- if user.has_perm("com.moderate_news") -%}
{% if user.has_perm("com.moderate_news") %}
{# This is an additional query for each non-moderated news,
but it will be executed only for admin users, and only one time
(if they do their job and moderate news as soon as they see them),
(if they do their job and moderated news as soon as they see them),
so it's still reasonable #}
<div
{% if news is integer or news is string -%}
{% if news is integer or news is string %}
x-data="{ nbEvents: 0 }"
x-init="nbEvents = await nbToPublish()"
{%- elif news.is_published -%}
x-data="{ nbEvents: 0 }"
{%- else -%}
{% else %}
x-data="{ nbEvents: {{ news.dates.count() }} }"
{%- endif -%}
{% endif %}
>
<template x-if="nbEvents > 1">
<div>

View File

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

View File

@@ -1,5 +1,11 @@
{% extends "core/base.jinja" %}
{% block script %}
{{ super() }}
<script src="{{ static('com/js/poster_list.js') }}"></script>
{% endblock %}
{% block title %}
{% trans %}Poster{% endtrans %}
{% endblock %}
@@ -9,7 +15,7 @@
{% endblock %}
{% block content %}
<div id="poster_list" x-data="{ active: null }">
<div id="poster_list">
<div id="title">
<h3>{% trans %}Posters{% endtrans %}</h3>
@@ -32,13 +38,7 @@
{% for poster in poster_list %}
<div class="poster{% if not poster.is_moderated %} not_moderated{% endif %}">
<div class="name">{{ poster.name }}</div>
<div
class="image"
hover="{% trans %}Click to expand{% endtrans %}"
@click="active = $el.firstElementChild"
>
<img src="{{ poster.file.url }}"></img>
</div>
<div class="image"><img src="{{ poster.file.url }}"></img></div>
<div class="dates">
<div class="begin">{{ poster.date_begin | localtime | date("d/M/Y H:m") }}</div>
<div class="end">{{ poster.date_end | localtime | date("d/M/Y H:m") }}</div>
@@ -62,14 +62,7 @@
</div>
<div
id="view"
@keyup.escape.window="active = null"
@click="active = null"
:class="{active: active !== null}"
>
<div id="placeholder"><img :src="active?.src"></div>
</div>
<div id="view"><div id="placeholder"></div></div>
</div>
{% endblock %}

View File

@@ -2,44 +2,28 @@
<html lang="fr">
<head>
<title>{% trans %}Slideshow{% endtrans %}</title>
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script type="module" src="{{ static('bundled/com/slideshow-index.ts') }}"></script>
<script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
<script src="{{ static('com/js/slideshow.js') }}"></script>
</head>
<body x-data="slideshow([
{% for poster in posters %}
{
url: '{{ poster.file.url }}',
displayTime: {{ poster.display_time }}
},
{% endfor %}
])">
<div
id="slideshow"
@click="toggleFullScreen"
hover="{% trans %}Click to expand{% endtrans %}"
@keyup.f.window="toggleFullScreen"
>
<body>
<div id="slideshow">
<div id="slides">
<template x-for="(poster, index) in posters">
<div class="slide" :class="{
current: index === current,
previous: index !== current && index === previous,
}">
<img :src="poster.url">
{% for poster in posters %}
<div class="slide {% if loop.first %}center{% else %}right{% endif %}" display_time="{{ poster.display_time }}">
<img src="{{ poster.file.url }}">
</div>
</template>
{% endfor %}
</div>
<div id="progress_bullets">
<template x-for="(poster, index) in posters">
<div class="bullet" :class="{active: current === index}"></div>
</template>
{% for poster in posters %}
<div class="bullet {% if loop.first %}active{% endif %}"></div>
{% endfor %}
</div>
<progress :value="progress" max="100" x-show="posters.length > 1 && progress > 0"></progress>
<div id="progress_bar"></div>
</div>
</body>

View File

@@ -31,7 +31,9 @@
<td>
<a href="{{ url('com:weekmail_article_edit', article_id=a.id) }}">{% trans %}Edit{% endtrans %}</a> |
<a href="{{ url('com:weekmail_article_delete', article_id=a.id) }}">{% trans %}Delete{% endtrans %}</a> |
<a href="?add_article={{ a.id }}">{% trans %}Add to weekmail{% endtrans %}</a>
<a href="?add_article={{ a.id }}">{% trans %}Add to weekmail{% endtrans %}</a> |
<a href="?up_article={{ a.id }}">{% trans %}Up{% endtrans %}</a> |
<a href="?down_article={{ a.id }}">{% trans %}Down{% endtrans %}</a>
</td>
</tr>
{% endfor %}

View File

@@ -28,7 +28,6 @@ from typing import Any
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import (
PermissionRequiredMixin,
)
@@ -56,7 +55,7 @@ from core.auth.mixins import (
PermissionOrClubBoardRequiredMixin,
)
from core.models import User
from core.views.mixins import TabedViewMixin
from core.views.mixins import QuickNotifMixin, TabedViewMixin
from core.views.widgets.markdown import MarkdownInput
# Sith object
@@ -334,7 +333,7 @@ class NewsFeed(Feed):
# Weekmail
class WeekmailPreviewView(ComTabsMixin, CanEditPropMixin, DetailView):
class WeekmailPreviewView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, DetailView):
model = Weekmail
template_name = "com/weekmail_preview.jinja"
success_url = reverse_lazy("com:weekmail")
@@ -346,11 +345,12 @@ class WeekmailPreviewView(ComTabsMixin, CanEditPropMixin, DetailView):
def post(self, request, *args, **kwargs):
self.object = self.get_object()
messages.success(self.request, _("Weekmail sent successfully"))
if request.POST["send"] == "validate":
try:
self.object.send()
return HttpResponseRedirect(reverse("com:weekmail"))
return HttpResponseRedirect(
reverse("com:weekmail") + "?qn_weekmail_send_success"
)
except SMTPRecipientsRefused as e:
self.bad_recipients = e.recipients
elif request.POST["send"] == "clean":
@@ -361,6 +361,7 @@ class WeekmailPreviewView(ComTabsMixin, CanEditPropMixin, DetailView):
for u in users:
u.preferences.receive_weekmail = False
u.preferences.save()
self.quick_notif_list += ["qn_success"]
return super().get(request, *args, **kwargs)
def get_object(self, queryset=None):
@@ -374,7 +375,7 @@ class WeekmailPreviewView(ComTabsMixin, CanEditPropMixin, DetailView):
return kwargs
class WeekmailEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateView):
model = Weekmail
template_name = "com/weekmail.jinja"
form_class = modelform_factory(
@@ -414,10 +415,7 @@ class WeekmailEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
art.rank, prev_art.rank = prev_art.rank, art.rank
art.save()
prev_art.save()
messages.success(
self.request,
_("%(title)s moved up in the Weekmail") % {"title": art.title},
)
self.quick_notif_list += ["qn_success"]
if "down_article" in request.GET:
art = get_object_or_404(
WeekmailArticle, id=request.GET["down_article"], weekmail=self.object
@@ -429,10 +427,7 @@ class WeekmailEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
art.rank, next_art.rank = next_art.rank, art.rank
art.save()
next_art.save()
messages.success(
self.request,
_("%(title)s moved down in the Weekmail") % {"title": art.title},
)
self.quick_notif_list += ["qn_success"]
if "add_article" in request.GET:
art = get_object_or_404(
WeekmailArticle, id=request.GET["add_article"], weekmail=None
@@ -441,10 +436,7 @@ class WeekmailEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
art.rank = self.object.articles.aggregate(Max("rank"))["rank__max"] or 0
art.rank += 1
art.save()
messages.success(
self.request,
_("%(title)s added to the Weekmail") % {"title": art.title},
)
self.quick_notif_list += ["qn_success"]
if "del_article" in request.GET:
art = get_object_or_404(
WeekmailArticle, id=request.GET["del_article"], weekmail=self.object
@@ -452,10 +444,7 @@ class WeekmailEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
art.weekmail = None
art.rank = -1
art.save()
messages.success(
self.request,
_("%(title)s removed from the Weekmail") % {"title": art.title},
)
self.quick_notif_list += ["qn_success"]
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
@@ -465,7 +454,9 @@ class WeekmailEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
return kwargs
class WeekmailArticleEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
class WeekmailArticleEditView(
ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateView
):
"""Edit an article."""
model = WeekmailArticle
@@ -477,10 +468,11 @@ class WeekmailArticleEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
pk_url_kwarg = "article_id"
template_name = "core/edit.jinja"
success_url = reverse_lazy("com:weekmail")
quick_notif_url_arg = "qn_weekmail_article_edit"
current_tab = "weekmail"
class WeekmailArticleCreateView(CreateView):
class WeekmailArticleCreateView(QuickNotifMixin, CreateView):
"""Post an article."""
model = WeekmailArticle
@@ -491,6 +483,7 @@ class WeekmailArticleCreateView(CreateView):
)
template_name = "core/create.jinja"
success_url = reverse_lazy("core:user_tools")
quick_notif_url_arg = "qn_weekmail_new_article"
def get_initial(self):
if "club" not in self.request.GET:

View File

@@ -768,7 +768,7 @@ class Command(BaseCommand):
s = Subscription(
member=user,
subscription_type=subscription_type,
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
)
s.subscription_start = s.compute_start(start)
s.subscription_end = s.compute_end(
@@ -789,11 +789,7 @@ class Command(BaseCommand):
subscribers = Group.objects.create(name="Cotisants")
subscribers.permissions.add(
*list(
perms.filter(
codename__in=["add_news", "add_uvcomment", "view_reservationslot"]
)
)
*list(perms.filter(codename__in=["add_news", "add_uvcomment"]))
)
old_subscribers = Group.objects.create(name="Anciens cotisants")
old_subscribers.permissions.add(

View File

@@ -1,7 +1,6 @@
import random
from datetime import date, timedelta
from datetime import timezone as tz
from math import ceil
from typing import Iterator
from dateutil.relativedelta import relativedelta
@@ -25,7 +24,6 @@ from counter.models import (
)
from forum.models import Forum, ForumMessage, ForumTopic
from pedagogy.models import UV
from reservation.models import ReservationSlot, Room
from subscription.models import Subscription
@@ -42,20 +40,45 @@ class Command(BaseCommand):
self.stdout.write("Creating users...")
users = self.create_users()
# len(subscribers) is approximately 480
subscribers = random.sample(users, k=int(0.8 * len(users)))
self.stdout.write("Creating subscriptions...")
self.create_subscriptions(subscribers)
self.stdout.write("Creating club memberships...")
self.create_club_memberships(subscribers)
self.stdout.write("Creating rooms and reservation...")
self.create_resources_and_reservations(random.sample(subscribers, k=40))
users_qs = User.objects.filter(id__in=[s.id for s in subscribers])
subscribers_now = list(
users_qs.annotate(
filter=Exists(
Subscription.objects.filter(
member_id=OuterRef("pk"), subscription_end__gte=now()
)
)
)
)
old_subscribers = list(
users_qs.annotate(
filter=Exists(
Subscription.objects.filter(
member_id=OuterRef("pk"), subscription_end__lt=now()
)
)
)
)
self.make_club(
Club.objects.get(id=settings.SITH_MAIN_CLUB_ID),
random.sample(subscribers_now, k=min(30, len(subscribers_now))),
random.sample(old_subscribers, k=min(60, len(old_subscribers))),
)
self.make_club(
Club.objects.get(name="Troll Penché"),
random.sample(subscribers_now, k=min(20, len(subscribers_now))),
random.sample(old_subscribers, k=min(80, len(old_subscribers))),
)
self.stdout.write("Creating uvs...")
self.create_uvs()
self.stdout.write("Creating products...")
self.create_products()
self.stdout.write("Creating sales and refills...")
sellers = list(User.objects.order_by("?")[:100])
sellers = random.sample(list(User.objects.all()), 100)
self.create_sales(sellers)
self.stdout.write("Creating permanences...")
self.create_permanences(sellers)
@@ -169,97 +192,6 @@ class Command(BaseCommand):
memberships = Membership.objects.bulk_create(memberships)
Membership._add_club_groups(memberships)
def create_club_memberships(self, users: list[User]):
users_qs = User.objects.filter(id__in=[s.id for s in users])
subscribers_now = list(
users_qs.annotate(
filter=Exists(
Subscription.objects.filter(
member_id=OuterRef("pk"), subscription_end__gte=now()
)
)
)
)
old_subscribers = list(
users_qs.annotate(
filter=Exists(
Subscription.objects.filter(
member_id=OuterRef("pk"), subscription_end__lt=now()
)
)
)
)
self.make_club(
Club.objects.get(id=settings.SITH_MAIN_CLUB_ID),
random.sample(subscribers_now, k=min(30, len(subscribers_now))),
random.sample(old_subscribers, k=min(60, len(old_subscribers))),
)
self.make_club(
Club.objects.get(name="Troll Penché"),
random.sample(subscribers_now, k=min(20, len(subscribers_now))),
random.sample(old_subscribers, k=min(80, len(old_subscribers))),
)
def create_resources_and_reservations(self, users: list[User]):
"""Generate reservable rooms and reservations slots for those rooms.
Contrary to the other data generator,
this one generates more data than what is expected on the real db.
"""
ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID)
pdf = Club.objects.get(id=settings.SITH_PDF_CLUB_ID)
troll = Club.objects.get(name="Troll Penché")
rooms = [
Room(
name=name,
club=club,
location=location,
description=self.faker.text(100),
)
for name, club, location in [
("Champi", ae, "BELFORT"),
("Muzik", ae, "BELFORT"),
("Pôle Tech", ae, "BELFORT"),
("Jolly", troll, "BELFORT"),
("Cookut", pdf, "BELFORT"),
("Lucky", pdf, "BELFORT"),
("Potards", pdf, "SEVENANS"),
("Bureau AE", ae, "SEVENANS"),
]
]
rooms = Room.objects.bulk_create(rooms)
reservations = []
for room in rooms:
# how much people use this room.
# The higher the number, the more reservations exist,
# the smaller the interval between two slot is,
# and the more future reservations have already been made ahead of time
affluence = random.randint(2, 6)
slot_start = make_aware(self.faker.past_datetime("-5y").replace(minute=0))
generate_until = make_aware(
self.faker.future_datetime(timedelta(days=1) * affluence**2)
)
while slot_start < generate_until:
if slot_start.hour < 8:
# if a reservation would start in the middle of the night
# make it start the next morning instead
slot_start += timedelta(hours=10 - slot_start.hour)
duration = timedelta(minutes=15) * (1 + int(random.gammavariate(3, 2)))
reservations.append(
ReservationSlot(
room=room,
author=random.choice(users),
start_at=slot_start,
end_at=slot_start + duration,
created_at=slot_start - self.faker.time_delta("+7d"),
)
)
slot_start += duration + (
timedelta(minutes=15) * ceil(random.expovariate(affluence / 192))
)
reservations.sort(key=lambda slot: slot.created_at)
ReservationSlot.objects.bulk_create(reservations)
def create_uvs(self):
root = User.objects.get(username="root")
categories = ["CS", "TM", "OM", "QC", "EC"]
@@ -457,7 +389,7 @@ class Command(BaseCommand):
Permanency.objects.bulk_create(perms)
def create_forums(self):
forumers = list(User.objects.order_by("?")[:100])
forumers = random.sample(list(User.objects.all()), 100)
most_actives = random.sample(forumers, 10)
categories = list(Forum.objects.filter(is_category=True))
new_forums = [
@@ -475,7 +407,7 @@ class Command(BaseCommand):
for _ in range(100)
]
ForumTopic.objects.bulk_create(new_topics)
topics = list(ForumTopic.objects.values_list("id", flat=True))
topics = list(ForumTopic.objects.all())
def get_author():
if random.random() > 0.5:
@@ -483,7 +415,7 @@ class Command(BaseCommand):
return random.choice(forumers)
messages = []
for topic_id in topics:
for t in topics:
nb_messages = max(1, int(random.normalvariate(mu=90, sigma=50)))
dates = sorted(
[
@@ -495,7 +427,7 @@ class Command(BaseCommand):
messages.extend(
[
ForumMessage(
topic_id=topic_id,
topic=t,
author=get_author(),
date=d,
message="\n\n".join(

View File

@@ -651,6 +651,9 @@ class User(AbstractUser):
class AnonymousUser(AuthAnonymousUser):
def __init__(self):
super().__init__()
@property
def was_subscribed(self):
return False
@@ -659,6 +662,10 @@ class AnonymousUser(AuthAnonymousUser):
def is_subscribed(self):
return False
@property
def subscribed(self):
return False
@property
def is_root(self):
return False
@@ -1190,18 +1197,6 @@ class NotLocked(LockError):
pass
class PageQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
if user.is_anonymous:
return self.filter(view_groups=settings.SITH_GROUP_PUBLIC_ID)
if user.has_perm("core.view_page"):
return self.all()
groups_ids = [g.id for g in user.cached_groups]
if user.is_subscribed:
groups_ids.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
return self.filter(view_groups__in=groups_ids)
# This function prevents generating migration upon settings change
def get_default_owner_group():
return settings.SITH_GROUP_ROOT_ID
@@ -1271,8 +1266,6 @@ class Page(models.Model):
_("lock_timeout"), null=True, blank=True, default=None
)
objects = PageQuerySet.as_manager()
class Meta:
unique_together = ("name", "parent")
permissions = (
@@ -1282,9 +1275,12 @@ class Page(models.Model):
def __str__(self):
return self.get_full_name()
def save(self, *args, force_lock: bool = False, **kwargs):
def save(self, *args, **kwargs):
"""Performs some needed actions before and after saving a page in database."""
if not force_lock and not self.is_locked():
locked = kwargs.pop("force_lock", False)
if not locked:
locked = self.is_locked()
if not locked:
raise NotLocked("The page is not locked and thus can not be saved")
self.full_clean()
if not self.id:
@@ -1296,7 +1292,7 @@ class Page(models.Model):
# It also update all the children to maintain correct names
self._full_name = self.get_full_name()
for c in self.children.all():
c.save(force_lock=force_lock)
c.save()
super().save(*args, **kwargs)
self.unset_lock()
@@ -1412,14 +1408,14 @@ class Page(models.Model):
def need_club_redirection(self):
return self.is_club_page and self.name != settings.SITH_CLUB_ROOT_PAGE
def delete(self, *args, **kwargs):
def delete(self):
self.unset_lock_recursive()
self.set_lock_recursive(User.objects.get(id=0))
for child in self.children.all():
child.parent = self.parent
child.save()
child.unset_lock_recursive()
return super().delete(*args, **kwargs)
super().delete()
class PageRev(models.Model):
@@ -1466,12 +1462,9 @@ class PageRev(models.Model):
def get_absolute_url(self):
return reverse("core:page", kwargs={"page_name": self.page._full_name})
def can_be_edited_by(self, user: User) -> bool:
def can_be_edited_by(self, user):
return self.page.can_be_edited_by(user)
def is_owned_by(self, user: User) -> bool:
return any(g.id == self.page.owner_group_id for g in user.cached_groups)
def get_notification_types():
return settings.SITH_NOTIFICATIONS

View File

@@ -1,10 +1,7 @@
import { alpinePlugin } from "#core:utils/notifications";
import { morph } from "@alpinejs/morph";
import sort from "@alpinejs/sort";
import Alpine from "alpinejs";
Alpine.plugin([sort, morph]);
Alpine.magic("notifications", alpinePlugin);
Alpine.plugin(sort);
window.Alpine = Alpine;
window.addEventListener("DOMContentLoaded", () => {

View File

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

View File

@@ -1,36 +0,0 @@
export enum NotificationLevel {
Error = "error",
Warning = "warning",
Success = "success",
}
export function createNotification(message: string, level: NotificationLevel) {
const element = document.getElementById("quick-notifications");
if (element === null) {
return false;
}
return element.dispatchEvent(
new CustomEvent("quick-notification-add", {
detail: { text: message, tag: level },
}),
);
}
export function deleteNotifications() {
const element = document.getElementById("quick-notifications");
if (element === null) {
return false;
}
return element.dispatchEvent(new CustomEvent("quick-notification-delete"));
}
export function alpinePlugin() {
return {
error: (message: string) => createNotification(message, NotificationLevel.Error),
warning: (message: string) =>
createNotification(message, NotificationLevel.Warning),
success: (message: string) =>
createNotification(message, NotificationLevel.Success),
clear: () => deleteNotifications(),
};
}

View File

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

View File

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

View File

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

View File

@@ -321,6 +321,7 @@ $hovered-red-text-color: #ff4d4d;
>#header_notif {
box-sizing: border-box;
display: none;
position: absolute;
margin: 0;
background-color: whitesmoke;

View File

@@ -0,0 +1,38 @@
$(() => {
$("#quick_notif li").click(function () {
$(this).hide();
});
});
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function createQuickNotif(msg) {
const el = document.createElement("li");
el.textContent = msg;
el.addEventListener("click", () => el.parentNode.removeChild(el));
document.getElementById("quick_notif").appendChild(el);
}
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function deleteQuickNotifs() {
const el = document.getElementById("quick_notif");
while (el.firstChild) {
el.removeChild(el.firstChild);
}
}
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function displayNotif() {
$("#header_notif").toggle().parent().toggleClass("white");
}
// You can't get the csrf token from the template in a widget
// We get it from a cookie as a workaround, see this link
// https://docs.djangoproject.com/en/2.0/ref/csrf/#ajax
// Sadly, getting the cookie is not possible with CSRF_COOKIE_HTTPONLY or CSRF_USE_SESSIONS is True
// So, the true workaround is to get the token from the dom
// https://docs.djangoproject.com/en/2.0/ref/csrf/#acquiring-the-token-if-csrf-use-sessions-is-true
// biome-ignore lint/style/useNamingConvention: can't find it used anywhere but I will not play with the devil
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function getCSRFToken() {
return $("[name=csrfmiddlewaretoken]").val();
}

View File

@@ -270,6 +270,17 @@ body {
}
/*--------------------------------CONTENT------------------------------*/
#quick_notif {
width: 100%;
margin: 0 auto;
list-style-type: none;
background: $second-color;
li {
padding: 10px;
}
}
#content {
padding: 1em 1%;
box-shadow: $shadow-color 0 5px 10px;
@@ -506,10 +517,6 @@ th {
>ul {
margin-top: 0;
}
>input[type="checkbox"] {
padding: unset;
}
}
td {

View File

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

View File

@@ -32,6 +32,10 @@
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/core/tooltips-index.ts') }}"></script>
<!-- Jquery declared here to be accessible in every django widgets -->
<script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
<script src="{{ static('core/js/script.js') }}"></script>
{% block additional_css %}{% endblock %}
{% block additional_js %}{% endblock %}
{% endblock %}
@@ -70,15 +74,17 @@
<div id="page">
<ul id="quick_notif">
{% for n in quick_notifs %}
<li>{{ n }}</li>
{% endfor %}
</ul>
<div id="content">
{%- block tabs -%}
{% include "core/base/tabs.jinja" %}
{%- endblock -%}
{% block notifications %}
{% include "core/base/notifications.jinja" %}
{% endblock %}
{%- block errors -%}
{% if error %}
{{ error }}
@@ -95,6 +101,16 @@
{% endblock %}
{% block script %}
<script>
document.addEventListener("keydown", (e) => {
// Looking at the `s` key when not typing in a form
if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) {
return;
}
document.getElementById("search").focus();
e.preventDefault(); // Don't type the character in the focused search input
})
</script>
{% endblock %}
</body>
</html>

View File

@@ -74,25 +74,25 @@
{% endif %}
></a>
</div>
<div class="notification" x-data="{display: false}" :class="{white: display}">
<a href="#" @click.prevent="display = !display">
<i :class="`fa-${display ? 'solid': 'regular'} fa-bell`" x-transition></i>
{% set notifications = user.notifications.filter(viewed=False).order_by("-date")|list %}
<div class="notification">
<a href="#" onclick="displayNotif()">
<i class="fa-regular fa-bell"></i>
{% set notification_count = user.notifications.filter(viewed=False).count() %}
{%- if notifications|length > 0 -%}
{% if notification_count > 0 %}
<span>
{% if notifications|length < 100 %}
{{ notifications|length }}
{%- else -%}
99+
{%- endif -%}
{% if notification_count < 100 %}
{{ notification_count }}
{% else %}
&nbsp;
{% endif %}
</span>
{% endif %}
</a>
<div id="header_notif" x-show="display" x-cloak x-transition @click.outside="display = false">
<div id="header_notif">
<ul>
{%- if notifications|length > 0 -%}
{%- for n in notifications -%}
{% if user.notifications.filter(viewed=False).count() > 0 %}
{% for n in user.notifications.filter(viewed=False).order_by('-date') %}
<li>
<a href="{{ url("core:notification", notif_id=n.id) }}">
<div class="datetime">
@@ -108,10 +108,10 @@
</div>
</a>
</li>
{%- endfor -%}
{%- else -%}
{% endfor %}
{% else %}
<li class="empty-notification">{% trans %}You do not have any unread notification{% endtrans %}</li>
{%- endif -%}
{% endif %}
</ul>
<div class="options">
<a href="{{ url('core:notification_list') }}">

View File

@@ -1,24 +0,0 @@
<div id="quick-notifications"
x-data="{
messages: [
{% if messages %}
{% for message in messages %}
{
tag: '{{ message.tags }}',
text: '{{ message }}',
},
{% endfor %}
{% endif %}
]
}"
@quick-notification-add="(e) => messages.push(e?.detail)"
@quick-notification-delete="messages = []">
<template x-for="message in messages">
<div x-data="{show: true}" class="alert" :class="`alert-${message.tag}`" x-show="show" x-transition>
<span class="alert-main" x-text="message.text"></span>
<span class="clickable" @click="show = false">
<i class="fa fa-close"></i>
</span>
</div>
</template>
</div>

View File

@@ -15,7 +15,6 @@
{{ select_all_checkbox("add_users") }}
<hr>
{% csrf_token %}
{{ form.non_field_errors() }}
<label for="{{ form.users_removed.id_for_label }}">{{ form.users_removed.label }} :</label>
{{ form.users_removed.errors }}
{% for user in form.users_removed %}

View File

@@ -30,11 +30,7 @@
- {{ purchase.date|localtime|time(DATETIME_FORMAT) }}
</td>
<td>{{ purchase.counter }}</td>
{% if not purchase.seller %}
<td>{% trans %}Deleted user{% endtrans %}</td>
{% else %}
<td><a href="{{ purchase.seller.get_absolute_url() }}">{{ purchase.seller.get_display_name() }}</a></td>
{% endif %}
<td>{{ purchase.label }}</td>
<td>{{ purchase.quantity }}</td>
<td>{{ purchase.quantity * purchase.unit_price }} €</td>

View File

@@ -1,13 +1,12 @@
{% spaceless %}
{% for js in statics.js %}
{% for js in statics.js %}
<script-once type="module" src="{{ js }}"></script-once>
{% endfor %}
{% for css in statics.css %}
{% endfor %}
{% for css in statics.css %}
<link-once rel="stylesheet" type="text/css" href="{{ css }}" defer></link-once>
{% endfor %}
{% endfor %}
<{{ component }} name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %}>
{% for group_name, group_choices, group_index in widget.optgroups %}
<{{ component }} name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %}>
{% for group_name, group_choices, group_index in widget.optgroups %}
{% if group_name %}
<optgroup label="{{ group_name }}">
{% endif %}
@@ -17,9 +16,8 @@
{% if group_name %}
</optgroup>
{% endif %}
{% endfor %}
{% if initial %}
{% endfor %}
{% if initial %}
<slot style="display:none" name="initial">{{ initial }}</slot>
{% endif %}
</{{ component }}>
{% endspaceless %}
{% endif %}
</{{ component }}>

View File

@@ -1,58 +0,0 @@
import pytest
from django.conf import settings
from django.contrib.auth.models import Permission
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from core.baker_recipes import board_user, subscriber_user
from core.models import AnonymousUser, Page, User
from sith.settings import SITH_GROUP_OLD_SUBSCRIBERS_ID, SITH_GROUP_SUBSCRIBERS_ID
@pytest.mark.django_db
def test_edit_page(client: Client):
user = board_user.make()
page = baker.prepare(Page)
page.save(force_lock=True)
page.view_groups.add(user.groups.first())
client.force_login(user)
url = reverse("core:page_edit", kwargs={"page_name": page._full_name})
res = client.get(url)
assert res.status_code == 200
res = client.post(url, data={"content": "Hello World"})
assertRedirects(res, reverse("core:page", kwargs={"page_name": page._full_name}))
revision = page.revisions.last()
assert revision.content == "Hello World"
@pytest.mark.django_db
def test_viewable_by():
# remove existing pages to prevent side effect
Page.objects.all().delete()
view_groups = [
[settings.SITH_GROUP_PUBLIC_ID],
[settings.SITH_GROUP_PUBLIC_ID, SITH_GROUP_SUBSCRIBERS_ID],
[SITH_GROUP_SUBSCRIBERS_ID],
[SITH_GROUP_SUBSCRIBERS_ID, SITH_GROUP_OLD_SUBSCRIBERS_ID],
[],
]
pages = baker.make(Page, _quantity=len(view_groups), _bulk_create=True)
for page, groups in zip(pages, view_groups, strict=True):
page.view_groups.set(groups)
viewable = Page.objects.viewable_by(AnonymousUser()).values_list("id", flat=True)
assert set(viewable) == {pages[0].id, pages[1].id}
subscriber = subscriber_user.make()
viewable = Page.objects.viewable_by(subscriber).values_list("id", flat=True)
assert set(viewable) == {p.id for p in pages[0:4]}
root_user = baker.make(
User, user_permissions=[Permission.objects.get(codename="view_page")]
)
viewable = Page.objects.viewable_by(root_user).values_list("id", flat=True)
assert set(viewable) == {p.id for p in pages}

View File

@@ -20,8 +20,7 @@ from core.baker_recipes import (
)
from core.models import Group, User
from core.views import UserTabsMixin
from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Refilling, Selling
from counter.models import Counter, Refilling, Selling
from eboutic.models import Invoice, InvoiceItem
@@ -130,31 +129,6 @@ def test_user_account_not_found(client: Client):
assert res.status_code == 404
@pytest.mark.django_db
def test_is_deleted_barman_shown_as_deleted(client: Client):
customer = baker.make(Customer)
date = now()
sale_recipe.make(
seller=iter([None, baker.make(User)]),
customer=customer,
date=date,
_quantity=2,
_bulk_create=True,
)
client.force_login(customer.user)
res = client.get(
reverse(
"core:user_account_detail",
kwargs={
"user_id": customer.user.id,
"year": date.year,
"month": date.month,
},
)
)
assert res.status_code == 200
class TestFilterInactive(TestCase):
@classmethod
def setUpTestData(cls):

View File

@@ -39,8 +39,9 @@ from django.forms import (
DateInput,
DateTimeInput,
TextInput,
Widget,
)
from django.utils.timezone import localtime, now
from django.utils.timezone import now
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget
@@ -114,7 +115,7 @@ class SelectUser(TextInput):
def validate_future_timestamp(value: date | datetime):
if value <= now():
raise ValidationError(_("Ensure this timestamp is set in the future"))
raise ValueError(_("Ensure this timestamp is set in the future"))
class FutureDateTimeField(forms.DateTimeField):
@@ -122,8 +123,8 @@ class FutureDateTimeField(forms.DateTimeField):
default_validators = [validate_future_timestamp]
def widget_attrs(self, widget: forms.Widget) -> dict[str, str]:
return {"min": widget.format_value(localtime())}
def widget_attrs(self, widget: Widget) -> dict[str, str]:
return {"min": widget.format_value(now())}
# Forms

View File

@@ -2,6 +2,7 @@ import copy
import inspect
from typing import Any, ClassVar, LiteralString, Protocol, Unpack
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest, HttpResponse
from django.template.loader import render_to_string
@@ -40,6 +41,36 @@ class TabedViewMixin(View):
return kwargs
class QuickNotifMixin:
quick_notif_list = []
def dispatch(self, request, *arg, **kwargs):
# In some cases, the class can stay instanciated, so we need to reset the list
self.quick_notif_list = []
return super().dispatch(request, *arg, **kwargs)
def get_success_url(self):
ret = super().get_success_url()
if hasattr(self, "quick_notif_url_arg"):
if "?" in ret:
ret += "&" + self.quick_notif_url_arg
else:
ret += "?" + self.quick_notif_url_arg
return ret
def get_context_data(self, **kwargs):
"""Add quick notifications to context."""
kwargs = super().get_context_data(**kwargs)
kwargs["quick_notifs"] = []
for n in self.quick_notif_list:
kwargs["quick_notifs"].append(settings.SITH_QUICK_NOTIF[n])
for key, val in settings.SITH_QUICK_NOTIF.items():
for gk in self.request.GET:
if key == gk:
kwargs["quick_notifs"].append(val)
return kwargs
class AllowFragment:
"""Add `is_fragment` to templates. It's only True if the request is emitted by htmx"""
@@ -78,7 +109,7 @@ class FragmentMixin(TemplateResponseMixin, ContextMixin):
return render(
request,
"app/template.jinja",
context={"fragment": fragment(request)}
context={"fragment": fragment(request)
}
# in urls.py

View File

@@ -43,14 +43,11 @@ class CanEditPagePropMixin(CanEditPropMixin):
return res
class PageListView(ListView):
class PageListView(CanViewMixin, ListView):
model = Page
template_name = "core/page_list.jinja"
def get_queryset(self):
return (
Page.objects.viewable_by(self.request.user)
.annotate(
queryset = (
Page.objects.annotate(
display_name=Coalesce(
Subquery(
PageRev.objects.filter(page=OuterRef("id"))
@@ -60,6 +57,7 @@ class PageListView(ListView):
F("name"),
)
)
.prefetch_related("view_groups")
.select_related("parent")
)
@@ -186,7 +184,7 @@ class PageEditViewBase(CanEditMixin, UpdateView):
)
template_name = "core/pagerev_edit.jinja"
def get_object(self, *args, **kwargs):
def get_object(self):
self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
return self._get_revision()

View File

@@ -65,7 +65,7 @@ from core.views.forms import (
UserGroupsForm,
UserProfileForm,
)
from core.views.mixins import TabedViewMixin, UseFragmentsMixin
from core.views.mixins import QuickNotifMixin, TabedViewMixin, UseFragmentsMixin
from counter.models import Counter, Refilling, Selling
from eboutic.models import Invoice
from subscription.models import Subscription
@@ -564,7 +564,7 @@ class UserUpdateGroupView(UserTabsMixin, CanEditPropMixin, UpdateView):
current_tab = "groups"
class UserToolsView(LoginRequiredMixin, UserTabsMixin, TemplateView):
class UserToolsView(LoginRequiredMixin, QuickNotifMixin, UserTabsMixin, TemplateView):
"""Displays the logged user's tools."""
template_name = "core/user_tools.jinja"

View File

@@ -39,7 +39,6 @@
flex: auto;
margin: 0.2em;
width: 20%;
min-width: 350px;
ul {
list-style-type: none;

View File

@@ -67,13 +67,13 @@
<option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option>
<option value="ANN">{% trans %}Cancel (ANN){% endtrans %}</option>
</optgroup>
{%- for category in categories.keys() -%}
{% for category in categories.keys() %}
<optgroup label="{{ category }}">
{%- for product in categories[category] -%}
{% for product in categories[category] %}
<option value="{{ product.id }}">{{ product }}</option>
{%- endfor -%}
{% endfor %}
</optgroup>
{%- endfor -%}
{% endfor %}
</counter-product-select>
<input type="submit" value="{% trans %}Go{% endtrans %}"/>

View File

@@ -4,6 +4,7 @@
heading_level: 3
members:
- TabedViewMixin
- QuickNotifMixin
- AllowFragment
- FragmentMixin
- UseFragmentsMixin

View File

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

View File

@@ -31,5 +31,12 @@
</div>
<br>
{% include "core/base/notifications.jinja" %}
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
</div>

View File

@@ -1,9 +1,5 @@
{% extends "core/base.jinja" %}
{% block notifications %}
{# Notifications are moved inside the billing info fragment #}
{% endblock %}
{% block title %}
{% trans %}Basket state{% endtrans %}
{% endblock %}

View File

@@ -22,6 +22,14 @@
{% block content %}
<h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
<div id="eboutic" x-data="basket({{ last_purchase_time }})">
<div id="basket">
<h3>Panier</h3>

View File

@@ -4,6 +4,14 @@
<h3>{% trans %}Eboutic{% endtrans %}</h3>
<div>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% if success %}
{% trans %}Payment successful{% endtrans %}
{% else %}

View File

@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-26 22:40+0200\n"
"POT-Creation-Date: 2025-09-19 17:22+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,12 +174,12 @@ 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 must be subscribed to join a club"
msgstr "Vous devez être cotisant pour faire partie d'un club"
msgid "You should specify a role"
msgstr "Vous devez choisir un rôle"
#: club/forms.py
msgid "You are already a member of this club"
msgstr "Vous êtes déjà membre de ce club."
#: club/forms.py sas/forms.py
msgid "You do not have the permission to do that"
msgstr "Vous n'avez pas la permission de faire cela"
#: club/models.py
msgid "slug name"
@@ -239,7 +239,7 @@ msgid "role"
msgstr "rôle"
#: club/models.py core/models.py counter/models.py election/models.py
#: forum/models.py reservation/models.py
#: forum/models.py
msgid "description"
msgstr "description"
@@ -350,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"
@@ -514,18 +519,6 @@ msgstr "Nouveau Trombi"
msgid "Posters"
msgstr "Affiches"
#: club/templates/club/club_tools.jinja
msgid "Reservable rooms"
msgstr "Salles réservables"
#: club/templates/club/club_tools.jinja
msgid "Add a room"
msgstr "Ajouter une salle"
#: club/templates/club/club_tools.jinja
msgid "This club manages no reservable room"
msgstr "Ce club ne gère pas de salle réservable"
#: club/templates/club/club_tools.jinja
msgid "Counters:"
msgstr "Comptoirs : "
@@ -576,24 +569,6 @@ msgstr ""
msgid "Save"
msgstr "Sauver"
#: club/templates/club/fragments/add_member.jinja
msgid "Add a new member"
msgstr "Ajouter un nouveau membre"
#: club/templates/club/fragments/add_member.jinja
msgid "Join club"
msgstr "Rejoindre le club"
#: 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/fragments/add_member.jinja
msgid "Join"
msgstr "Rejoindre"
#: club/templates/club/mailing.jinja
msgid "Mailing lists"
msgstr "Mailing listes"
@@ -700,15 +675,6 @@ msgstr "Vente"
msgid "Mailing list"
msgstr "Listes de diffusion"
#: club/views.py
#, python-format
msgid "%(user)s has been added to club."
msgstr "%(user)s a été ajouté au club."
#: club/views.py
msgid "You are now a member of this club."
msgstr "Vous êtes maintenant membre de ce club."
#: com/forms.py
msgid "Format: 16:9 | Resolution: 1920x1080"
msgstr "Format : 16:9 | Résolution : 1920x1080"
@@ -789,7 +755,7 @@ msgstr "Une description plus détaillée et exhaustive de l'évènement."
msgid "The club which organizes the event."
msgstr "Le club qui organise l'évènement."
#: com/models.py pedagogy/models.py reservation/models.py trombi/models.py
#: com/models.py pedagogy/models.py trombi/models.py
msgid "author"
msgstr "auteur"
@@ -1073,19 +1039,10 @@ msgstr "Nos services"
msgid "UV Guide"
msgstr "Guide des UVs"
#: com/templates/com/news_list.jinja
msgid "Timetable"
msgstr "Emploi du temps"
#: com/templates/com/news_list.jinja core/templates/core/base/navbar.jinja
msgid "Matmatronch"
msgstr "Matmatronch"
#: com/templates/com/news_list.jinja
#: reservation/templates/reservation/schedule.jinja
msgid "Room reservation"
msgstr "Réservation de salle"
#: com/templates/com/news_list.jinja core/templates/core/base/navbar.jinja
#: core/templates/core/user_tools.jinja
msgid "Elections"
@@ -1146,10 +1103,6 @@ msgstr "Modération"
msgid "No posters"
msgstr "Aucune affiche"
#: com/templates/com/poster_list.jinja com/templates/com/screen_slideshow.jinja
msgid "Click to expand"
msgstr "Cliquez pour agrandir"
#: com/templates/com/poster_moderate.jinja
msgid "Posters - moderation"
msgstr "Affiches - modération"
@@ -1207,14 +1160,6 @@ msgstr "Contenu"
msgid "Add to weekmail"
msgstr "Ajouter au Weekmail"
#: com/templates/com/weekmail.jinja
msgid "Articles included the next weekmail"
msgstr "Article inclus dans le prochain Weekmail"
#: com/templates/com/weekmail.jinja
msgid "Delete from weekmail"
msgstr "Supprimer du Weekmail"
#: com/templates/com/weekmail.jinja
msgid "Up"
msgstr "Monter"
@@ -1223,6 +1168,14 @@ msgstr "Monter"
msgid "Down"
msgstr "Descendre"
#: com/templates/com/weekmail.jinja
msgid "Articles included the next weekmail"
msgstr "Article inclus dans le prochain Weekmail"
#: com/templates/com/weekmail.jinja
msgid "Delete from weekmail"
msgstr "Supprimer du Weekmail"
#: com/templates/com/weekmail_preview.jinja
#: core/templates/core/user_account_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
@@ -1304,10 +1257,6 @@ msgstr "Liste d'écrans"
msgid "All incoming events"
msgstr "Tous les événements à venir"
#: com/views.py
msgid "Weekmail sent successfully"
msgstr "Weekmail envoyé avec succès"
#: com/views.py
msgid "Delete and save to regenerate"
msgstr "Supprimer et sauver pour régénérer"
@@ -1316,26 +1265,6 @@ msgstr "Supprimer et sauver pour régénérer"
msgid "Weekmail of the "
msgstr "Weekmail du "
#: com/views.py
#, python-format
msgid "%(title)s moved up in the Weekmail"
msgstr "%(title)s monté dans le Weekmail"
#: com/views.py
#, python-format
msgid "%(title)s moved down in the Weekmail"
msgstr "%(title)s descendu dans le Weekmail"
#: com/views.py
#, python-format
msgid "%(title)s added to the Weekmail"
msgstr "%(title)s ajouté dans Weekmail"
#: com/views.py
#, python-format
msgid "%(title)s removed from the Weekmail"
msgstr "%(title)s retiré du Weekmail"
#: com/views.py
msgid ""
"You must be a board member of the selected club to post in the Weekmail."
@@ -1952,7 +1881,6 @@ msgstr "Confirmation"
#: core/templates/core/file_delete_confirm.jinja
#: counter/templates/counter/counter_click.jinja
#: counter/templates/counter/fragments/delete_student_card.jinja
#: reservation/templates/reservation/fragments/create_reservation.jinja
#: sas/templates/sas/ask_picture_removal.jinja
msgid "Cancel"
msgstr "Annuler"
@@ -2412,10 +2340,6 @@ msgstr "Etickets"
msgid "User has no account"
msgstr "L'utilisateur n'a pas de compte"
#: core/templates/core/user_account_detail.jinja
msgid "Deleted user"
msgstr "Utilisateur supprimé"
#: core/templates/core/user_account_detail.jinja
#: counter/templates/counter/last_ops.jinja
#: counter/templates/counter/refilling_list.jinja
@@ -3077,7 +3001,7 @@ msgstr "Mettre à True si le mail a reçu une erreur"
msgid "The operation that emptied the account."
msgstr "L'opération qui a vidé le compte."
#: counter/models.py pedagogy/models.py reservation/models.py
#: counter/models.py pedagogy/models.py
msgid "comment"
msgstr "commentaire"
@@ -4616,72 +4540,21 @@ msgstr "Signaler ce commentaire"
msgid "Edit UE"
msgstr "Éditer l'UE"
#: reservation/forms.py
msgid "The start must be set before the end"
msgstr "Le début doit être placé avant la fin"
#: pedagogy/templates/pedagogy/uv_edit.jinja
msgid "Import from UTBM"
msgstr "Importer depuis l'UTBM"
#: reservation/models.py
msgid "room name"
msgstr "Nom de la salle"
#: pedagogy/templates/pedagogy/uv_edit.jinja
msgid "Unknown UE code"
msgstr "Code d'UE inconnu"
#: reservation/models.py
msgid "room owner"
msgstr "propriétaire de la salle"
#: pedagogy/templates/pedagogy/uv_edit.jinja
msgid "Successful autocomplete"
msgstr "Autocomplétion réussite"
#: reservation/models.py
msgid "The club which manages this room"
msgstr "Le club qui gère cette salle"
#: reservation/models.py
msgid "site"
msgstr "site"
#: reservation/models.py
msgid "reservable room"
msgstr "salle réservable"
#: reservation/models.py
msgid "reservable rooms"
msgstr "salles réservables"
#: reservation/models.py
msgid "reserved room"
msgstr "salle réservée"
#: reservation/models.py
msgid "slot start"
msgstr "début du créneau"
#: reservation/models.py
msgid "slot end"
msgstr "fin du créneau"
#: reservation/models.py
msgid "reservation slot"
msgstr "créneau de réservation"
#: reservation/models.py
msgid "reservation slots"
msgstr "créneaux de réservation"
#: reservation/models.py
msgid "There is already a reservation on this slot."
msgstr "Il y a déjà une réservation sur ce créneau."
#: reservation/templates/reservation/fragments/create_reservation.jinja
msgid "Book a room"
msgstr "Réserver une salle"
#: reservation/templates/reservation/schedule.jinja
msgid "You can book a room by selecting a free slot in the calendar."
msgstr ""
"Vous pouvez réserver une salle en sélectionnant un emplacement libre dans le "
"calendrier."
#: reservation/views.py
#, python-format
msgid "%(name)s was updated successfully"
msgstr "%(name)s a été mis à jour avec succès"
#: pedagogy/templates/pedagogy/uv_edit.jinja
msgid "An error occurred: "
msgstr "Une erreur est survenue : "
#: rootplace/forms.py
msgid "User that will be kept"
@@ -4756,10 +4629,6 @@ msgstr "Pas de ban actif"
msgid "Add a new album"
msgstr "Ajouter un nouvel album"
#: sas/forms.py
msgid "You do not have the permission to do that"
msgstr "Vous n'avez pas la permission de faire cela"
#: sas/forms.py
msgid "Upload images"
msgstr "Envoyer les images"
@@ -4950,8 +4819,8 @@ msgid "N/A"
msgstr "N/A"
#: sith/settings.py
msgid "AE account"
msgstr "Compte AE"
msgid "Transfert"
msgstr "Virement"
#: sith/settings.py
msgid "Belfort"
@@ -5239,6 +5108,26 @@ msgstr "Vous avez acheté %s"
msgid "You have a notification"
msgstr "Vous avez une notification"
#: sith/settings.py
msgid "Success!"
msgstr "Succès !"
#: sith/settings.py
msgid "Fail!"
msgstr "Échec !"
#: sith/settings.py
msgid "You successfully posted an article in the Weekmail"
msgstr "Article posté avec succès dans le Weekmail"
#: sith/settings.py
msgid "You successfully edited an article in the Weekmail"
msgstr "Article édité avec succès dans le Weekmail"
#: sith/settings.py
msgid "You successfully sent the Weekmail"
msgstr "Weekmail envoyé avec succès"
#: sith/settings.py
msgid "AE tee-shirt"
msgstr "Tee-shirt AE"
@@ -5279,14 +5168,6 @@ msgstr "lieu"
msgid "You can not subscribe many time for the same period"
msgstr "Vous ne pouvez pas cotiser plusieurs fois pour la même période"
#: subscription/templates/subscription/forms/create_existing_user.jinja
msgid ""
"If the subscription is done using the AE account, you must also click it on "
"the AE counter."
msgstr ""
"Si la cotisation est faite en utilisant le compte AE, vous devez également "
"la cliquer sur le comptoir AE."
#: subscription/templates/subscription/fragments/creation_success.jinja
#, python-format
msgid "Subscription created for %(user)s"
@@ -5325,18 +5206,6 @@ msgstr "Membre existant"
msgid "the groups that can create subscriptions"
msgstr "les groupes pouvant créer des cotisations"
#: timetable/templates/timetable/generator.jinja
msgid "Timetable generator"
msgstr "Générateur d'emploi du temps"
#: timetable/templates/timetable/generator.jinja
msgid "Generate"
msgstr "Générer"
#: timetable/templates/timetable/generator.jinja
msgid "Save to PNG"
msgstr "Sauver en PNG"
#: trombi/models.py
msgid "subscription deadline"
msgstr "fin des inscriptions"
@@ -5562,38 +5431,10 @@ msgstr "Mes photos"
msgid "Admin tools"
msgstr "Admin Trombi"
#: trombi/views.py
msgid "Trombi modified"
msgstr "Trombi modifié"
#: trombi/views.py
msgid "User added to the trombi"
msgstr "Utilisateur ajouté au trombi"
#: trombi/views.py
msgid "User couldn't be added to the trombi"
msgstr "L'utilisateur n'a pas pu être ajouté au trombi"
#: trombi/views.py
msgid "User removed from the trombi"
msgstr "Utilisateur retiré du trombi"
#: trombi/views.py
msgid "Explain why you rejected the comment"
msgstr "Expliquez pourquoi vous refusez le commentaire"
#: trombi/views.py
msgid "Comment accepted"
msgstr "Commentaire accepté"
#: trombi/views.py
msgid "Comment rejected"
msgstr "Commentaire rejeté"
#: trombi/views.py
msgid "Comment removed"
msgstr "Commentaire retiré"
#: trombi/views.py
msgid "Rejected comment"
msgstr "Commentaire rejeté"
@@ -5634,10 +5475,6 @@ msgstr ""
"pouvez vous inscrire qu'à un seul Trombi, donc ne jouez pas avec cet option "
"ou vous encourerez la colère des admins!"
#: trombi/views.py
msgid "User modified"
msgstr "Utilisateur modifié"
#: trombi/views.py
msgid "Personal email (not UTBM)"
msgstr "Email personnel (pas UTBM)"
@@ -5650,14 +5487,6 @@ msgstr "Téléphone"
msgid "Native town"
msgstr "Ville d'origine"
#: trombi/views.py
msgid "User removed from trombi"
msgstr "Utilisateur retiré du trombi"
#: trombi/views.py
msgid "Comment added"
msgstr "Commentaire ajouté"
#: trombi/views.py
msgid ""
"You can not yet write comment, you must wait for the subscription deadline "

View File

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

172
package-lock.json generated
View File

@@ -9,18 +9,14 @@
"version": "3",
"license": "GPL-3.0-only",
"dependencies": {
"@alpinejs/morph": "^3.14.9",
"@alpinejs/sort": "^3.14.7",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.6.13",
"@fortawesome/fontawesome-free": "^6.6.0",
"@fullcalendar/core": "^6.1.19",
"@fullcalendar/daygrid": "^6.1.19",
"@fullcalendar/icalendar": "^6.1.19",
"@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/list": "^6.1.19",
"@fullcalendar/resource": "^6.1.19",
"@fullcalendar/resource-timeline": "^6.1.19",
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/icalendar": "^6.1.15",
"@fullcalendar/list": "^6.1.15",
"@sentry/browser": "^9.29.0",
"@zip.js/zip.js": "^2.7.52",
"3d-force-graph": "^1.73.4",
@@ -33,9 +29,8 @@
"d3-force-3d": "^3.0.5",
"easymde": "^2.19.0",
"glob": "^11.0.0",
"html2canvas": "^1.4.1",
"htmx-ext-alpine-morph": "^2.0.1",
"htmx.org": "^2.0.3",
"jquery": "^3.7.1",
"js-cookie": "^3.0.5",
"lit-html": "^3.3.0",
"native-file-system-adapter": "^3.0.1",
@@ -52,6 +47,7 @@
"@types/alpinejs": "^3.13.10",
"@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-klay": "^3.1.4",
"@types/jquery": "^3.5.31",
"@types/js-cookie": "^3.0.6",
"typescript": "^5.8.3",
"vite": "^6.3.6",
@@ -59,12 +55,6 @@
"vite-plugin-static-copy": "^3.1.2"
}
},
"node_modules/@alpinejs/morph": {
"version": "3.14.9",
"resolved": "https://registry.npmjs.org/@alpinejs/morph/-/morph-3.14.9.tgz",
"integrity": "sha512-i1mrH5Gza/egszxnCVwQWypRhsKGq28RFWHWuW7aI0+rWo1pvFw+aPhJLImbpt7hx44DtDOr5m4l9Ah+JPFmFw==",
"license": "MIT"
},
"node_modules/@alpinejs/sort": {
"version": "3.14.9",
"resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.14.9.tgz",
@@ -2282,15 +2272,6 @@
"ical.js": "^1.4.0"
}
},
"node_modules/@fullcalendar/interaction": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.19.tgz",
"integrity": "sha512-GOciy79xe8JMVp+1evAU3ytdwN/7tv35t5i1vFkifiuWcQMLC/JnLg/RA2s4sYmQwoYhTw/p4GLcP0gO5B3X5w==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.19"
}
},
"node_modules/@fullcalendar/list": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.19.tgz",
@@ -2300,67 +2281,6 @@
"@fullcalendar/core": "~6.1.19"
}
},
"node_modules/@fullcalendar/premium-common": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/premium-common/-/premium-common-6.1.19.tgz",
"integrity": "sha512-bOWHm1u1dUy6M4fQ0hNK7qEI7SrVWrN1ovv/z4/FE/ybfM19ukz7SFs907Ur7KUBWLNKTQYXBtdrY/ginwWraw==",
"license": "SEE LICENSE IN LICENSE.md",
"peerDependencies": {
"@fullcalendar/core": "~6.1.19"
}
},
"node_modules/@fullcalendar/resource": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/resource/-/resource-6.1.19.tgz",
"integrity": "sha512-br1ylX/aIOfd8m7Tzl2LpJBSI+N9Q6aS1qw7K9qnQjYXWQyHBlfLG6ZcPmmkjfaqTUJc8ARRbtNWj1ts5qOZgQ==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@fullcalendar/premium-common": "~6.1.19"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.19"
}
},
"node_modules/@fullcalendar/resource-timeline": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/resource-timeline/-/resource-timeline-6.1.19.tgz",
"integrity": "sha512-oC3aVR++dLqJNeBwmLHq9sDgRDFfIG0qSteV7bgBekvNlqEMqXx8wPjUxnELrq8rrhMmK4iV3wO7AB/48IVgyg==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@fullcalendar/premium-common": "~6.1.19",
"@fullcalendar/scrollgrid": "~6.1.19",
"@fullcalendar/timeline": "~6.1.19"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.19",
"@fullcalendar/resource": "~6.1.19"
}
},
"node_modules/@fullcalendar/scrollgrid": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/scrollgrid/-/scrollgrid-6.1.19.tgz",
"integrity": "sha512-S1pbiYHvmV0ep6z5sWXJQfgW4Y/jrS5iLIAqSagDFPK0jr327nBxl7Ryi3Zb5UdMIP0/O4GXs8jwZabQPd8SOg==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@fullcalendar/premium-common": "~6.1.19"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.19"
}
},
"node_modules/@fullcalendar/timeline": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/timeline/-/timeline-6.1.19.tgz",
"integrity": "sha512-d2P961mnUTXtJeWNmIq1neoDmZcrPUaK7nGFoc+jQAlnmG3aNSVWQmD1ia694AMqLWtcWkwipW9MuaJgx2QvrA==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@fullcalendar/premium-common": "~6.1.19",
"@fullcalendar/scrollgrid": "~6.1.19"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.19"
}
},
"node_modules/@hey-api/json-schema-ref-parser": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.6.tgz",
@@ -2969,6 +2889,16 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT"
},
"node_modules/@types/jquery": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.33.tgz",
"integrity": "sha512-SeyVJXlCZpEki5F0ghuYe+L+PprQta6nRZqhONt9F13dWBtR/ftoaIbdRQ7cis7womE+X2LKhsDdDtkkDhJS6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/sizzle": "*"
}
},
"node_modules/@types/js-cookie": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
@@ -2989,6 +2919,13 @@
"integrity": "sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w==",
"license": "MIT"
},
"node_modules/@types/sizzle": {
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz",
"integrity": "sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/tern": {
"version": "0.23.9",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
@@ -3187,15 +3124,6 @@
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -3584,15 +3512,6 @@
"node": ">= 8"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/cytoscape": {
"version": "3.33.1",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
@@ -4265,27 +4184,6 @@
"node": ">= 0.4"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/htmx-ext-alpine-morph": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/htmx-ext-alpine-morph/-/htmx-ext-alpine-morph-2.0.1.tgz",
"integrity": "sha512-teGpcVatx5IjDUYQs959x9FcePM1TIksjfW5tSe1KVQVEVSmbGxEoemneC7XV6RYpX+27i/xn1fPjduwvHDrAw==",
"dependencies": {
"htmx.org": "^2.0.2"
}
},
"node_modules/htmx.org": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.6.tgz",
@@ -4486,6 +4384,12 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jquery": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==",
"license": "MIT"
},
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
@@ -5575,15 +5479,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/three": {
"version": "0.177.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.177.0.tgz",
@@ -5841,15 +5736,6 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vite": {
"version": "6.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",

View File

@@ -21,8 +21,7 @@
"#core:*": "./core/static/bundled/*",
"#pedagogy:*": "./pedagogy/static/bundled/*",
"#counter:*": "./counter/static/bundled/*",
"#com:*": "./com/static/bundled/*",
"#reservation:*": "./reservation/static/bundled/*"
"#com:*": "./com/static/bundled/*"
},
"devDependencies": {
"@babel/core": "^7.25.2",
@@ -33,6 +32,7 @@
"@types/alpinejs": "^3.13.10",
"@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-klay": "^3.1.4",
"@types/jquery": "^3.5.31",
"@types/js-cookie": "^3.0.6",
"typescript": "^5.8.3",
"vite": "^6.3.6",
@@ -40,18 +40,14 @@
"vite-plugin-static-copy": "^3.1.2"
},
"dependencies": {
"@alpinejs/morph": "^3.14.9",
"@alpinejs/sort": "^3.14.7",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.6.13",
"@fortawesome/fontawesome-free": "^6.6.0",
"@fullcalendar/core": "^6.1.19",
"@fullcalendar/daygrid": "^6.1.19",
"@fullcalendar/icalendar": "^6.1.19",
"@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/list": "^6.1.19",
"@fullcalendar/resource": "^6.1.19",
"@fullcalendar/resource-timeline": "^6.1.19",
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/icalendar": "^6.1.15",
"@fullcalendar/list": "^6.1.15",
"@sentry/browser": "^9.29.0",
"@zip.js/zip.js": "^2.7.52",
"3d-force-graph": "^1.73.4",
@@ -64,9 +60,8 @@
"d3-force-3d": "^3.0.5",
"easymde": "^2.19.0",
"glob": "^11.0.0",
"html2canvas": "^1.4.1",
"htmx-ext-alpine-morph": "^2.0.1",
"htmx.org": "^2.0.3",
"jquery": "^3.7.1",
"js-cookie": "^3.0.5",
"lit-html": "^3.3.0",
"native-file-system-adapter": "^3.0.1",

View File

@@ -13,7 +13,8 @@
{% block content %}
<div class="pedagogy">
<div id="uv_detail">
<button onclick='(function(){
<p id="return_noscript"><a href="{{ url('pedagogy:guide') }}">{% trans %}Back{% endtrans %}</a></p>
<button id="return_js" onclick='(function(){
// If comes from the guide page, go back with history
if (document.referrer.replace(/\?(.+)/gm,"").endsWith(`{{ url("pedagogy:guide") }}`)){
window.history.back();
@@ -216,4 +217,9 @@
</div>
</div>
<script type="text/javascript">
$("#return_noscript").hide();
$("#return_js").show();
</script>
{% endblock %}

View File

@@ -21,6 +21,11 @@
{{ field.errors }}
<label for="{{ field.name }}">{{ field.label }}</label>
{{ field }}
{% if field.name == 'code' %}
<button type="button" id="autofill">{% trans %}Import from UTBM{% endtrans %}</button>
{% endif %}
</p>
{% endif %}
@@ -31,3 +36,48 @@
<p><input type="submit" value="{% trans %}Update{% endtrans %}" /></p>
</form>
{% endblock %}
{% block script %}
{{ super() }}
<script type="text/javascript">
document.addEventListener('DOMContentLoaded', function() {
const autofillBtn = document.getElementById('autofill')
const codeInput = document.querySelector('input[name="code"]')
autofillBtn.addEventListener('click', () => {
const url = `/api/uv/${codeInput.value}`;
deleteQuickNotifs()
$.ajax({
dataType: "json",
url: url,
success: function(data, _, xhr) {
if (xhr.status !== 200) {
createQuickNotif("{% trans %}Unknown UE code{% endtrans %}")
return
}
Object.entries(data)
.filter(([_, val]) => !!val) // skip entries with null or undefined value
.map(([key, val]) => { // convert keys to DOM elements
return [document.querySelector('[name="' + key + '"]'), val];
})
.filter(([elem, _]) => !!elem) // skip non-existing DOM elements
.forEach(([elem, val]) => { // write the value in the form field
if (elem.tagName === 'TEXTAREA') {
// MD editor text input
elem.parentNode.querySelector('.CodeMirror').CodeMirror.setValue(val);
} else {
elem.value = val;
}
});
createQuickNotif('{% trans %}Successful autocomplete{% endtrans %}')
},
error: function(_, _, statusMessage) {
createQuickNotif('{% trans %}An error occurred: {% endtrans %}' + statusMessage)
},
})
})
})
</script>
{% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -309,7 +309,6 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
// Clear selection and cache of retrieved user so they can be filtered again
widget.clear(false);
widget.clearOptions();
widget.setTextboxValue("");
},
/**

View File

@@ -123,10 +123,8 @@ INSTALLED_APPS = (
"trombi",
"matmat",
"pedagogy",
"reservation",
"galaxy",
"antispam",
"timetable",
"api",
)
@@ -276,7 +274,7 @@ LOGGING = {
# Internationalization
# https://docs.djangoproject.com/en/1.8/topics/i18n/
LANGUAGE_CODE = "fr"
LANGUAGE_CODE = "fr-FR"
LANGUAGES = [("en", _("English")), ("fr", _("French"))]
@@ -423,11 +421,18 @@ SITH_PROFILE_DEPARTMENTS = [
("NA", _("N/A")),
]
SITH_ACCOUNTING_PAYMENT_METHOD = [
("CHECK", _("Check")),
("CASH", _("Cash")),
("TRANSFERT", _("Transfert")),
("CARD", _("Credit card")),
]
SITH_SUBSCRIPTION_PAYMENT_METHOD = [
("CHECK", _("Check")),
("CARD", _("Credit card")),
("CASH", _("Cash")),
("AE_ACCOUNT", _("AE account")),
("EBOUTIC", _("Eboutic")),
("OTHER", _("Other")),
]
@@ -436,7 +441,6 @@ SITH_SUBSCRIPTION_LOCATIONS = [
("SEVENANS", _("Sevenans")),
("MONTBELIARD", _("Montbéliard")),
("EBOUTIC", _("Eboutic")),
("OTHER", _("Other")),
]
SITH_COUNTER_BARS = [(1, "MDE"), (2, "Foyer"), (35, "La Gommette")]
@@ -687,6 +691,14 @@ SITH_PERMANENT_NOTIFICATIONS = {
"SAS_MODERATION": "sas.models.sas_notification_callback",
}
SITH_QUICK_NOTIF = {
"qn_success": _("Success!"),
"qn_fail": _("Fail!"),
"qn_weekmail_new_article": _("You successfully posted an article in the Weekmail"),
"qn_weekmail_article_edit": _("You successfully edited an article in the Weekmail"),
"qn_weekmail_send_success": _("You successfully sent the Weekmail"),
}
# Mailing related settings
SITH_MAILING_DOMAIN = "utbm.fr"

View File

@@ -49,15 +49,10 @@ urlpatterns = [
path("trombi/", include(("trombi.urls", "trombi"), namespace="trombi")),
path("matmatronch/", include(("matmat.urls", "matmat"), namespace="matmat")),
path("pedagogy/", include(("pedagogy.urls", "pedagogy"), namespace="pedagogy")),
path(
"reservation/",
include(("reservation.urls", "reservation"), namespace="reservation"),
),
path("admin/", admin.site.urls),
path("i18n/", include("django.conf.urls.i18n")),
path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"),
path("captcha/", include("captcha.urls")),
path("edt/", include(("timetable.urls", "timetable"), namespace="timetable")),
]
if settings.DEBUG:

View File

@@ -2,7 +2,6 @@ import secrets
from typing import Any
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
@@ -24,13 +23,6 @@ class SelectionDateForm(forms.Form):
class SubscriptionForm(forms.ModelForm):
allowed_payment_methods = ["CARD", "CASH", "AE_ACCOUNT"]
class Meta:
model = Subscription
fields = ["subscription_type", "payment_method", "location"]
widgets = {"payment_method": forms.RadioSelect}
def __init__(self, *args, initial=None, **kwargs):
initial = initial or {}
if "subscription_type" not in initial:
@@ -38,14 +30,6 @@ class SubscriptionForm(forms.ModelForm):
if "payment_method" not in initial:
initial["payment_method"] = "CARD"
super().__init__(*args, initial=initial, **kwargs)
self.fields["payment_method"].choices = [
m
for m in settings.SITH_SUBSCRIPTION_PAYMENT_METHOD
if m[0] in self.allowed_payment_methods
]
self.fields["location"].choices = [
m for m in settings.SITH_SUBSCRIPTION_LOCATIONS if m[0] != "EBOUTIC"
]
def save(self, *args, **kwargs):
if self.errors:
@@ -77,8 +61,7 @@ class SubscriptionNewUserForm(SubscriptionForm):
assert user.is_subscribed
"""
allowed_payment_methods = ["CARD", "CASH"]
template_name = "subscription/forms/create_new_user.jinja"
template_name = "subscription/forms/create_new_user.html"
__user_fields = forms.fields_for_model(
User,
@@ -90,6 +73,10 @@ class SubscriptionNewUserForm(SubscriptionForm):
email = __user_fields["email"]
date_of_birth = __user_fields["date_of_birth"]
class Meta:
model = Subscription
fields = ["subscription_type", "payment_method", "location"]
field_order = [
"first_name",
"last_name",
@@ -143,7 +130,7 @@ class SubscriptionNewUserForm(SubscriptionForm):
class SubscriptionExistingUserForm(SubscriptionForm):
"""Form to add a subscription to an existing user."""
template_name = "subscription/forms/create_existing_user.jinja"
template_name = "subscription/forms/create_existing_user.html"
required_css_class = "required"
birthdate = forms.fields_for_model(
@@ -153,9 +140,10 @@ class SubscriptionExistingUserForm(SubscriptionForm):
help_texts={"date_of_birth": _("This user didn't fill its birthdate yet.")},
)["date_of_birth"]
class Meta(SubscriptionForm.Meta):
fields = ["member", *SubscriptionForm.Meta.fields]
widgets = SubscriptionForm.Meta.widgets | {"member": AutoCompleteSelectUser}
class Meta:
model = Subscription
fields = ["member", "subscription_type", "payment_method", "location"]
widgets = {"member": AutoCompleteSelectUser}
field_order = [
"member",

View File

@@ -1,56 +0,0 @@
# Generated by Django 5.2.3 on 2025-09-08 05:38
from django.db import migrations, models
from django.db.migrations.state import StateApps
def rename_enums(apps: StateApps, schema_editor):
Subscription = apps.get_model("subscription", "Subscription")
Subscription.objects.filter(subscription_type="EBOUTIC").update(
subscription_type="AE_ACCOUNT"
)
def rename_enums_reverse(apps: StateApps, schema_editor):
Subscription = apps.get_model("subscription", "Subscription")
Subscription.objects.filter(subscription_type="AE_ACCOUNT").update(
subscription_type="EBOUTIC"
)
class Migration(migrations.Migration):
dependencies = [("subscription", "0014_auto_20201207_2323")]
operations = [
migrations.AlterField(
model_name="subscription",
name="location",
field=models.CharField(
choices=[
("BELFORT", "Belfort"),
("SEVENANS", "Sevenans"),
("MONTBELIARD", "Montbéliard"),
("EBOUTIC", "Eboutic"),
("OTHER", "Other"),
],
max_length=20,
verbose_name="location",
),
),
migrations.AlterField(
model_name="subscription",
name="payment_method",
field=models.CharField(
choices=[
("CHECK", "Check"),
("CARD", "Credit card"),
("CASH", "Cash"),
("AE_ACCOUNT", "AE account"),
("OTHER", "Other"),
],
max_length=255,
verbose_name="payment method",
),
),
migrations.RunPython(rename_enums, reverse_code=rename_enums_reverse),
]

View File

@@ -0,0 +1,14 @@
{% load static %}
{% load i18n %}
<div x-data="existing_user_subscription_form" class="form-content existing-user">
<fieldset>
{{ form.as_p }}
</fieldset>
<div
id="subscription-form-user-mini-profile"
x-html="profileFragment"
:aria-busy="loading"
></div>
</div>

View File

@@ -1,28 +0,0 @@
{% load static %}
{% load i18n %}
<div x-data="existing_user_subscription_form" class="form-content existing-user">
<fieldset>
{{ errors }}
{% for field, errors in fields %}
<p{% with classes=field.css_classes %}{% if classes %} class="{{ classes }}"{% endif %}{% endwith %}>
{{ field.label_tag }}
{{ field }}
{% if field.help_text %}
<span class="helptext">{{ field.help_text }}</span>
{% endif %}
</p>
{% if field.name == "payment_method" %}
<i>
{% blocktranslate %}If the subscription is done using the AE account, you must also click it on the AE counter.{% endblocktranslate %}
</i>
{% endif %}
{% endfor %}
</fieldset>
<div
id="subscription-form-user-mini-profile"
x-html="profileFragment"
:aria-busy="loading"
></div>
</div>

View File

@@ -90,7 +90,7 @@ class TestSubscriptionIntegration(TestCase):
s = Subscription(
member=self.user,
subscription_type=list(settings.SITH_SUBSCRIPTIONS.keys())[3],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0],
)
s.subscription_start = date(2017, 8, 29)
s.subscription_end = s.compute_end(duration=0.166, start=s.subscription_start)
@@ -101,7 +101,7 @@ class TestSubscriptionIntegration(TestCase):
s = Subscription(
member=self.user,
subscription_type=list(settings.SITH_SUBSCRIPTIONS.keys())[3],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0],
)
s.subscription_start = date(2017, 8, 29)
s.subscription_end = s.compute_end(duration=0.333, start=s.subscription_start)
@@ -112,7 +112,7 @@ class TestSubscriptionIntegration(TestCase):
s = Subscription(
member=self.user,
subscription_type=list(settings.SITH_SUBSCRIPTIONS.keys())[3],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0],
)
s.subscription_start = date(2017, 8, 29)
s.subscription_end = s.compute_end(
@@ -126,7 +126,7 @@ class TestSubscriptionIntegration(TestCase):
s = Subscription(
member=self.user,
subscription_type=list(settings.SITH_SUBSCRIPTIONS.keys())[3],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0],
)
s.subscription_start = date(2017, 8, 29)
s.subscription_end = s.compute_end(duration=0.5, start=s.subscription_start)
@@ -137,7 +137,7 @@ class TestSubscriptionIntegration(TestCase):
s = Subscription(
member=self.user,
subscription_type=list(settings.SITH_SUBSCRIPTIONS.keys())[3],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0],
)
s.subscription_start = date(2017, 8, 29)
s.subscription_end = s.compute_end(duration=0.67, start=s.subscription_start)
@@ -148,7 +148,7 @@ class TestSubscriptionIntegration(TestCase):
s = Subscription(
member=self.user,
subscription_type=list(settings.SITH_SUBSCRIPTIONS.keys())[3],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0],
)
s.subscription_start = date(2018, 9, 1)
s.subscription_end = s.compute_end(duration=0.23, start=s.subscription_start)
@@ -160,7 +160,7 @@ class TestSubscriptionIntegration(TestCase):
s = Subscription(
member=user,
subscription_type="deux-semestres",
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0],
)
s.subscription_start = date(2015, 8, 29)
s.subscription_end = s.compute_end(
@@ -181,7 +181,7 @@ class TestSubscriptionIntegration(TestCase):
s = Subscription(
member=user,
subscription_type="deux-mois-essai",
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0],
)
s.subscription_start = date(2015, 8, 29)
s.subscription_end = s.compute_end(
@@ -202,7 +202,7 @@ class TestSubscriptionIntegration(TestCase):
s = Subscription(
member=user,
subscription_type="deux-mois-essai",
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0],
)
s.subscription_start = date(2015, 8, 29)
s.subscription_end = s.compute_end(

View File

@@ -38,7 +38,7 @@ def test_form_existing_user_valid(
"birthdate": user.date_of_birth,
"subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
}
form = SubscriptionExistingUserForm(data)
assert form.is_valid()
@@ -55,7 +55,7 @@ def test_form_existing_user_with_birthdate(settings: SettingsWrapper):
"member": user,
"subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
}
form = SubscriptionExistingUserForm(data)
assert not form.is_valid()
@@ -81,7 +81,7 @@ def test_form_existing_user_invalid(settings: SettingsWrapper):
"member": user,
"subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
}
form = SubscriptionExistingUserForm(data)
@@ -99,7 +99,7 @@ def test_form_new_user(settings: SettingsWrapper):
"date_of_birth": localdate() - relativedelta(years=18),
"subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
}
form = SubscriptionNewUserForm(data)
assert form.is_valid()
@@ -130,7 +130,7 @@ def test_form_set_new_user_as_student(settings: SettingsWrapper, subscription_ty
"date_of_birth": localdate() - relativedelta(years=18),
"subscription_type": subscription_type,
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
}
form = SubscriptionNewUserForm(data)
assert form.is_valid()
@@ -180,7 +180,7 @@ def test_submit_form_existing_user(client: Client, settings: SettingsWrapper):
"birthdate": user.date_of_birth,
"subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
},
)
user.refresh_from_db()
@@ -212,7 +212,7 @@ def test_submit_form_new_user(client: Client, settings: SettingsWrapper):
"date_of_birth": localdate() - relativedelta(years=18),
"subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
},
)
user = User.objects.get(email="jdoe@utbm.fr")

View File

View File

@@ -1 +0,0 @@
# Register your models here.

View File

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

Some files were not shown because too many files have changed in this diff Show More