Compare commits

..

48 Commits

Author SHA1 Message Date
Kenneth SOARES
2550fd0af7 update subscription price 2025-10-06 13:37:50 +02:00
thomas girod
4b44e50780 Merge pull request #1193 from ae-utbm/optimize-jinja
Optimisations
2025-10-02 19:05:03 +02:00
imperosol
40c3276c3c remove spaces from autocomplete selects 2025-09-29 17:43:50 +02:00
imperosol
543a424258 fix: N+1 on news list for admins 2025-09-29 16:10:50 +02:00
imperosol
8ff25e6034 optimize main page notifications 2025-09-29 08:45:56 +02:00
thomas girod
03f53e921b Merge pull request #1192 from ae-utbm/fix-add-member
fix: wrong text on member form submit button
2025-09-27 18:01:10 +02:00
imperosol
56f09fd739 fix: wrong text on member form submit button 2025-09-27 17:40:18 +02:00
thomas girod
19e3fc604d Merge pull request #1172 from ae-utbm/htmx-club
HTMXify club members page
2025-09-27 17:29:16 +02:00
imperosol
24e1ad6dc8 apply review comments 2025-09-27 17:06:43 +02:00
Bartuccio Antoine
289ffe1109 Merge pull request #1190 from ae-utbm/alpine-notifications
Add alpine notifications plugin
2025-09-26 18:29:04 +02:00
imperosol
eadf74604c Split ClubMemberForm into JoinClubForm and ClubAddMemberForm 2025-09-26 18:23:49 +02:00
imperosol
cc58479a19 use new notifications system 2025-09-26 16:00:31 +02:00
imperosol
c03b6e5d9d add tests 2025-09-26 15:49:36 +02:00
imperosol
66cf2bd957 Better management of roles in ClubMemberForm 2025-09-26 15:49:33 +02:00
imperosol
3e8f3b9275 feat: success message on membership creation 2025-09-26 15:49:24 +02:00
imperosol
c7363de44f improve new member form style 2025-09-26 15:49:24 +02:00
imperosol
966fe0ec0e fix: N+1 queries on old club members view 2025-09-26 15:49:24 +02:00
imperosol
fd0af3a804 HTMXify club members page 2025-09-26 15:49:24 +02:00
imperosol
7db66bb8f6 feat: MembershipQuerySet.editable_by method 2025-09-26 15:49:24 +02:00
thomas girod
ff5bb04af1 Merge pull request #1188 from ae-utbm/autocomplete-sas
Clear tom select text when identifying users in SAS
2025-09-26 15:48:24 +02:00
Sli
ca50e5dc81 Add alpine notifications plugin 2025-09-26 14:54:26 +02:00
Bartuccio Antoine
f015bde768 Merge pull request #1186 from ae-utbm/jquery
Remove JQuery
2025-09-26 14:36:02 +02:00
Sli
bb09fd0feb Apply review comments 2025-09-26 14:33:17 +02:00
Sli
210278440a Change notification zone position 2025-09-26 13:36:36 +02:00
Sli
e041da9cf4 Remove unnecessary complex anonymous callback on poster list 2025-09-25 22:07:29 +02:00
Sli
54c1957776 Move notifications from eboutic checkout to billing info fragment 2025-09-25 16:02:56 +02:00
Sli
30356d97f3 Use SuccessMessageMixin on trombi 2025-09-25 16:02:56 +02:00
Sli
7eaf25a64f Remove QuikNotifMixin 2025-09-25 16:02:56 +02:00
Sli
c6e86841b3 Remove jquery remeanants 2025-09-25 16:02:56 +02:00
Sli
cbe9887efb Create unified notification system 2025-09-25 16:02:55 +02:00
Noa Fouich
980952807a Merge pull request #1189 from ae-utbm/deleted_barman_user_fix
Deleted barman user fix
2025-09-25 16:01:36 +02:00
Noa Fouich
0b7c516f18 adding test 2025-09-25 15:57:21 +02:00
Noa Fouich
e186052283 Fix deleted barman on user account
# Conflicts:
#	locale/fr/LC_MESSAGES/django.po
2025-09-25 15:57:16 +02:00
imperosol
ec80b72a25 clear tom select text when identifying users in SAS 2025-09-25 07:38:44 +02:00
Bartuccio Antoine
6cd3875b2b Merge pull request #1187 from ae-utbm/fix-search
Remove `s` shortcut for search bar
2025-09-24 18:09:00 +02:00
Sli
ad8b003336 Remove s shortcut for search bar 2025-09-24 16:36:55 +02:00
Bartuccio Antoine
b4f5a866e3 Merge pull request #1185 from ae-utbm/posters
Remove jquery from posters
2025-09-23 14:59:24 +02:00
Sli
d87b069769 Apply review comments 2025-09-23 10:28:05 +02:00
thomas girod
9461b2e5d9 Merge pull request #1184 from ae-utbm/page-N+1
fix: N+1 query on PageListView
2025-09-23 09:18:24 +02:00
Sli
4701c0804b Fix slideshow transition 2025-09-22 23:06:18 +02:00
imperosol
acb6c6ce9c fix: N+1 query on PageListView 2025-09-22 18:14:14 +02:00
Sli
95e6fff98b Migrate poster view to alpine 2025-09-22 14:30:23 +02:00
thomas girod
f1a5a0781c Merge pull request #1181 from ae-utbm/fix-subscription
Fix subscription
2025-09-22 13:41:15 +02:00
imperosol
854dd2d9e7 add disclaimer for subscription purchase with AE account 2025-09-22 13:28:42 +02:00
imperosol
a7c96425c8 fix: ClubSellingView N+1 queries 2025-09-22 13:28:42 +02:00
Sli
dff23fae7f Migrate slideshow to alpine 2025-09-22 13:26:28 +02:00
thomas girod
34b0dc3302 Merge pull request #1182 from ae-utbm/fix-pagerev
fix: 500 on page properties edit
2025-09-22 13:04:22 +02:00
imperosol
ce2ef78a6d fix: 500 on page properties edit 2025-09-21 16:01:17 +02:00
68 changed files with 1424 additions and 973 deletions

View File

@@ -26,12 +26,16 @@ from django import forms
from django.conf import settings from django.conf import settings
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from club.models import Club, Mailing, MailingSubscription, Membership from club.models import Club, Mailing, MailingSubscription, Membership
from core.models import User from core.models import User
from core.views.forms import SelectDate, SelectDateTime from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import AutoCompleteSelectMultipleUser from core.views.widgets.ajax_select import (
AutoCompleteSelectMultipleUser,
AutoCompleteSelectUser,
)
from counter.models import Counter, Selling from counter.models import Counter, Selling
@@ -188,70 +192,81 @@ class SellingsForm(forms.Form):
) )
class ClubMemberForm(forms.Form): class ClubOldMemberForm(forms.Form):
"""Form handling the members of a club.""" 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."""
error_css_class = "error" error_css_class = "error"
required_css_class = "required" required_css_class = "required"
users = forms.ModelMultipleChoiceField( class Meta:
label=_("Users to add"), model = Membership
help_text=_("Search users to add (one or more)."), fields = ["role", "description"]
required=False,
widget=AutoCompleteSelectMultipleUser,
queryset=User.objects.all(),
)
def __init__(self, *args, **kwargs): def __init__(self, *args, club: Club, request_user: User, **kwargs):
self.club = kwargs.pop("club") self.club = club
self.request_user = kwargs.pop("request_user") self.request_user = request_user
self.club_members = kwargs.pop("club_members", None)
if not self.club_members:
self.club_members = self.club.members.ongoing().order_by("-role").all()
self.request_user_membership = self.club.get_membership_for(self.request_user) self.request_user_membership = self.club.get_membership_for(self.request_user)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["role"].required = True
# Using a ModelForm binds too much the form with the model and we don't want that self.fields["role"].choices = [
# We want the view to process the model creation since they are multiple users (value, name)
# We also want the form to handle bulk deletion for value, name in settings.SITH_CLUB_ROLES.items()
self.fields.update( if value <= self.max_available_role
forms.fields_for_model(
Membership,
fields=("role", "start_date", "description"),
widgets={"start_date": SelectDate},
)
)
# Role is required only if users is specified
self.fields["role"].required = False
# Start date and description are never really required
self.fields["start_date"].required = False
self.fields["description"].required = False
self.fields["users_old"] = forms.ModelMultipleChoiceField(
User.objects.filter(
id__in=[
ms.user.id
for ms in self.club_members
if ms.can_be_edited_by(self.request_user)
] ]
).all(), self.instance.club = club
label=_("Mark as old"),
required=False,
widget=forms.CheckboxSelectMultiple,
)
if not self.request_user.is_root:
self.fields.pop("start_date")
def clean_users(self): @property
"""Check that the user is not trying to add an user already in the club. def max_available_role(self):
"""The greatest role that will be obtainable with this form."""
# 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.
Also check that the user is valid and has a valid subscription. Also check that the user is valid and has a valid subscription.
""" """
cleaned_data = super().clean() user = self.cleaned_data["user"]
users = []
for user in cleaned_data["users"]:
if not user.is_subscribed: if not user.is_subscribed:
raise forms.ValidationError( raise forms.ValidationError(
_("User must be subscriber to take part to a club"), code="invalid" _("User must be subscriber to take part to a club"), code="invalid"
@@ -260,33 +275,30 @@ class ClubMemberForm(forms.Form):
raise forms.ValidationError( raise forms.ValidationError(
_("You can not add the same user twice"), code="invalid" _("You can not add the same user twice"), code="invalid"
) )
users.append(user) return user
return users
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
def clean(self): def clean(self):
"""Check user rights for adding an user.""" """Check that the user is subscribed and isn't already in the club."""
cleaned_data = super().clean() if not self.request_user.is_subscribed:
raise forms.ValidationError(
if "start_date" in cleaned_data and not cleaned_data["start_date"]: _("You must be subscribed to join a club"), code="invalid"
# Drop start_date if allowed to edition but not specified )
cleaned_data.pop("start_date") if self.club.get_membership_for(self.request_user):
raise forms.ValidationError(
if not cleaned_data.get("users"): _("You are already a member of this club"), code="invalid"
# No user to add equals no check needed )
return cleaned_data return super().clean()
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,7 +30,8 @@ from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email from django.core.validators import RegexValidator, validate_email
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q from django.db.models import Exists, F, OuterRef, Q, Value
from django.db.models.functions import Greatest
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
@@ -209,10 +210,6 @@ class Club(models.Model):
"""Method to see if that object can be edited by the given user.""" """Method to see if that object can be edited by the given user."""
return self.has_rights_in_club(user) return self.has_rights_in_club(user)
def can_be_viewed_by(self, user: User) -> bool:
"""Method to see if that object can be seen by the given user."""
return user.was_subscribed
def get_membership_for(self, user: User) -> Membership | None: def get_membership_for(self, user: User) -> Membership | None:
"""Return the current membership the given user. """Return the current membership the given user.
@@ -252,6 +249,44 @@ class MembershipQuerySet(models.QuerySet):
""" """
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE) return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
def editable_by(self, user: User) -> Self:
"""Filter Memberships that this user can edit.
Users with the `club.change_membership` permission can edit all Membership.
The other users can 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: def update(self, **kwargs) -> int:
"""Refresh the cache and edit group ownership. """Refresh the cache and edit group ownership.
@@ -328,16 +363,12 @@ class Membership(models.Model):
User, User,
verbose_name=_("user"), verbose_name=_("user"),
related_name="memberships", related_name="memberships",
null=False,
blank=False,
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
club = models.ForeignKey( club = models.ForeignKey(
Club, Club,
verbose_name=_("club"), verbose_name=_("club"),
related_name="members", related_name="members",
null=False,
blank=False,
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
start_date = models.DateField(_("start date"), default=timezone.now) start_date = models.DateField(_("start date"), default=timezone.now)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,12 +23,14 @@
# #
import csv import csv
from typing import Any
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator from django.core.paginator import InvalidPage, Paginator
from django.db.models import Sum from django.db.models import Q, Sum
from django.http import ( from django.http import (
Http404, Http404,
HttpResponseRedirect, HttpResponseRedirect,
@@ -37,20 +39,28 @@ from django.http import (
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.safestring import SafeString
from django.utils.timezone import now
from django.utils.translation import gettext as _t from django.utils.translation import gettext as _t
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, View from django.views.generic import DetailView, ListView, View
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.forms import ( from club.forms import (
ClubAddMemberForm,
ClubAdminEditForm, ClubAdminEditForm,
ClubEditForm, ClubEditForm,
ClubMemberForm, ClubOldMemberForm,
JoinClubForm,
MailingForm, MailingForm,
SellingsForm, SellingsForm,
) )
from club.models import Club, Mailing, MailingSubscription, Membership from club.models import (
Club,
Mailing,
MailingSubscription,
Membership,
)
from com.models import Poster from com.models import Poster
from com.views import ( from com.views import (
PosterCreateBaseView, PosterCreateBaseView,
@@ -60,11 +70,10 @@ from com.views import (
) )
from core.auth.mixins import ( from core.auth.mixins import (
CanEditMixin, CanEditMixin,
CanViewMixin,
) )
from core.models import PageRev from core.models import PageRev
from core.views import DetailFormView, PageEditViewBase from core.views import DetailFormView, PageEditViewBase, UseFragmentsMixin
from core.views.mixins import TabedViewMixin from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin
from counter.models import Selling from counter.models import Selling
@@ -86,7 +95,7 @@ class ClubTabsMixin(TabedViewMixin):
"name": _("Infos"), "name": _("Infos"),
} }
] ]
if self.request.user.can_view(self.object): if self.request.user.has_perm("club.view_club"):
tab_list.extend( tab_list.extend(
[ [
{ {
@@ -235,13 +244,14 @@ class ClubPageEditView(ClubTabsMixin, PageEditViewBase):
return reverse_lazy("club:club_view", kwargs={"club_id": self.club.id}) return reverse_lazy("club:club_view", kwargs={"club_id": self.club.id})
class ClubPageHistView(ClubTabsMixin, CanViewMixin, DetailView): class ClubPageHistView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
"""Modification hostory of the page.""" """Modification hostory of the page."""
model = Club model = Club
pk_url_kwarg = "club_id" pk_url_kwarg = "club_id"
template_name = "club/page_history.jinja" template_name = "club/page_history.jinja"
current_tab = "history" current_tab = "history"
permission_required = "club.view_club"
class ClubToolsView(ClubTabsMixin, CanEditMixin, DetailView): class ClubToolsView(ClubTabsMixin, CanEditMixin, DetailView):
@@ -253,57 +263,121 @@ class ClubToolsView(ClubTabsMixin, CanEditMixin, DetailView):
current_tab = "tools" current_tab = "tools"
class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView): class ClubAddMembersFragment(
FragmentMixin, PermissionRequiredMixin, SuccessMessageMixin, CreateView
):
template_name = "club/fragments/add_member.jinja"
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
):
"""View of a club's members.""" """View of a club's members."""
model = Club model = Club
pk_url_kwarg = "club_id" pk_url_kwarg = "club_id"
form_class = ClubMemberForm form_class = ClubOldMemberForm
template_name = "club/club_members.jinja" template_name = "club/club_members.jinja"
current_tab = "members" current_tab = "members"
permission_required = "club.view_club"
@cached_property def get_fragments(self) -> dict[str, type[FragmentMixin] | FragmentRenderer]:
def members(self) -> list[Membership]: membership = self.object.get_membership_for(self.request.user)
return list(self.object.members.ongoing().order_by("-role")) if (
membership
and membership.role <= settings.SITH_MAXIMUM_FREE_ROLE
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}}
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() return super().get_form_kwargs() | {
kwargs["request_user"] = self.request.user "user": self.request.user,
kwargs["club"] = self.object "club": self.object,
kwargs["club_members"] = self.members }
return kwargs
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["members"] = self.members editable = list(
kwargs["form"].fields["members_old"].queryset.values_list("id", flat=True)
)
kwargs["members"] = list(
self.object.members.ongoing()
.annotate(is_editable=Q(id__in=editable))
.order_by("-role")
.select_related("user")
)
kwargs["can_end_membership"] = len(editable) > 0
return kwargs return kwargs
def form_valid(self, form): def form_valid(self, form):
"""Check user rights.""" for membership in form.cleaned_data.get("members_old"):
resp = super().form_valid(form) membership.end_date = now()
data = form.clean()
users = data.pop("users", [])
users_old = data.pop("users_old", [])
for user in users:
Membership(club=self.object, user=user, **data).save()
for user in users_old:
membership = self.object.get_membership_for(user)
membership.end_date = timezone.now()
membership.save() membership.save()
return resp return super().form_valid(form)
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return self.request.path return self.request.path
class ClubOldMembersView(ClubTabsMixin, CanViewMixin, DetailView): class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
"""Old members of a club.""" """Old members of a club."""
model = Club model = Club
pk_url_kwarg = "club_id" pk_url_kwarg = "club_id"
template_name = "club/club_old_members.jinja" template_name = "club/club_old_members.jinja"
current_tab = "elderlies" current_tab = "elderlies"
permission_required = "club.view_club"
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"old_members": (
self.object.members.exclude(end_date=None)
.order_by("-role", "description", "-end_date")
.select_related("user")
)
}
class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView): class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
@@ -344,7 +418,7 @@ class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
form = self.get_form() form = self.get_form()
if form.is_valid(): if form.is_valid():
if not len([v for v in form.cleaned_data.values() if v is not None]): if not len([v for v in form.cleaned_data.values() if v is not None]):
qs = Selling.objects.filter(id=-1) qs = Selling.objects.none()
if form.cleaned_data["begin_date"]: if form.cleaned_data["begin_date"]:
qs = qs.filter(date__gte=form.cleaned_data["begin_date"]) qs = qs.filter(date__gte=form.cleaned_data["begin_date"])
if form.cleaned_data["end_date"]: if form.cleaned_data["end_date"]:
@@ -362,7 +436,9 @@ class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
if len(selected_products) > 0: if len(selected_products) > 0:
qs = qs.filter(product__in=selected_products) qs = qs.filter(product__in=selected_products)
kwargs["result"] = qs.all().order_by("-id") kwargs["result"] = qs.select_related(
"counter", "counter__club", "customer", "customer__user", "seller"
).order_by("-id")
kwargs["total"] = sum([s.quantity * s.unit_price for s in kwargs["result"]]) kwargs["total"] = sum([s.quantity * s.unit_price for s in kwargs["result"]])
total_quantity = qs.all().aggregate(Sum("quantity")) total_quantity = qs.all().aggregate(Sum("quantity"))
if total_quantity["quantity__sum"]: if total_quantity["quantity__sum"]:

View File

@@ -0,0 +1,49 @@
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

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

View File

@@ -1,23 +0,0 @@
$(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

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

View File

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

View File

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

View File

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

View File

@@ -31,9 +31,7 @@
<td> <td>
<a href="{{ url('com:weekmail_article_edit', article_id=a.id) }}">{% trans %}Edit{% endtrans %}</a> | <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="{{ 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> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

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

View File

@@ -768,7 +768,7 @@ class Command(BaseCommand):
s = Subscription( s = Subscription(
member=user, member=user,
subscription_type=subscription_type, subscription_type=subscription_type,
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0],
) )
s.subscription_start = s.compute_start(start) s.subscription_start = s.compute_start(start)
s.subscription_end = s.compute_end( s.subscription_end = s.compute_end(

View File

@@ -1197,6 +1197,18 @@ class NotLocked(LockError):
pass 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 # This function prevents generating migration upon settings change
def get_default_owner_group(): def get_default_owner_group():
return settings.SITH_GROUP_ROOT_ID return settings.SITH_GROUP_ROOT_ID
@@ -1266,6 +1278,8 @@ class Page(models.Model):
_("lock_timeout"), null=True, blank=True, default=None _("lock_timeout"), null=True, blank=True, default=None
) )
objects = PageQuerySet.as_manager()
class Meta: class Meta:
unique_together = ("name", "parent") unique_together = ("name", "parent")
permissions = ( permissions = (
@@ -1275,12 +1289,9 @@ class Page(models.Model):
def __str__(self): def __str__(self):
return self.get_full_name() return self.get_full_name()
def save(self, *args, **kwargs): def save(self, *args, force_lock: bool = False, **kwargs):
"""Performs some needed actions before and after saving a page in database.""" """Performs some needed actions before and after saving a page in database."""
locked = kwargs.pop("force_lock", False) if not force_lock and not self.is_locked():
if not locked:
locked = self.is_locked()
if not locked:
raise NotLocked("The page is not locked and thus can not be saved") raise NotLocked("The page is not locked and thus can not be saved")
self.full_clean() self.full_clean()
if not self.id: if not self.id:
@@ -1292,7 +1303,7 @@ class Page(models.Model):
# It also update all the children to maintain correct names # It also update all the children to maintain correct names
self._full_name = self.get_full_name() self._full_name = self.get_full_name()
for c in self.children.all(): for c in self.children.all():
c.save() c.save(force_lock=force_lock)
super().save(*args, **kwargs) super().save(*args, **kwargs)
self.unset_lock() self.unset_lock()
@@ -1408,14 +1419,14 @@ class Page(models.Model):
def need_club_redirection(self): def need_club_redirection(self):
return self.is_club_page and self.name != settings.SITH_CLUB_ROOT_PAGE return self.is_club_page and self.name != settings.SITH_CLUB_ROOT_PAGE
def delete(self): def delete(self, *args, **kwargs):
self.unset_lock_recursive() self.unset_lock_recursive()
self.set_lock_recursive(User.objects.get(id=0)) self.set_lock_recursive(User.objects.get(id=0))
for child in self.children.all(): for child in self.children.all():
child.parent = self.parent child.parent = self.parent
child.save() child.save()
child.unset_lock_recursive() child.unset_lock_recursive()
super().delete() return super().delete(*args, **kwargs)
class PageRev(models.Model): class PageRev(models.Model):
@@ -1462,9 +1473,12 @@ class PageRev(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return reverse("core:page", kwargs={"page_name": self.page._full_name}) return reverse("core:page", kwargs={"page_name": self.page._full_name})
def can_be_edited_by(self, user): def can_be_edited_by(self, user: User) -> bool:
return self.page.can_be_edited_by(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(): def get_notification_types():
return settings.SITH_NOTIFICATIONS return settings.SITH_NOTIFICATIONS

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,10 +32,6 @@
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script> <script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/core/tooltips-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_css %}{% endblock %}
{% block additional_js %}{% endblock %} {% block additional_js %}{% endblock %}
{% endblock %} {% endblock %}
@@ -74,17 +70,15 @@
<div id="page"> <div id="page">
<ul id="quick_notif">
{% for n in quick_notifs %}
<li>{{ n }}</li>
{% endfor %}
</ul>
<div id="content"> <div id="content">
{%- block tabs -%} {%- block tabs -%}
{% include "core/base/tabs.jinja" %} {% include "core/base/tabs.jinja" %}
{%- endblock -%} {%- endblock -%}
{% block notifications %}
{% include "core/base/notifications.jinja" %}
{% endblock %}
{%- block errors -%} {%- block errors -%}
{% if error %} {% if error %}
{{ error }} {{ error }}
@@ -101,16 +95,6 @@
{% endblock %} {% endblock %}
{% block script %} {% 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 %} {% endblock %}
</body> </body>
</html> </html>

View File

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

View File

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

View File

@@ -30,7 +30,11 @@
- {{ purchase.date|localtime|time(DATETIME_FORMAT) }} - {{ purchase.date|localtime|time(DATETIME_FORMAT) }}
</td> </td>
<td>{{ purchase.counter }}</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> <td><a href="{{ purchase.seller.get_absolute_url() }}">{{ purchase.seller.get_display_name() }}</a></td>
{% endif %}
<td>{{ purchase.label }}</td> <td>{{ purchase.label }}</td>
<td>{{ purchase.quantity }}</td> <td>{{ purchase.quantity }}</td>
<td>{{ purchase.quantity * purchase.unit_price }} €</td> <td>{{ purchase.quantity * purchase.unit_price }} €</td>

View File

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

58
core/tests/test_page.py Normal file
View File

@@ -0,0 +1,58 @@
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,7 +20,8 @@ from core.baker_recipes import (
) )
from core.models import Group, User from core.models import Group, User
from core.views import UserTabsMixin from core.views import UserTabsMixin
from counter.models import Counter, Refilling, Selling from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Refilling, Selling
from eboutic.models import Invoice, InvoiceItem from eboutic.models import Invoice, InvoiceItem
@@ -129,6 +130,31 @@ def test_user_account_not_found(client: Client):
assert res.status_code == 404 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): class TestFilterInactive(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@@ -2,7 +2,6 @@ import copy
import inspect import inspect
from typing import Any, ClassVar, LiteralString, Protocol, Unpack from typing import Any, ClassVar, LiteralString, Protocol, Unpack
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.template.loader import render_to_string from django.template.loader import render_to_string
@@ -41,36 +40,6 @@ class TabedViewMixin(View):
return kwargs 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: class AllowFragment:
"""Add `is_fragment` to templates. It's only True if the request is emitted by htmx""" """Add `is_fragment` to templates. It's only True if the request is emitted by htmx"""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,14 +22,6 @@
{% block content %} {% block content %}
<h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1> <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="eboutic" x-data="basket({{ last_purchase_time }})">
<div id="basket"> <div id="basket">
<h3>Panier</h3> <h3>Panier</h3>

View File

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

View File

@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-19 17:22+0200\n" "POT-Creation-Date: 2025-09-26 17:36+0200\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -174,12 +174,12 @@ msgid "You can not add the same user twice"
msgstr "Vous ne pouvez pas ajouter deux fois le même utilisateur" msgstr "Vous ne pouvez pas ajouter deux fois le même utilisateur"
#: club/forms.py #: club/forms.py
msgid "You should specify a role" msgid "You must be subscribed to join a club"
msgstr "Vous devez choisir un rôle" msgstr "Vous devez être cotisant pour faire partie d'un club"
#: club/forms.py sas/forms.py #: club/forms.py
msgid "You do not have the permission to do that" msgid "You are already a member of this club"
msgstr "Vous n'avez pas la permission de faire cela" msgstr "Vous êtes déjà membre de ce club."
#: club/models.py #: club/models.py
msgid "slug name" msgid "slug name"
@@ -350,11 +350,6 @@ msgstr "Depuis"
msgid "There are no members in this club." msgid "There are no members in this club."
msgstr "Il n'y a pas de membres dans ce club." msgstr "Il n'y a pas de membres dans ce club."
#: club/templates/club/club_members.jinja core/templates/core/file_detail.jinja
#: core/views/forms.py trombi/templates/trombi/detail.jinja
msgid "Add"
msgstr "Ajouter"
#: club/templates/club/club_old_members.jinja #: club/templates/club/club_old_members.jinja
msgid "Club old members" msgid "Club old members"
msgstr "Anciens membres du club" msgstr "Anciens membres du club"
@@ -569,6 +564,24 @@ msgstr ""
msgid "Save" msgid "Save"
msgstr "Sauver" 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 #: club/templates/club/mailing.jinja
msgid "Mailing lists" msgid "Mailing lists"
msgstr "Mailing listes" msgstr "Mailing listes"
@@ -675,6 +688,15 @@ msgstr "Vente"
msgid "Mailing list" msgid "Mailing list"
msgstr "Listes de diffusion" 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 #: com/forms.py
msgid "Format: 16:9 | Resolution: 1920x1080" msgid "Format: 16:9 | Resolution: 1920x1080"
msgstr "Format : 16:9 | Résolution : 1920x1080" msgstr "Format : 16:9 | Résolution : 1920x1080"
@@ -1103,6 +1125,10 @@ msgstr "Modération"
msgid "No posters" msgid "No posters"
msgstr "Aucune affiche" 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 #: com/templates/com/poster_moderate.jinja
msgid "Posters - moderation" msgid "Posters - moderation"
msgstr "Affiches - modération" msgstr "Affiches - modération"
@@ -1160,14 +1186,6 @@ msgstr "Contenu"
msgid "Add to weekmail" msgid "Add to weekmail"
msgstr "Ajouter au Weekmail" msgstr "Ajouter au Weekmail"
#: com/templates/com/weekmail.jinja
msgid "Up"
msgstr "Monter"
#: com/templates/com/weekmail.jinja
msgid "Down"
msgstr "Descendre"
#: com/templates/com/weekmail.jinja #: com/templates/com/weekmail.jinja
msgid "Articles included the next weekmail" msgid "Articles included the next weekmail"
msgstr "Article inclus dans le prochain Weekmail" msgstr "Article inclus dans le prochain Weekmail"
@@ -1176,6 +1194,14 @@ msgstr "Article inclus dans le prochain Weekmail"
msgid "Delete from weekmail" msgid "Delete from weekmail"
msgstr "Supprimer du Weekmail" msgstr "Supprimer du Weekmail"
#: com/templates/com/weekmail.jinja
msgid "Up"
msgstr "Monter"
#: com/templates/com/weekmail.jinja
msgid "Down"
msgstr "Descendre"
#: com/templates/com/weekmail_preview.jinja #: com/templates/com/weekmail_preview.jinja
#: core/templates/core/user_account_detail.jinja #: core/templates/core/user_account_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
@@ -1257,6 +1283,10 @@ msgstr "Liste d'écrans"
msgid "All incoming events" msgid "All incoming events"
msgstr "Tous les événements à venir" msgstr "Tous les événements à venir"
#: com/views.py
msgid "Weekmail sent successfully"
msgstr "Weekmail envoyé avec succès"
#: com/views.py #: com/views.py
msgid "Delete and save to regenerate" msgid "Delete and save to regenerate"
msgstr "Supprimer et sauver pour régénérer" msgstr "Supprimer et sauver pour régénérer"
@@ -1265,6 +1295,26 @@ msgstr "Supprimer et sauver pour régénérer"
msgid "Weekmail of the " msgid "Weekmail of the "
msgstr "Weekmail du " 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 #: com/views.py
msgid "" msgid ""
"You must be a board member of the selected club to post in the Weekmail." "You must be a board member of the selected club to post in the Weekmail."
@@ -2340,6 +2390,10 @@ msgstr "Etickets"
msgid "User has no account" msgid "User has no account"
msgstr "L'utilisateur n'a pas de compte" 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 #: core/templates/core/user_account_detail.jinja
#: counter/templates/counter/last_ops.jinja #: counter/templates/counter/last_ops.jinja
#: counter/templates/counter/refilling_list.jinja #: counter/templates/counter/refilling_list.jinja
@@ -4540,22 +4594,6 @@ msgstr "Signaler ce commentaire"
msgid "Edit UE" msgid "Edit UE"
msgstr "Éditer l'UE" msgstr "Éditer l'UE"
#: pedagogy/templates/pedagogy/uv_edit.jinja
msgid "Import from UTBM"
msgstr "Importer depuis l'UTBM"
#: pedagogy/templates/pedagogy/uv_edit.jinja
msgid "Unknown UE code"
msgstr "Code d'UE inconnu"
#: pedagogy/templates/pedagogy/uv_edit.jinja
msgid "Successful autocomplete"
msgstr "Autocomplétion réussite"
#: pedagogy/templates/pedagogy/uv_edit.jinja
msgid "An error occurred: "
msgstr "Une erreur est survenue : "
#: rootplace/forms.py #: rootplace/forms.py
msgid "User that will be kept" msgid "User that will be kept"
msgstr "Utilisateur qui sera conservé" msgstr "Utilisateur qui sera conservé"
@@ -4629,6 +4667,10 @@ msgstr "Pas de ban actif"
msgid "Add a new album" msgid "Add a new album"
msgstr "Ajouter un nouvel 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 #: sas/forms.py
msgid "Upload images" msgid "Upload images"
msgstr "Envoyer les images" msgstr "Envoyer les images"
@@ -4819,8 +4861,8 @@ msgid "N/A"
msgstr "N/A" msgstr "N/A"
#: sith/settings.py #: sith/settings.py
msgid "Transfert" msgid "AE account"
msgstr "Virement" msgstr "Compte AE"
#: sith/settings.py #: sith/settings.py
msgid "Belfort" msgid "Belfort"
@@ -4932,47 +4974,47 @@ msgstr "Suppression de rechargement"
#: sith/settings.py #: sith/settings.py
msgid "One semester" msgid "One semester"
msgstr "Un semestre, 20 €" msgstr "Un semestre"
#: sith/settings.py #: sith/settings.py
msgid "Two semesters" msgid "Two semesters"
msgstr "Deux semestres, 35 €" msgstr "Deux semestres"
#: sith/settings.py #: sith/settings.py
msgid "Common core cursus" msgid "Common core cursus"
msgstr "Cursus tronc commun, 60 €" msgstr "Cursus tronc commun"
#: sith/settings.py #: sith/settings.py
msgid "Branch cursus" msgid "Branch cursus"
msgstr "Cursus branche, 60 €" msgstr "Cursus branche"
#: sith/settings.py #: sith/settings.py
msgid "Alternating cursus" msgid "Alternating cursus"
msgstr "Cursus alternant, 30 €" msgstr "Cursus alternant"
#: sith/settings.py #: sith/settings.py
msgid "Honorary member" msgid "Honorary member"
msgstr "Membre honoraire, 0 €" msgstr "Membre honoraire"
#: sith/settings.py #: sith/settings.py
msgid "Assidu member" msgid "Assidu member"
msgstr "Membre d'Assidu, 0 €" msgstr "Membre d'Assidu"
#: sith/settings.py #: sith/settings.py
msgid "Amicale/DOCEO member" msgid "Amicale/DOCEO member"
msgstr "Membre de l'Amicale/DOCEO, 0 €" msgstr "Membre de l'Amicale/DOCEO"
#: sith/settings.py #: sith/settings.py
msgid "UT network member" msgid "UT network member"
msgstr "Cotisant du réseau UT, 0 €" msgstr "Cotisant du réseau UT"
#: sith/settings.py #: sith/settings.py
msgid "CROUS member" msgid "CROUS member"
msgstr "Membres du CROUS, 0 €" msgstr "Membres du CROUS"
#: sith/settings.py #: sith/settings.py
msgid "Sbarro/ESTA member" msgid "Sbarro/ESTA member"
msgstr "Membre de Sbarro ou de l'ESTA, 20 €" msgstr "Membre de Sbarro ou de l'ESTA"
#: sith/settings.py #: sith/settings.py
msgid "One semester Welcome Week" msgid "One semester Welcome Week"
@@ -4999,28 +5041,28 @@ msgid "One day"
msgstr "Un jour" msgstr "Un jour"
#: sith/settings.py #: sith/settings.py
msgid "GA staff member" msgid "GA staff member (2 weeks)"
msgstr "Membre staff GA (2 semaines), 1 €" msgstr "Membre staff GA (2 semaines)"
#: sith/settings.py #: sith/settings.py
msgid "One semester (-20%)" msgid "One semester (-20%)"
msgstr "Un semestre (-20%), 12 €" msgstr "Un semestre (-20%)"
#: sith/settings.py #: sith/settings.py
msgid "Two semesters (-20%)" msgid "Two semesters (-20%)"
msgstr "Deux semestres (-20%), 22 €" msgstr "Deux semestres (-20%)"
#: sith/settings.py #: sith/settings.py
msgid "Common core cursus (-20%)" msgid "Common core cursus (-20%)"
msgstr "Cursus tronc commun (-20%), 36 €" msgstr "Cursus tronc commun (-20%)"
#: sith/settings.py #: sith/settings.py
msgid "Branch cursus (-20%)" msgid "Branch cursus (-20%)"
msgstr "Cursus branche (-20%), 36 €" msgstr "Cursus branche (-20%)"
#: sith/settings.py #: sith/settings.py
msgid "Alternating cursus (-20%)" msgid "Alternating cursus (-20%)"
msgstr "Cursus alternant (-20%), 24 €" msgstr "Cursus alternant (-20%)"
#: sith/settings.py #: sith/settings.py
msgid "One year for free(CA offer)" msgid "One year for free(CA offer)"
@@ -5108,26 +5150,6 @@ msgstr "Vous avez acheté %s"
msgid "You have a notification" msgid "You have a notification"
msgstr "Vous avez une 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 #: sith/settings.py
msgid "AE tee-shirt" msgid "AE tee-shirt"
msgstr "Tee-shirt AE" msgstr "Tee-shirt AE"
@@ -5168,6 +5190,14 @@ msgstr "lieu"
msgid "You can not subscribe many time for the same period" 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" 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 #: subscription/templates/subscription/fragments/creation_success.jinja
#, python-format #, python-format
msgid "Subscription created for %(user)s" msgid "Subscription created for %(user)s"
@@ -5431,10 +5461,38 @@ msgstr "Mes photos"
msgid "Admin tools" msgid "Admin tools"
msgstr "Admin Trombi" 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 #: trombi/views.py
msgid "Explain why you rejected the comment" msgid "Explain why you rejected the comment"
msgstr "Expliquez pourquoi vous refusez le commentaire" 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 #: trombi/views.py
msgid "Rejected comment" msgid "Rejected comment"
msgstr "Commentaire rejeté" msgstr "Commentaire rejeté"
@@ -5475,6 +5533,10 @@ msgstr ""
"pouvez vous inscrire qu'à un seul Trombi, donc ne jouez pas avec cet option " "pouvez vous inscrire qu'à un seul Trombi, donc ne jouez pas avec cet option "
"ou vous encourerez la colère des admins!" "ou vous encourerez la colère des admins!"
#: trombi/views.py
msgid "User modified"
msgstr "Utilisateur modifié"
#: trombi/views.py #: trombi/views.py
msgid "Personal email (not UTBM)" msgid "Personal email (not UTBM)"
msgstr "Email personnel (pas UTBM)" msgstr "Email personnel (pas UTBM)"
@@ -5487,6 +5549,14 @@ msgstr "Téléphone"
msgid "Native town" msgid "Native town"
msgstr "Ville d'origine" 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 #: trombi/views.py
msgid "" msgid ""
"You can not yet write comment, you must wait for the subscription deadline " "You can not yet write comment, you must wait for the subscription deadline "

25
package-lock.json generated
View File

@@ -30,7 +30,6 @@
"easymde": "^2.19.0", "easymde": "^2.19.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"htmx.org": "^2.0.3", "htmx.org": "^2.0.3",
"jquery": "^3.7.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lit-html": "^3.3.0", "lit-html": "^3.3.0",
"native-file-system-adapter": "^3.0.1", "native-file-system-adapter": "^3.0.1",
@@ -47,7 +46,6 @@
"@types/alpinejs": "^3.13.10", "@types/alpinejs": "^3.13.10",
"@types/cytoscape-cxtmenu": "^3.4.4", "@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-klay": "^3.1.4", "@types/cytoscape-klay": "^3.1.4",
"@types/jquery": "^3.5.31",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.6", "vite": "^6.3.6",
@@ -2889,16 +2887,6 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT" "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": { "node_modules/@types/js-cookie": {
"version": "3.0.6", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
@@ -2919,13 +2907,6 @@
"integrity": "sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w==", "integrity": "sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w==",
"license": "MIT" "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": { "node_modules/@types/tern": {
"version": "0.23.9", "version": "0.23.9",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
@@ -4384,12 +4365,6 @@
"jiti": "lib/jiti-cli.mjs" "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": { "node_modules/js-cookie": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",

View File

@@ -32,7 +32,6 @@
"@types/alpinejs": "^3.13.10", "@types/alpinejs": "^3.13.10",
"@types/cytoscape-cxtmenu": "^3.4.4", "@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-klay": "^3.1.4", "@types/cytoscape-klay": "^3.1.4",
"@types/jquery": "^3.5.31",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.6", "vite": "^6.3.6",
@@ -61,7 +60,6 @@
"easymde": "^2.19.0", "easymde": "^2.19.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"htmx.org": "^2.0.3", "htmx.org": "^2.0.3",
"jquery": "^3.7.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lit-html": "^3.3.0", "lit-html": "^3.3.0",
"native-file-system-adapter": "^3.0.1", "native-file-system-adapter": "^3.0.1",

View File

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

View File

@@ -21,11 +21,6 @@
{{ field.errors }} {{ field.errors }}
<label for="{{ field.name }}">{{ field.label }}</label> <label for="{{ field.name }}">{{ field.label }}</label>
{{ field }} {{ field }}
{% if field.name == 'code' %}
<button type="button" id="autofill">{% trans %}Import from UTBM{% endtrans %}</button>
{% endif %}
</p> </p>
{% endif %} {% endif %}
@@ -36,48 +31,3 @@
<p><input type="submit" value="{% trans %}Update{% endtrans %}" /></p> <p><input type="submit" value="{% trans %}Update{% endtrans %}" /></p>
</form> </form>
{% endblock %} {% 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", "Pillow<12.0.0,>=11.1.0",
"mistune<4.0.0,>=3.1.3", "mistune<4.0.0,>=3.1.3",
"django-jinja<3.0.0,>=2.11.0", "django-jinja<3.0.0,>=2.11.0",
"cryptography>=45.0.3,<47.0.0", "cryptography>=45.0.3,<46.0.0",
"django-phonenumber-field<9.0.0,>=8.1.0", "django-phonenumber-field<9.0.0,>=8.1.0",
"phonenumbers>=9.0.2,<10.0.0", "phonenumbers>=9.0.2,<10.0.0",
"reportlab<5.0.0,>=4.3.1", "reportlab<5.0.0,>=4.3.1",

View File

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

View File

@@ -421,18 +421,11 @@ SITH_PROFILE_DEPARTMENTS = [
("NA", _("N/A")), ("NA", _("N/A")),
] ]
SITH_ACCOUNTING_PAYMENT_METHOD = [
("CHECK", _("Check")),
("CASH", _("Cash")),
("TRANSFERT", _("Transfert")),
("CARD", _("Credit card")),
]
SITH_SUBSCRIPTION_PAYMENT_METHOD = [ SITH_SUBSCRIPTION_PAYMENT_METHOD = [
("CHECK", _("Check")), ("CHECK", _("Check")),
("CARD", _("Credit card")), ("CARD", _("Credit card")),
("CASH", _("Cash")), ("CASH", _("Cash")),
("EBOUTIC", _("Eboutic")), ("AE_ACCOUNT", _("AE account")),
("OTHER", _("Other")), ("OTHER", _("Other")),
] ]
@@ -441,6 +434,7 @@ SITH_SUBSCRIPTION_LOCATIONS = [
("SEVENANS", _("Sevenans")), ("SEVENANS", _("Sevenans")),
("MONTBELIARD", _("Montbéliard")), ("MONTBELIARD", _("Montbéliard")),
("EBOUTIC", _("Eboutic")), ("EBOUTIC", _("Eboutic")),
("OTHER", _("Other")),
] ]
SITH_COUNTER_BARS = [(1, "MDE"), (2, "Foyer"), (35, "La Gommette")] SITH_COUNTER_BARS = [(1, "MDE"), (2, "Foyer"), (35, "La Gommette")]
@@ -547,7 +541,7 @@ SITH_SUBSCRIPTIONS = {
"duration": 4, "duration": 4,
}, },
"cursus-branche": {"name": _("Branch cursus"), "price": 60, "duration": 6}, "cursus-branche": {"name": _("Branch cursus"), "price": 60, "duration": 6},
"cursus-alternant": {"name": _("Alternating cursus"), "price": 30, "duration": 6}, "cursus-alternant": {"name": _("Alternating cursus"), "price": 35, "duration": 6},
"membre-honoraire": {"name": _("Honorary member"), "price": 0, "duration": 666}, "membre-honoraire": {"name": _("Honorary member"), "price": 0, "duration": 666},
"assidu": {"name": _("Assidu member"), "price": 0, "duration": 2}, "assidu": {"name": _("Assidu member"), "price": 0, "duration": 2},
"amicale/doceo": {"name": _("Amicale/DOCEO member"), "price": 0, "duration": 2}, "amicale/doceo": {"name": _("Amicale/DOCEO member"), "price": 0, "duration": 2},
@@ -559,8 +553,6 @@ SITH_SUBSCRIPTIONS = {
"price": 0, "price": 0,
"duration": 1, "duration": 1,
}, },
"un-mois-essai": {"name": _("One month for free"), "price": 0, "duration": 0.166},
"deux-mois-essai": {"name": _("Two months for free"), "price": 0, "duration": 0.33},
"benevoles-euroks": {"name": _("Eurok's volunteer"), "price": 5, "duration": 0.1}, "benevoles-euroks": {"name": _("Eurok's volunteer"), "price": 5, "duration": 0.1},
"six-semaines-essai": { "six-semaines-essai": {
"name": _("Six weeks for free"), "name": _("Six weeks for free"),
@@ -691,14 +683,6 @@ SITH_PERMANENT_NOTIFICATIONS = {
"SAS_MODERATION": "sas.models.sas_notification_callback", "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 # Mailing related settings
SITH_MAILING_DOMAIN = "utbm.fr" SITH_MAILING_DOMAIN = "utbm.fr"

View File

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

View File

@@ -0,0 +1,56 @@
# 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,21 @@
# Generated by Django 5.2.3 on 2025-10-06 11:24
from django.db import migrations, models
import subscription.models
class Migration(migrations.Migration):
dependencies = [("subscription", "0015_alter_subscription_location_and_more")]
operations = [
migrations.AlterField(
model_name="subscription",
name="subscription_type",
field=models.CharField(
choices=subscription.models.get_subscription_types,
max_length=255,
verbose_name="subscription type",
),
)
]

View File

@@ -38,16 +38,19 @@ def validate_payment(value):
raise ValidationError(_("Bad payment method")) raise ValidationError(_("Bad payment method"))
def get_subscription_types():
return (
(k, f"{v['name']}, {v['price']}")
for k, v in sorted(settings.SITH_SUBSCRIPTIONS.items())
)
class Subscription(models.Model): class Subscription(models.Model):
member = models.ForeignKey( member = models.ForeignKey(
User, related_name="subscriptions", on_delete=models.CASCADE User, related_name="subscriptions", on_delete=models.CASCADE
) )
subscription_type = models.CharField( subscription_type = models.CharField(
_("subscription type"), _("subscription type"), max_length=255, choices=get_subscription_types
max_length=255,
choices=(
(k, v["name"]) for k, v in sorted(settings.SITH_SUBSCRIPTIONS.items())
),
) )
subscription_start = models.DateField(_("subscription start")) subscription_start = models.DateField(_("subscription start"))
subscription_end = models.DateField(_("subscription end")) subscription_end = models.DateField(_("subscription end"))

View File

@@ -1,14 +0,0 @@
{% 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

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

View File

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

View File

@@ -26,7 +26,9 @@ from datetime import date
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import IntegrityError from django.db import IntegrityError
from django.forms.models import modelform_factory from django.forms.models import modelform_factory
@@ -46,7 +48,7 @@ from core.auth.mixins import (
) )
from core.models import User from core.models import User
from core.views.forms import SelectDate from core.views.forms import SelectDate
from core.views.mixins import QuickNotifMixin, TabedViewMixin from core.views.mixins import TabedViewMixin
from core.views.widgets.ajax_select import AutoCompleteSelectUser from core.views.widgets.ajax_select import AutoCompleteSelectUser
from trombi.models import Trombi, TrombiClubMembership, TrombiComment, TrombiUser from trombi.models import Trombi, TrombiClubMembership, TrombiComment, TrombiUser
@@ -134,15 +136,15 @@ class TrombiCreateView(CanCreateMixin, CreateView):
return self.form_invalid(form) return self.form_invalid(form)
class TrombiEditView(CanEditPropMixin, TrombiTabsMixin, UpdateView): class TrombiEditView(
CanEditPropMixin, TrombiTabsMixin, SuccessMessageMixin, UpdateView
):
model = Trombi model = Trombi
form_class = TrombiForm form_class = TrombiForm
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
pk_url_kwarg = "trombi_id" pk_url_kwarg = "trombi_id"
current_tab = "admin_tools" current_tab = "admin_tools"
success_message = _("Trombi modified")
def get_success_url(self):
return super().get_success_url() + "?qn_success"
class AddUserForm(forms.Form): class AddUserForm(forms.Form):
@@ -155,7 +157,7 @@ class AddUserForm(forms.Form):
) )
class TrombiDetailView(CanEditMixin, QuickNotifMixin, TrombiTabsMixin, DetailView): class TrombiDetailView(CanEditMixin, TrombiTabsMixin, DetailView):
model = Trombi model = Trombi
template_name = "trombi/detail.jinja" template_name = "trombi/detail.jinja"
pk_url_kwarg = "trombi_id" pk_url_kwarg = "trombi_id"
@@ -167,9 +169,9 @@ class TrombiDetailView(CanEditMixin, QuickNotifMixin, TrombiTabsMixin, DetailVie
if form.is_valid(): if form.is_valid():
try: try:
TrombiUser(user=form.cleaned_data["user"], trombi=self.object).save() TrombiUser(user=form.cleaned_data["user"], trombi=self.object).save()
self.quick_notif_list.append("qn_success") messages.success(self.request, _("User added to the trombi"))
except IntegrityError: # We don't care about duplicate keys except IntegrityError: # We don't care about duplicate keys
self.quick_notif_list.append("qn_fail") messages.error(self.request, _("User couldn't be added to the trombi"))
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@@ -185,22 +187,20 @@ class TrombiExportView(CanEditMixin, TrombiTabsMixin, DetailView):
current_tab = "admin_tools" current_tab = "admin_tools"
class TrombiDeleteUserView(CanEditPropMixin, TrombiTabsMixin, DeleteView): class TrombiDeleteUserView(
CanEditPropMixin, TrombiTabsMixin, SuccessMessageMixin, DeleteView
):
model = TrombiUser model = TrombiUser
pk_url_kwarg = "user_id" pk_url_kwarg = "user_id"
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
current_tab = "admin_tools" current_tab = "admin_tools"
success_message = _("User removed from the trombi")
def get_success_url(self): def get_success_url(self):
return ( return reverse("trombi:detail", kwargs={"trombi_id": self.object.trombi.id})
reverse("trombi:detail", kwargs={"trombi_id": self.object.trombi.id})
+ "?qn_success"
)
class TrombiModerateCommentsView( class TrombiModerateCommentsView(CanEditPropMixin, TrombiTabsMixin, DetailView):
CanEditPropMixin, QuickNotifMixin, TrombiTabsMixin, DetailView
):
model = Trombi model = Trombi
template_name = "trombi/comment_moderation.jinja" template_name = "trombi/comment_moderation.jinja"
pk_url_kwarg = "trombi_id" pk_url_kwarg = "trombi_id"
@@ -235,16 +235,18 @@ class TrombiModerateCommentView(DetailView):
if request.POST["action"] == "accept": if request.POST["action"] == "accept":
self.object.is_moderated = True self.object.is_moderated = True
self.object.save() self.object.save()
messages.success(self.request, _("Comment accepted"))
return redirect( return redirect(
reverse( reverse(
"trombi:moderate_comments", "trombi:moderate_comments",
kwargs={"trombi_id": self.object.author.trombi.id}, kwargs={"trombi_id": self.object.author.trombi.id},
) )
+ "?qn_success"
) )
elif request.POST["action"] == "reject": elif request.POST["action"] == "reject":
messages.success(self.request, _("Comment rejected"))
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
elif request.POST["action"] == "delete" and "reason" in request.POST: elif request.POST["action"] == "delete" and "reason" in request.POST:
messages.success(self.request, _("Comment removed"))
self.object.author.user.email_user( self.object.author.user.email_user(
subject="[%s] %s" % (settings.SITH_NAME, _("Rejected comment")), subject="[%s] %s" % (settings.SITH_NAME, _("Rejected comment")),
message=_( message=_(
@@ -265,7 +267,6 @@ class TrombiModerateCommentView(DetailView):
"trombi:moderate_comments", "trombi:moderate_comments",
kwargs={"trombi_id": self.object.author.trombi.id}, kwargs={"trombi_id": self.object.author.trombi.id},
) )
+ "?qn_success"
) )
raise Http404 raise Http404
@@ -299,9 +300,7 @@ class UserTrombiForm(forms.Form):
) )
class UserTrombiToolsView( class UserTrombiToolsView(LoginRequiredMixin, TrombiTabsMixin, TemplateView):
LoginRequiredMixin, QuickNotifMixin, TrombiTabsMixin, TemplateView
):
"""Display a user's trombi tools.""" """Display a user's trombi tools."""
template_name = "trombi/user_tools.jinja" template_name = "trombi/user_tools.jinja"
@@ -318,7 +317,6 @@ class UserTrombiToolsView(
user=request.user, trombi=self.form.cleaned_data["trombi"] user=request.user, trombi=self.form.cleaned_data["trombi"]
) )
trombi_user.save() trombi_user.save()
self.quick_notif_list += ["qn_success"]
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@@ -335,21 +333,24 @@ class UserTrombiToolsView(
return kwargs return kwargs
class UserTrombiEditPicturesView(TrombiTabsMixin, UserIsInATrombiMixin, UpdateView): class UserTrombiEditPicturesView(
TrombiTabsMixin, UserIsInATrombiMixin, SuccessMessageMixin, UpdateView
):
model = TrombiUser model = TrombiUser
fields = ["profile_pict", "scrub_pict"] fields = ["profile_pict", "scrub_pict"]
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
current_tab = "pictures" current_tab = "pictures"
success_message = _("User modified")
def get_object(self): def get_object(self):
return self.request.user.trombi_user return self.request.user.trombi_user
def get_success_url(self): def get_success_url(self):
return reverse("trombi:user_tools") + "?qn_success" return reverse("trombi:user_tools")
class UserTrombiEditProfileView( class UserTrombiEditProfileView(
QuickNotifMixin, TrombiTabsMixin, UserIsInATrombiMixin, UpdateView TrombiTabsMixin, UserIsInATrombiMixin, SuccessMessageMixin, UpdateView
): ):
model = User model = User
form_class = modelform_factory( form_class = modelform_factory(
@@ -370,16 +371,20 @@ class UserTrombiEditProfileView(
) )
template_name = "trombi/edit_profile.jinja" template_name = "trombi/edit_profile.jinja"
current_tab = "profile" current_tab = "profile"
success_message = _("User modified")
def get_object(self): def get_object(self):
return self.request.user return self.request.user
def get_success_url(self): def get_success_url(self):
return reverse("trombi:user_tools") + "?qn_success" return reverse("trombi:user_tools")
class UserTrombiResetClubMembershipsView(UserIsInATrombiMixin, RedirectView): class UserTrombiResetClubMembershipsView(
UserIsInATrombiMixin, SuccessMessageMixin, RedirectView
):
permanent = False permanent = False
success_message = _("User modified")
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
user = self.request.user.trombi_user user = self.request.user.trombi_user
@@ -387,18 +392,18 @@ class UserTrombiResetClubMembershipsView(UserIsInATrombiMixin, RedirectView):
return redirect(self.get_success_url()) return redirect(self.get_success_url())
def get_success_url(self): def get_success_url(self):
return reverse("trombi:profile") + "?qn_success" return reverse("trombi:profile")
class UserTrombiDeleteMembershipView(TrombiTabsMixin, CanEditMixin, DeleteView): class UserTrombiDeleteMembershipView(
TrombiTabsMixin, CanEditMixin, SuccessMessageMixin, DeleteView
):
model = TrombiClubMembership model = TrombiClubMembership
pk_url_kwarg = "membership_id" pk_url_kwarg = "membership_id"
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("trombi:profile") success_url = reverse_lazy("trombi:profile")
current_tab = "profile" current_tab = "profile"
success_message = _("User removed from trombi")
def get_success_url(self):
return super().get_success_url() + "?qn_success"
# Used by admins when someone does not have every club in his list # Used by admins when someone does not have every club in his list
@@ -428,15 +433,18 @@ class UserTrombiAddMembershipView(TrombiTabsMixin, CreateView):
) )
class UserTrombiEditMembershipView(CanEditMixin, TrombiTabsMixin, UpdateView): class UserTrombiEditMembershipView(
CanEditMixin, TrombiTabsMixin, SuccessMessageMixin, UpdateView
):
model = TrombiClubMembership model = TrombiClubMembership
pk_url_kwarg = "membership_id" pk_url_kwarg = "membership_id"
fields = ["role", "start", "end"] fields = ["role", "start", "end"]
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
current_tab = "profile" current_tab = "profile"
success_message = _("User modified")
def get_success_url(self): def get_success_url(self):
return super().get_success_url() + "?qn_success" return super().get_success_url()
class UserTrombiProfileView(TrombiTabsMixin, DetailView): class UserTrombiProfileView(TrombiTabsMixin, DetailView):
@@ -461,12 +469,13 @@ class UserTrombiProfileView(TrombiTabsMixin, DetailView):
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
class TrombiCommentFormView(LoginRequiredMixin, View): class TrombiCommentFormView(LoginRequiredMixin, SuccessMessageMixin, View):
"""Create/edit a trombi comment.""" """Create/edit a trombi comment."""
model = TrombiComment model = TrombiComment
fields = ["content"] fields = ["content"]
template_name = "trombi/comment.jinja" template_name = "trombi/comment.jinja"
success_message = _("Comment added")
def get_form_class(self): def get_form_class(self):
self.trombi = self.request.user.trombi_user.trombi self.trombi = self.request.user.trombi_user.trombi
@@ -496,7 +505,7 @@ class TrombiCommentFormView(LoginRequiredMixin, View):
) )
def get_success_url(self): def get_success_url(self):
return reverse("trombi:user_tools") + "?qn_success" return reverse("trombi:user_tools")
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)

View File

@@ -11,7 +11,7 @@
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"esModuleInterop": true, "esModuleInterop": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"types": ["jquery", "alpinejs"], "types": ["alpinejs"],
"paths": { "paths": {
"#openapi": ["./staticfiles/generated/openapi/client/index.ts"], "#openapi": ["./staticfiles/generated/openapi/client/index.ts"],
"#openapi:*": ["./staticfiles/generated/openapi/client/*"], "#openapi:*": ["./staticfiles/generated/openapi/client/*"],

View File

@@ -4,7 +4,6 @@ import inject from "@rollup/plugin-inject";
import { glob } from "glob"; import { glob } from "glob";
import { type AliasOptions, type UserConfig, defineConfig } from "vite"; import { type AliasOptions, type UserConfig, defineConfig } from "vite";
import type { Rollup } from "vite"; import type { Rollup } from "vite";
import { viteStaticCopy } from "vite-plugin-static-copy";
import tsconfig from "./tsconfig.json"; import tsconfig from "./tsconfig.json";
const outDir = resolve(__dirname, "./staticfiles/generated/bundled"); const outDir = resolve(__dirname, "./staticfiles/generated/bundled");
@@ -87,17 +86,6 @@ export default defineConfig((config: UserConfig) => {
Alpine: "alpinejs", Alpine: "alpinejs",
htmx: "htmx.org", htmx: "htmx.org",
}), }),
viteStaticCopy({
targets: [
{
src: resolve(nodeModules, "jquery/dist/jquery.min.js"),
dest: vendored,
},
], ],
}),
],
optimizeDeps: {
include: ["jquery"],
},
} satisfies UserConfig; } satisfies UserConfig;
}); });