mirror of
https://github.com/ae-utbm/sith.git
synced 2026-08-03 20:22:42 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4a70e006e | ||
|
|
2d91526708 | ||
|
|
d54e32321e | ||
|
|
4bbe049dcc | ||
|
|
6114184daa | ||
|
|
3f3add0482 | ||
|
|
e2a417bdac | ||
|
|
e973b013c6 | ||
|
|
b86ad5e936 | ||
|
|
c1084f1ac0 | ||
|
|
2c23e00c94 | ||
|
|
b6f25bf895 | ||
|
|
28551f87d2 |
@@ -1,7 +1,7 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.16.0
|
||||
rev: v0.15.13
|
||||
hooks:
|
||||
- id: ruff-check # just check the code, and print the errors
|
||||
- id: ruff-check # actually fix the fixable errors, but print nothing
|
||||
@@ -12,7 +12,7 @@ repos:
|
||||
rev: v0.6.1
|
||||
hooks:
|
||||
- id: biome-check
|
||||
additional_dependencies: ["@biomejs/biome@2.5.1"]
|
||||
additional_dependencies: ["@biomejs/biome@2.4.6"]
|
||||
- repo: https://github.com/rtts/djhtml
|
||||
rev: 3.0.11
|
||||
hooks:
|
||||
|
||||
+7
-2
@@ -46,7 +46,7 @@ from django.http import HttpRequest
|
||||
from ninja_extra import ControllerBase
|
||||
from ninja_extra.permissions import BasePermission
|
||||
|
||||
from counter.utils import is_logged_in_counter
|
||||
from counter.models import Counter
|
||||
|
||||
|
||||
class IsInGroup(BasePermission):
|
||||
@@ -186,7 +186,12 @@ class IsLoggedInCounter(BasePermission):
|
||||
"""Check that a user is logged in a counter."""
|
||||
|
||||
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
|
||||
return is_logged_in_counter(request)
|
||||
if "/counter/" not in request.META.get("HTTP_REFERER", ""):
|
||||
return False
|
||||
token = request.session.get("counter_token")
|
||||
if not token:
|
||||
return False
|
||||
return Counter.objects.filter(token=token).exists()
|
||||
|
||||
|
||||
CanAccessLookup = IsLoggedInCounter | HasPerm("core.access_lookup")
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"preset": "recommended",
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"useNamingConvention": "error"
|
||||
},
|
||||
|
||||
-41
@@ -11,7 +11,6 @@ from club.models import Club, Membership
|
||||
from club.schemas import (
|
||||
ClubSchema,
|
||||
ClubSearchFilterSchema,
|
||||
MembershipFilterSchema,
|
||||
SimpleClubSchema,
|
||||
UserMembershipSchema,
|
||||
)
|
||||
@@ -63,43 +62,3 @@ class UserClubController(ControllerBase):
|
||||
.filter(user=user)
|
||||
.select_related("club", "user", "role")
|
||||
)
|
||||
|
||||
|
||||
@api_controller("/clubs/members/")
|
||||
class ClubMembershipController(ControllerBase):
|
||||
@route.get(
|
||||
"/new",
|
||||
response=list[UserMembershipSchema],
|
||||
auth=[ApiKeyAuth(), SessionAuth()],
|
||||
permissions=[HasPerm("club.view_club")],
|
||||
url_name="get_new_clubs_members_since_date",
|
||||
)
|
||||
def fetch_new_club_members(self, filters: Query[MembershipFilterSchema]):
|
||||
"""give all the members of all clubs that have joined since a given date"""
|
||||
memberships = (
|
||||
Membership.objects.ongoing()
|
||||
.filter(start_date__gte=filters.since_date, end_date__isnull=True)
|
||||
.select_related("user", "role", "club")
|
||||
)
|
||||
if filters.clubs_id:
|
||||
memberships = memberships.filter(club_id__in=filters.clubs_id)
|
||||
|
||||
return memberships.order_by("start_date")
|
||||
|
||||
@route.get(
|
||||
"/former",
|
||||
response=list[UserMembershipSchema],
|
||||
auth=[ApiKeyAuth(), SessionAuth()],
|
||||
permissions=[HasPerm("club.view_club")],
|
||||
url_name="get_former_clubs_members_since_date",
|
||||
)
|
||||
def fetch_former_club_members(self, filters: Query[MembershipFilterSchema]):
|
||||
"""give all the former members of all clubs that have left since a given date"""
|
||||
memberships = Membership.objects.filter(
|
||||
start_date__lt=filters.since_date,
|
||||
end_date__gte=filters.since_date,
|
||||
).select_related("user", "role", "club")
|
||||
if filters.clubs_id:
|
||||
memberships = memberships.filter(club_id__in=filters.clubs_id)
|
||||
|
||||
return memberships.order_by("start_date")
|
||||
|
||||
@@ -21,13 +21,10 @@
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
#
|
||||
#
|
||||
import itertools
|
||||
from operator import attrgetter
|
||||
|
||||
from django import forms
|
||||
from django.db.models import Exists, OuterRef, Q, QuerySet
|
||||
from django.db.models.functions import Lower
|
||||
from django.forms.models import ModelChoiceField, ModelChoiceIterator
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -49,37 +46,6 @@ from counter.models import Counter, Selling
|
||||
from counter.schemas import SaleFilterSchema
|
||||
|
||||
|
||||
class ClubRoleChoiceIterator(ModelChoiceIterator):
|
||||
"""Custom `ModelChoiceIterator` for `ClubRoleChoiceField`"""
|
||||
|
||||
def __iter__(self):
|
||||
if self.field.empty_label is not None:
|
||||
yield "", self.field.empty_label
|
||||
queryset = self.queryset.select_related("club").order_by("club", "order")
|
||||
groups = [
|
||||
(club, [self.choice(role) for role in roles])
|
||||
for club, roles in itertools.groupby(queryset, key=attrgetter("club"))
|
||||
]
|
||||
if len(groups) == 1:
|
||||
# there is only one club involved, no need to have optgroups
|
||||
yield from groups[0][1]
|
||||
else:
|
||||
# there are multiple clubs, optgroups are necessary to differentiate
|
||||
# roles having the same name
|
||||
yield from groups
|
||||
|
||||
|
||||
class ClubRoleChoiceField(ModelChoiceField):
|
||||
"""Custom `ModelChoiceField` for `[ClubRole][club.models.ClubRole]`.
|
||||
|
||||
If only one club is involved, behave like the base `ModelChoiceField`.
|
||||
If dealing with the roles of multiple clubs, group the roles
|
||||
into a different `optgroup` for each club.
|
||||
"""
|
||||
|
||||
iterator = ClubRoleChoiceIterator
|
||||
|
||||
|
||||
class ClubLinkForm(forms.ModelForm):
|
||||
error_css_class = "error"
|
||||
required_css_class = "required"
|
||||
@@ -426,30 +392,6 @@ class ClubRoleForm(forms.ModelForm):
|
||||
self.instance.order = cleaned_data["ORDER"] - 1
|
||||
return cleaned_data
|
||||
|
||||
def save(self, commit=True): # noqa: FBT002
|
||||
instance: ClubRole = super().save(commit=commit)
|
||||
if commit and "is_board" in self.changed_data:
|
||||
# if the role was moved from board to simple member,
|
||||
# remove all users with that role from the club board group.
|
||||
# If the role became a board role, add users with
|
||||
# that role to the club board group.
|
||||
group_id = instance.club.board_group_id
|
||||
if self.cleaned_data["is_board"]:
|
||||
User.groups.through.objects.bulk_create(
|
||||
[
|
||||
User.groups.through(user_id=u, group_id=group_id)
|
||||
for u in Membership.objects.ongoing()
|
||||
.filter(role=instance)
|
||||
.values_list("user_id", flat=True)
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
else:
|
||||
User.groups.through.objects.filter(
|
||||
user__memberships__role=instance, group_id=group_id
|
||||
).delete()
|
||||
return instance
|
||||
|
||||
|
||||
class ClubRoleCreateForm(forms.ModelForm):
|
||||
"""Form to create a club role.
|
||||
|
||||
@@ -25,7 +25,8 @@ class Migration(migrations.Migration):
|
||||
"url_base",
|
||||
models.URLField(
|
||||
help_text=(
|
||||
"The base url that links with this type must respect"
|
||||
"The base url that links with this type "
|
||||
"must respect (e.g. `https://www.instagram.com`)"
|
||||
),
|
||||
unique=True,
|
||||
verbose_name="url base",
|
||||
|
||||
+4
-1
@@ -793,7 +793,10 @@ class LinkType(models.Model):
|
||||
url_base = models.URLField(
|
||||
"url base",
|
||||
unique=True,
|
||||
help_text=_("The base url that links with this type must respect"),
|
||||
help_text=_(
|
||||
"The base url that links with this type must respect (e.g. `%(url)s`)"
|
||||
)
|
||||
% {"url": "https://www.instagram.com"},
|
||||
)
|
||||
icon = models.CharField(
|
||||
_("icon"),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from datetime import date
|
||||
from typing import Annotated
|
||||
|
||||
from django.db.models import Q
|
||||
@@ -80,9 +79,3 @@ class UserMembershipSchema(ModelSchema):
|
||||
|
||||
club: SimpleClubSchema
|
||||
role: ClubRoleSchema
|
||||
user: SimpleUserSchema
|
||||
|
||||
|
||||
class MembershipFilterSchema(FilterSchema):
|
||||
since_date: Annotated[date, FilterLookup("date__lte")]
|
||||
clubs_id: set[int] | None = None
|
||||
|
||||
@@ -45,10 +45,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 575px){
|
||||
#club-list{
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
{% from "reservation/macros.jinja" import room_detail %}
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h3>{% trans %}Club tools{% endtrans %}</h3>
|
||||
<h3>{% trans %}Club tools{% endtrans %} ({{ club.name }})</h3>
|
||||
<div>
|
||||
<h4>{% trans %}Communication:{% endtrans %}</h4>
|
||||
<ul>
|
||||
@@ -19,18 +24,43 @@
|
||||
<li><a href="{{ url("club:club_roles", club_id=object.id) }}"></a></li>
|
||||
{% endif %}
|
||||
{% if object.trombi %}
|
||||
<li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li>
|
||||
<li>
|
||||
<a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">
|
||||
{% trans %}Edit Trombi{% endtrans %}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li><a href="{{ url('trombi:create', club_id=object.id) }}">{% trans %}New Trombi{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('club:poster_list', club_id=object.id) }}">{% trans %}Posters{% endtrans %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<h4>{% trans %}Reservable rooms{% endtrans %}</h4>
|
||||
<a
|
||||
href="{{ url("reservation:room_create") }}?club={{ object.id }}"
|
||||
class="btn btn-blue"
|
||||
>
|
||||
{% trans %}Add a room{% endtrans %}
|
||||
</a>
|
||||
{%- if reservable_rooms|length > 0 -%}
|
||||
<ul class="card-group">
|
||||
{%- for room in reservable_rooms -%}
|
||||
{{ room_detail(
|
||||
room,
|
||||
can_edit=user.can_edit(room),
|
||||
can_delete=request.user.has_perm("reservation.delete_room")
|
||||
) }}
|
||||
{%- endfor -%}
|
||||
</ul>
|
||||
{%- else -%}
|
||||
<p>
|
||||
{% trans %}This club manages no reservable room{% endtrans %}
|
||||
</p>
|
||||
{%- endif -%}
|
||||
<h4>{% trans %}Counters:{% endtrans %}</h4>
|
||||
<ul>
|
||||
{% for c in object.counters.filter(type="OFFICE") %}
|
||||
<li>{{ c }}:
|
||||
<a href="{{ url('counter:details', counter_id=c.id) }}">View</a>
|
||||
<a href="{{ url('counter:admin', counter_id=c.id) }}">Edit</a>
|
||||
{% for counter in counters %}
|
||||
<li>{{ counter }}:
|
||||
<a href="{{ url('counter:details', counter_id=counter.id) }}">View</a>
|
||||
<a href="{{ url('counter:admin', counter_id=counter.id) }}">Edit</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import localdate
|
||||
from model_bakery import baker
|
||||
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import User
|
||||
|
||||
|
||||
class TestMembershipAPI(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = baker.make(User)
|
||||
perm = Permission.objects.get(codename="view_club")
|
||||
cls.user.user_permissions.add(perm)
|
||||
cls.clubs = baker.make(Club, _quantity=3, is_active=True)
|
||||
cls.roles = baker.make(ClubRole, _quantity=3, is_active=True)
|
||||
cls.expectedNumQueries = 5
|
||||
|
||||
# Clean existing data to avoid side effects
|
||||
Membership.objects.all().delete()
|
||||
|
||||
cls.memberships = [
|
||||
# on going
|
||||
Membership.objects.create(
|
||||
club=cls.clubs[0],
|
||||
user=subscriber_user.make(),
|
||||
role=cls.roles[0],
|
||||
start_date=localdate() - timedelta(weeks=1),
|
||||
),
|
||||
# on going
|
||||
Membership.objects.create(
|
||||
club=cls.clubs[1],
|
||||
user=subscriber_user.make(),
|
||||
role=cls.roles[1],
|
||||
start_date=localdate() - timedelta(days=1),
|
||||
),
|
||||
# former
|
||||
Membership.objects.create(
|
||||
club=cls.clubs[1],
|
||||
user=subscriber_user.make(),
|
||||
role=cls.roles[2],
|
||||
start_date=localdate() - timedelta(weeks=2),
|
||||
end_date=localdate() - timedelta(days=6),
|
||||
),
|
||||
# on going
|
||||
Membership.objects.create(
|
||||
club=cls.clubs[2],
|
||||
user=subscriber_user.make(),
|
||||
role=cls.roles[0],
|
||||
start_date=localdate() - timedelta(weeks=3),
|
||||
),
|
||||
# former
|
||||
Membership.objects.create(
|
||||
club=cls.clubs[1],
|
||||
user=subscriber_user.make(),
|
||||
role=cls.roles[2],
|
||||
start_date=localdate() - timedelta(days=4),
|
||||
end_date=localdate() - timedelta(days=3),
|
||||
),
|
||||
# on going
|
||||
Membership.objects.create(
|
||||
club=cls.clubs[2],
|
||||
user=subscriber_user.make(),
|
||||
role=cls.roles[0],
|
||||
start_date=localdate() - timedelta(days=1),
|
||||
),
|
||||
# former
|
||||
Membership.objects.create(
|
||||
club=cls.clubs[0],
|
||||
user=subscriber_user.make(),
|
||||
role=cls.roles[0],
|
||||
start_date=localdate() - timedelta(weeks=6),
|
||||
end_date=localdate() - timedelta(days=3),
|
||||
),
|
||||
# former
|
||||
Membership.objects.create(
|
||||
club=cls.clubs[2],
|
||||
user=subscriber_user.make(),
|
||||
role=cls.roles[0],
|
||||
start_date=localdate() - timedelta(weeks=8),
|
||||
end_date=localdate() - timedelta(days=6),
|
||||
),
|
||||
# former
|
||||
Membership.objects.create(
|
||||
club=cls.clubs[1],
|
||||
user=subscriber_user.make(),
|
||||
role=cls.roles[0],
|
||||
start_date=localdate() - timedelta(weeks=8),
|
||||
end_date=localdate() - timedelta(weeks=7, days=5),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class TestNewMembershipAPI(TestMembershipAPI):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
super().setUpTestData()
|
||||
cls.url = reverse("api:get_new_clubs_members_since_date")
|
||||
|
||||
def test_new_membership_one_club(self):
|
||||
self.client.force_login(self.user)
|
||||
since_date = localdate() - timedelta(weeks=1)
|
||||
arg = {"since_date": since_date, "clubs_id": self.clubs[0].id}
|
||||
with self.assertNumQueries(self.expectedNumQueries):
|
||||
response = self.client.get(self.url, query_params=arg)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
membership_ids = [e["id"] for e in data]
|
||||
expected_ids = [self.memberships[0].id]
|
||||
assert membership_ids == expected_ids
|
||||
|
||||
def test_new_membership_multiple_club(self):
|
||||
self.client.force_login(self.user)
|
||||
since_date = localdate() - timedelta(weeks=1)
|
||||
arg = {
|
||||
"since_date": since_date,
|
||||
"clubs_id": [self.clubs[0].id, self.clubs[1].id],
|
||||
}
|
||||
with self.assertNumQueries(self.expectedNumQueries):
|
||||
response = self.client.get(self.url, query_params=arg)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
membership_ids = [e["id"] for e in data]
|
||||
expected_ids = [self.memberships[0].id, self.memberships[1].id]
|
||||
assert membership_ids == expected_ids
|
||||
|
||||
def test_new_membership_all_clubs(self):
|
||||
self.client.force_login(self.user)
|
||||
since_date = localdate() - timedelta(weeks=1)
|
||||
arg = {"since_date": since_date}
|
||||
with self.assertNumQueries(self.expectedNumQueries):
|
||||
response = self.client.get(self.url, query_params=arg)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
membership_ids = [e["id"] for e in data]
|
||||
expected_ids = [
|
||||
self.memberships[0].id,
|
||||
self.memberships[1].id,
|
||||
self.memberships[5].id,
|
||||
]
|
||||
assert membership_ids == expected_ids
|
||||
|
||||
|
||||
class TestFormerMembershipAPI(TestMembershipAPI):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
super().setUpTestData()
|
||||
cls.url = reverse("api:get_former_clubs_members_since_date")
|
||||
|
||||
def test_former_membership_one_club(self):
|
||||
self.client.force_login(self.user)
|
||||
since_date = localdate() - timedelta(weeks=1)
|
||||
arg = {"since_date": since_date, "clubs_id": self.clubs[1].id}
|
||||
with self.assertNumQueries(self.expectedNumQueries):
|
||||
response = self.client.get(self.url, query_params=arg)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
membership_ids = [e["id"] for e in data]
|
||||
expected_ids = [self.memberships[2].id]
|
||||
assert membership_ids == expected_ids
|
||||
|
||||
def test_new_membership_multiple_club(self):
|
||||
self.client.force_login(self.user)
|
||||
since_date = localdate() - timedelta(weeks=1)
|
||||
arg = {
|
||||
"since_date": since_date,
|
||||
"clubs_id": [self.clubs[1].id, self.clubs[0].id],
|
||||
}
|
||||
with self.assertNumQueries(self.expectedNumQueries):
|
||||
response = self.client.get(self.url, query_params=arg)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
membership_ids = [e["id"] for e in data]
|
||||
expected_ids = [self.memberships[6].id, self.memberships[2].id]
|
||||
assert membership_ids == expected_ids
|
||||
|
||||
def test_new_membership_all_clubs(self):
|
||||
self.client.force_login(self.user)
|
||||
since_date = localdate() - timedelta(weeks=1)
|
||||
arg = {"since_date": since_date}
|
||||
with self.assertNumQueries(self.expectedNumQueries):
|
||||
response = self.client.get(self.url, query_params=arg)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
membership_ids = [e["id"] for e in data]
|
||||
expected_ids = [
|
||||
self.memberships[7].id,
|
||||
self.memberships[6].id,
|
||||
self.memberships[2].id,
|
||||
]
|
||||
assert membership_ids == expected_ids
|
||||
@@ -4,7 +4,6 @@ import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from model_bakery import baker, seq
|
||||
from model_bakery.recipe import Recipe
|
||||
from pytest_django.asserts import assertRedirects
|
||||
@@ -240,7 +239,7 @@ class TestClubRoleUpdate(TestCase):
|
||||
|
||||
def test_president_moves_itself_out_of_the_presidency(self):
|
||||
"""Test that if the user moves its own role out of the presidency,
|
||||
then it loses access to the update page."""
|
||||
then it's redirected to another page and loses access to the update page."""
|
||||
self.payload["roles-0-is_presidency"] = False
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.post(self.url, data=self.payload)
|
||||
@@ -252,29 +251,3 @@ class TestClubRoleUpdate(TestCase):
|
||||
|
||||
res = self.client.get(self.url)
|
||||
assert res.status_code == 403
|
||||
|
||||
def test_role_stops_being_board(self):
|
||||
"""Test that if a role stops being a board role,
|
||||
its users lose the club board group."""
|
||||
self.payload["roles-0-is_board"] = False
|
||||
self.payload["roles-0-is_presidency"] = False
|
||||
self.payload["roles-1-is_board"] = False
|
||||
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
|
||||
assert formset.is_valid()
|
||||
formset.save()
|
||||
assert not self.user.groups.contains(self.club.board_group)
|
||||
|
||||
def test_role_becomes_board(self):
|
||||
"""Test that if a role becomes a board role,
|
||||
its active users get the club board group"""
|
||||
members = [
|
||||
baker.make(Membership, club=self.club, role=self.roles[0], end_date=None),
|
||||
baker.make(Membership, club=self.club, role=self.roles[0], end_date=now()),
|
||||
]
|
||||
self.payload["roles-2-is_board"] = True
|
||||
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
|
||||
assert formset.is_valid()
|
||||
formset.save()
|
||||
# the second membership is finished, so its user shouldn't get the role
|
||||
assert members[0].user.groups.contains(self.club.board_group)
|
||||
assert not members[1].user.groups.contains(self.club.board_group)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import pytest
|
||||
from aemark import markdown
|
||||
from bs4 import BeautifulSoup
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
@@ -8,6 +7,7 @@ from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
||||
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.markdown import markdown
|
||||
from core.models import PageRev, User
|
||||
|
||||
|
||||
|
||||
@@ -309,6 +309,12 @@ class ClubToolsView(ClubTabsMixin, CanEditMixin, DetailView):
|
||||
template_name = "club/club_tools.jinja"
|
||||
current_tab = "tools"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(**kwargs) | {
|
||||
"reservable_rooms": list(self.object.reservable_rooms.all()),
|
||||
"counters": list(self.object.counters.filter(type="OFFICE")),
|
||||
}
|
||||
|
||||
|
||||
class ClubAddMembersFragment(
|
||||
FragmentMixin, PermissionRequiredMixin, SuccessMessageMixin, CreateView
|
||||
|
||||
+1
-1
@@ -1,13 +1,13 @@
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from aemark import markdown
|
||||
from ninja import FilterLookup, FilterSchema, ModelSchema
|
||||
from ninja_extra import service_resolver
|
||||
from ninja_extra.context import RouteContext
|
||||
|
||||
from club.schemas import ClubProfileSchema
|
||||
from com.models import News, NewsDate
|
||||
from core.markdown import markdown
|
||||
|
||||
|
||||
class NewsDateFilterSchema(FilterSchema):
|
||||
|
||||
@@ -84,7 +84,6 @@
|
||||
font-size: 85%;
|
||||
|
||||
#links_content {
|
||||
overflow: auto;
|
||||
box-shadow: $shadow-color 1px 1px 1px;
|
||||
min-height: 20em;
|
||||
padding: 1em;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
{% from "com/macros.jinja" import news_moderation_alert %}
|
||||
|
||||
{% block title %}AE UTBM{% endblock %}
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/components/calendar.scss') }}">
|
||||
|
||||
{# Atom feed discovery, not really css but also goes there #}
|
||||
<link rel="alternate" type="application/rss+xml" title="{% trans %}News feed{% endtrans %}" href="{{ url("com:news_feed") }}">
|
||||
@@ -213,6 +215,12 @@
|
||||
<i class="fa-solid fa-magnifying-glass fa-xl"></i>
|
||||
<a href="{{ url("matmat:search") }}">{% trans %}Matmatronch{% endtrans %}</a>
|
||||
</li>
|
||||
{% if user.has_perm("reservation.view_reservationslot") %}
|
||||
<li>
|
||||
<i class="fa-solid fa-thumbtack fa-xl"></i>
|
||||
<a href="{{ url("reservation:main") }}">{% trans %}Room reservation{% endtrans %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<i class="fa-solid fa-check-to-slot fa-xl"></i>
|
||||
<a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a>
|
||||
|
||||
@@ -2,7 +2,6 @@ from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from aemark import markdown
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.http import HttpResponse
|
||||
@@ -14,6 +13,7 @@ from pytest_django.asserts import assertNumQueries
|
||||
|
||||
from com.ics_calendar import IcsCalendar
|
||||
from com.models import News, NewsDate
|
||||
from core.markdown import markdown
|
||||
from core.models import User
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -170,7 +170,7 @@ class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
|
||||
form_class = NewsForm
|
||||
template_name = "com/news_edit.jinja"
|
||||
pk_url_kwarg = "news_id"
|
||||
permission_required = "com.change_news"
|
||||
permission_required = "com.edit_news"
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form) # Does the saving part
|
||||
|
||||
+27
-26
@@ -2,9 +2,12 @@
|
||||
<h1>Markdown-AE Documentation</h1>
|
||||
<p>Le Markdown le plus standard se trouve documenté ici:
|
||||
<a href="https://www.markdownguide.org/basic-syntax">https://www.markdownguide.org/basic-syntax</a>.<br />
|
||||
Si cette page n’est pas exhaustive vis à vis de la syntaxe du site AE,
|
||||
Si cette page n'est pas exhaustive vis à vis de la syntaxe du site AE,
|
||||
elle a au moins le mérite de bien documenter le Markdown original.</p>
|
||||
<p>Le réel parseur du site AE est une version tunée de <a href="https://github.com/kivikakk/comrak">comrak</a>.</p>
|
||||
<p>Le réel parseur du site AE est une version tunée de <a href="https://github.com/lepture/mistune">mistune</a>.<br />
|
||||
Les plus aventureux pourront aller lire ses <a href="https://github.com/lepture/mistune/blob/master/tests/fixtures">tests</a>
|
||||
afin d'en connaître la syntaxe le plus finement possible.<br />
|
||||
En pratique, cette page devrait déjà résumer une bonne partie.</p>
|
||||
<h2>Basique</h2>
|
||||
<ul>
|
||||
<li>Mettre le texte en <strong>gras</strong> : <code>**texte**</code></li>
|
||||
@@ -12,8 +15,8 @@ elle a au moins le mérite de bien documenter le Markdown original.</p>
|
||||
<li><u>Souligner</u> le texte : <code>__texte__</code></li>
|
||||
<li><del>Barrer du texte</del> : <code>~~texte~~</code></li>
|
||||
<li>On peut bien sûr tout <del><em><strong><u>combiner</u></strong></em></del> : <code>~~***__texte__***~~</code></li>
|
||||
<li>Mettre du texte<sup>en exposant</sup> : <code><sup>texte</sup></code></li>
|
||||
<li>Mettre du texte<sub>en indice</sub> : <code><sub>texte</sub></code></li>
|
||||
<li>Mettre du texte^en exposant^ : <code><sup>texte</sup></code></li>
|
||||
<li>Mettre du texte~en indice~ : <code><sub>texte</sub></code></li>
|
||||
</ul>
|
||||
<h2>Liens</h2>
|
||||
<ul>
|
||||
@@ -25,10 +28,10 @@ elle a au moins le mérite de bien documenter le Markdown original.</p>
|
||||
</ul>
|
||||
<p><a href="http://www.site.com">nom du lien</a></p>
|
||||
<ul>
|
||||
<li>Les liens peuvent être internes au site de l’AE, on peut dès lors éviter d’entrer
|
||||
l’adresse complète d’une page : <code>[nom du lien](page://nomDeLaPage)</code></li>
|
||||
<li>Les liens peuvent être internes au site de l'AE, on peut dès lors éviter d'entrer
|
||||
l'adresse complète d'une page : <code>[nom du lien](page://nomDeLaPage)</code></li>
|
||||
</ul>
|
||||
<p><a href="/page/nomDeLaPage">nom du lien</a></p>
|
||||
<p><a href="/page/nomDeLaPage/">nom du lien</a></p>
|
||||
<ul>
|
||||
<li>On peut également utiliser une image pour les liens :
|
||||
<code>[nom du lien](options)</code></li>
|
||||
@@ -109,7 +112,7 @@ etc...
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>L’alignement dans les cellules est géré comme suit, avec les ‘:’ sur la ligne en dessous du titre:</p>
|
||||
<p>L'alignement dans les cellules est géré comme suit, avec les ':' sur la ligne en dessous du titre:</p>
|
||||
<pre><code>| Titre | Titre2 | Titre3 |
|
||||
|:-------|:------:|-------:|
|
||||
| gauche | centre | droite |
|
||||
@@ -117,16 +120,16 @@ etc...
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left">Titre</th>
|
||||
<th align="center">Titre2</th>
|
||||
<th align="right">Titre3</th>
|
||||
<th style="text-align:left">Titre</th>
|
||||
<th style="text-align:center">Titre2</th>
|
||||
<th style="text-align:right">Titre3</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left">gauche</td>
|
||||
<td align="center">centre</td>
|
||||
<td align="right">droite</td>
|
||||
<td style="text-align:left">gauche</td>
|
||||
<td style="text-align:center">centre</td>
|
||||
<td style="text-align:right">droite</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -138,11 +141,11 @@ etc...
|
||||

|
||||

|
||||
</code></pre>
|
||||
<p><img src="/static/core/img/logo.png" style="width:50%" alt="image à 50%" title="Image à 50%" /><br />
|
||||
<p><img src="/static/core/img/logo.png" alt="image à 50%" title="Image à 50%" style="width:50%;" /><br />
|
||||
Image à 50% de la largeur de la page.</p>
|
||||
<p><img src="/static/core/img/logo.png" style="width:350px" alt="image de 350 pixels de large" title="Image de 350 pixels" /><br />
|
||||
<p><img src="/static/core/img/logo.png" alt="image de 350 pixels de large" title="Image de 350 pixels" style="width:350px;" /><br />
|
||||
Image de 350 pixels de large.</p>
|
||||
<p><img src="/static/core/img/logo.png" style="width:350px;height:100px" alt="image de 350x100 pixels" title="Image de 350x100 pixels" /><br />
|
||||
<p><img src="/static/core/img/logo.png" alt="image de 350x100 pixels" title="Image de 350x100 pixels" style="width:350px;height:100px;" /><br />
|
||||
Image de 350x100 pixels.</p>
|
||||
<p>(devrait pouvoir détecter si vidéo ou non)</p>
|
||||
<h2>Blocs de citations</h2>
|
||||
@@ -156,9 +159,9 @@ Image de 350x100 pixels.</p>
|
||||
un bloc de
|
||||
citation</p>
|
||||
</blockquote>
|
||||
<p>Il est possible d’intégrer de la syntaxe Markdown-AE dans un tel bloc.</p>
|
||||
<p>Il est possible d'intégrer de la syntaxe Markdown-AE dans un tel bloc.</p>
|
||||
<h2>Note de bas de page</h2>
|
||||
<p>On les crée comme ça<sup class="footnote-ref"><a href="#fn-key" id="fnref-key" data-footnote-ref>1</a></sup>:</p>
|
||||
<p>On les crée comme ça<sup class="footnote-ref" id="fnref-1"><a href="#fn-1">1</a></sup>:</p>
|
||||
<pre><code>Je fais une note[^clef].
|
||||
|
||||
[^clef]: je note ensuite où je veux le contenu de ma clef qui apparaîtra quand même en bas
|
||||
@@ -172,15 +175,13 @@ citation</p>
|
||||
</code></pre>
|
||||
<h2>Échapper des caractères</h2>
|
||||
<ul>
|
||||
<li>Il est possible d’ignorer un caractère spécial en l’échappant à l’aide d’un \</li>
|
||||
<li>L’échappement de blocs de codes complet se fera à l’aide de balises <nosyntax></nosyntax></li>
|
||||
<li>Il est possible d'ignorer un caractère spécial en l'échappant à l'aide d'un \</li>
|
||||
<li>L'échappement de blocs de codes complet se fera à l'aide de balises <nosyntax></nosyntax></li>
|
||||
</ul>
|
||||
<h2>Autres (hérité de l’ancien wiki)</h2>
|
||||
<h2>Autres (hérité de l'ancien wiki)</h2>
|
||||
<p>Une ligne peut être créée avec une ligne contenant 4 tirets (<code>----</code>).</p>
|
||||
<section class="footnotes" data-footnotes>
|
||||
<section class="footnotes">
|
||||
<ol>
|
||||
<li id="fn-key">
|
||||
<p>ceci est le contenu de ma clef <a href="#fnref-key" class="footnote-backref" data-footnote-backref data-footnote-backref-idx="1" aria-label="Back to reference 1">↩</a></p>
|
||||
</li>
|
||||
<li id="fn-1"><p>ceci est le contenu de ma clef<a href="#fnref-1" class="footnote">↩</a></p></li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
@@ -7,7 +7,10 @@ https://www.markdownguide.org/basic-syntax.
|
||||
Si cette page n'est pas exhaustive vis à vis de la syntaxe du site AE,
|
||||
elle a au moins le mérite de bien documenter le Markdown original.
|
||||
|
||||
Le réel parseur du site AE est une version tunée de [comrak](https://github.com/kivikakk/comrak).
|
||||
Le réel parseur du site AE est une version tunée de [mistune](https://github.com/lepture/mistune).
|
||||
Les plus aventureux pourront aller lire ses [tests](https://github.com/lepture/mistune/blob/master/tests/fixtures)
|
||||
afin d'en connaître la syntaxe le plus finement possible.
|
||||
En pratique, cette page devrait déjà résumer une bonne partie.
|
||||
|
||||
## Basique
|
||||
|
||||
|
||||
@@ -22,10 +22,11 @@
|
||||
#
|
||||
|
||||
|
||||
from aemark import markdown
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from core.markdown import markdown
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Output the fully rendered SYNTAX.md file"
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
#
|
||||
#
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import date, timedelta
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, NamedTuple
|
||||
@@ -33,8 +33,7 @@ from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from django.utils.lorem_ipsum import paragraphs
|
||||
from django.utils.timezone import localdate, now
|
||||
from django.utils.timezone import localdate
|
||||
from PIL import Image
|
||||
|
||||
from club.models import Club, ClubLink, ClubRole, LinkType, Membership
|
||||
@@ -44,14 +43,13 @@ from core.models import BanGroup, Group, Page, PageRev, SithFile, User
|
||||
from core.utils import resize_image
|
||||
from counter.models import (
|
||||
Counter,
|
||||
CounterSellers,
|
||||
Price,
|
||||
Product,
|
||||
ProductType,
|
||||
ReturnableProduct,
|
||||
StudentCard,
|
||||
)
|
||||
from election.models import Candidature, Election, ElectionList, Role, Vote
|
||||
from election.models import Candidature, Election, ElectionList, Role
|
||||
from forum.models import Forum
|
||||
from pedagogy.models import UE
|
||||
from sas.models import Album, PeoplePictureRelation, Picture
|
||||
@@ -366,15 +364,62 @@ class Command(BaseCommand):
|
||||
Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE")
|
||||
|
||||
# Add barman to counter
|
||||
CounterSellers.objects.bulk_create(
|
||||
Counter.sellers.through.objects.bulk_create(
|
||||
[
|
||||
CounterSellers(counter_id=1, user=skia, is_regular=True), # MDE
|
||||
CounterSellers(counter_id=2, user=krophil, is_regular=True), # Foyer
|
||||
Counter.sellers.through(counter_id=1, user=skia), # MDE
|
||||
Counter.sellers.through(counter_id=2, user=krophil), # Foyer
|
||||
]
|
||||
)
|
||||
|
||||
# Create an election
|
||||
self._create_elections(groups, clubs, skia, sli, krophil)
|
||||
el = Election.objects.create(
|
||||
title="Élection 2017",
|
||||
description="La roue tourne",
|
||||
start_candidature="1942-06-12 10:28:45+01",
|
||||
end_candidature="2042-06-12 10:28:45+01",
|
||||
start_date="1942-06-12 10:28:45+01",
|
||||
end_date="7942-06-12 10:28:45+01",
|
||||
)
|
||||
el.view_groups.add(groups.public)
|
||||
el.edit_groups.add(clubs.ae.board_group)
|
||||
el.candidature_groups.add(groups.subscribers)
|
||||
el.vote_groups.add(groups.subscribers)
|
||||
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
|
||||
listeT = ElectionList.objects.create(title="Troll", election=el)
|
||||
pres = Role.objects.create(
|
||||
election=el, title="Président AE", description="Roi de l'AE"
|
||||
)
|
||||
resp = Role.objects.create(
|
||||
election=el, title="Co Respo Info", max_choice=2, description="Ghetto++"
|
||||
)
|
||||
Candidature.objects.bulk_create(
|
||||
[
|
||||
Candidature(
|
||||
role=resp,
|
||||
user=skia,
|
||||
election_list=liste,
|
||||
program="Refesons le site AE",
|
||||
),
|
||||
Candidature(
|
||||
role=resp,
|
||||
user=sli,
|
||||
election_list=liste,
|
||||
program="Vasy je deviens mon propre adjoint",
|
||||
),
|
||||
Candidature(
|
||||
role=resp,
|
||||
user=krophil,
|
||||
election_list=listeT,
|
||||
program="Le Pôle Troll !",
|
||||
),
|
||||
Candidature(
|
||||
role=pres,
|
||||
user=sli,
|
||||
election_list=listeT,
|
||||
program="En fait j'aime pas l'info, je voulais faire GMC",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# Forum
|
||||
room = Forum.objects.create(
|
||||
@@ -847,7 +892,11 @@ class Command(BaseCommand):
|
||||
|
||||
subscribers = Group.objects.create(name="Cotisants")
|
||||
subscribers.permissions.add(
|
||||
*list(perms.filter(codename__in=["add_news", "add_uecomment"]))
|
||||
*list(
|
||||
perms.filter(
|
||||
codename__in=["add_news", "add_uecomment", "view_reservationslot"]
|
||||
)
|
||||
)
|
||||
)
|
||||
old_subscribers = Group.objects.create(name="Anciens cotisants")
|
||||
old_subscribers.permissions.add(
|
||||
@@ -965,132 +1014,3 @@ class Command(BaseCommand):
|
||||
BanGroup.objects.create(name="Banned from buying alcohol", description="")
|
||||
BanGroup.objects.create(name="Banned from counters", description="")
|
||||
BanGroup.objects.create(name="Banned to subscribe", description="")
|
||||
|
||||
def _create_elections(
|
||||
self,
|
||||
groups: PopulatedGroups,
|
||||
clubs: PopulatedClubs,
|
||||
skia: User,
|
||||
sli: User,
|
||||
krophil: User,
|
||||
):
|
||||
"""Populate elections.
|
||||
|
||||
4 elections are created :
|
||||
|
||||
- one that has not started yet,
|
||||
- one on the candidature period
|
||||
- one on the vote period
|
||||
- one that is finished
|
||||
|
||||
All elections have two lists, are linked to the AE and Troll clubs,
|
||||
and have one role for each board role of thos two clubs, plus
|
||||
an additional role linked to no club roles.
|
||||
|
||||
The ongoing vote and finished elections have candidates.
|
||||
|
||||
The finished election has 10 voters.
|
||||
"""
|
||||
|
||||
def election_factory(title: str, start_candidature: datetime):
|
||||
return Election(
|
||||
title=title,
|
||||
description="",
|
||||
start_candidature=start_candidature,
|
||||
end_candidature=start_candidature + timedelta(days=7),
|
||||
start_date=start_candidature + timedelta(days=7),
|
||||
end_date=start_candidature + timedelta(days=14),
|
||||
)
|
||||
|
||||
# create the elections
|
||||
elections = Election.objects.bulk_create(
|
||||
[
|
||||
election_factory("Election terminée", now() - timedelta(days=14)),
|
||||
election_factory("Votes en cours", now() - timedelta(days=7)),
|
||||
election_factory("Candidatures en cours", now()),
|
||||
election_factory("Election à venir", now() + timedelta(days=7)),
|
||||
]
|
||||
)
|
||||
finished, ongoing_vote, _ongoing_candidature, _not_started = elections
|
||||
|
||||
# set the groups (all elections have the same groups)
|
||||
groups.public.viewable_elections.set(elections)
|
||||
clubs.ae.board_group.editable_elections.set(elections)
|
||||
groups.subscribers.candidate_elections.set(elections)
|
||||
groups.subscribers.votable_elections.set(elections)
|
||||
|
||||
# link elections to clubs (AE and Troll for all elections)
|
||||
Election.clubs.through.objects.bulk_create(
|
||||
[
|
||||
*[Election.clubs.through(club=clubs.ae, election=e) for e in elections],
|
||||
*[
|
||||
Election.clubs.through(club=clubs.troll, election=e)
|
||||
for e in elections
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
# Create lists (all elections have two lists)
|
||||
ElectionList.objects.bulk_create(
|
||||
[
|
||||
*[ElectionList(title="Candidat libre", election=e) for e in elections],
|
||||
*[ElectionList(title="Troll", election=e) for e in elections],
|
||||
]
|
||||
)
|
||||
|
||||
# Create roles.
|
||||
# Elections have a role for each board club role of AE and Troll,
|
||||
# +an additional role linked to no club role
|
||||
club_roles = list(
|
||||
ClubRole.objects.filter(club__in=[clubs.ae, clubs.troll], is_board=True)
|
||||
.select_related("club")
|
||||
.order_by("club_id", "order")
|
||||
)
|
||||
Role.objects.bulk_create(
|
||||
[
|
||||
*[
|
||||
Role(election=e, title=f"{r.name} {r.club.name}", club_role=r)
|
||||
for r in club_roles
|
||||
for e in elections
|
||||
],
|
||||
*[Role(election=e, title="Rôle libre") for e in elections],
|
||||
]
|
||||
)
|
||||
|
||||
# create candidatures for ongoing_vote and finished elections
|
||||
candidatures = []
|
||||
lipsum = "\n\n".join(paragraphs(2))
|
||||
for election in ongoing_vote, finished:
|
||||
lists = list(election.election_lists.order_by("id"))
|
||||
roles = list(election.roles.order_by("order")[:3])
|
||||
candidatures.extend(
|
||||
[
|
||||
Candidature(
|
||||
role=roles[0], user=skia, election_list=lists[0], program=lipsum
|
||||
),
|
||||
Candidature(
|
||||
role=roles[1], user=sli, election_list=lists[0], program=lipsum
|
||||
),
|
||||
Candidature(
|
||||
role=roles[2], user=krophil, election_list=lists[1], program=""
|
||||
),
|
||||
Candidature(
|
||||
role=roles[2], user=sli, election_list=lists[0], program=lipsum
|
||||
),
|
||||
]
|
||||
)
|
||||
candidatures = Candidature.objects.bulk_create(candidatures)
|
||||
|
||||
skia, sli_vp, krophil, sli_treso = candidatures[4:] # candidates of finished
|
||||
votes = Vote.objects.bulk_create(
|
||||
[
|
||||
*[Vote(role=skia.role) for _ in range(6)],
|
||||
*[Vote(role=sli_vp.role) for _ in range(8)],
|
||||
*[Vote(role=krophil.role) for _ in range(9)],
|
||||
]
|
||||
)
|
||||
skia.votes.set(votes[:6])
|
||||
sli_vp.votes.set(votes[6:14])
|
||||
krophil.votes.set(votes[14:20])
|
||||
sli_treso.votes.set(votes[20:23])
|
||||
finished.voters.set(list(User.objects.all()[:10]))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import random
|
||||
from datetime import date, timedelta
|
||||
from datetime import timezone as tz
|
||||
from math import ceil
|
||||
from typing import Iterator
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
@@ -25,6 +26,7 @@ from counter.models import (
|
||||
)
|
||||
from forum.models import Forum, ForumMessage, ForumTopic
|
||||
from pedagogy.models import UE
|
||||
from reservation.models import ReservationSlot, Room
|
||||
from subscription.models import Subscription
|
||||
|
||||
|
||||
@@ -42,45 +44,20 @@ class Command(BaseCommand):
|
||||
self.stdout.write("Creating users...")
|
||||
users = self.create_users()
|
||||
self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of users
|
||||
# len(subscribers) is approximately 480
|
||||
subscribers = random.sample(users, k=int(0.8 * len(users)))
|
||||
self.stdout.write("Creating subscriptions...")
|
||||
self.create_subscriptions(subscribers)
|
||||
self.stdout.write("Creating club memberships...")
|
||||
users_qs = User.objects.filter(id__in=[s.id for s in subscribers])
|
||||
subscribers_now = list(
|
||||
users_qs.annotate(
|
||||
filter=Exists(
|
||||
Subscription.objects.filter(
|
||||
member_id=OuterRef("pk"), subscription_end__gte=now()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
old_subscribers = list(
|
||||
users_qs.annotate(
|
||||
filter=Exists(
|
||||
Subscription.objects.filter(
|
||||
member_id=OuterRef("pk"), subscription_end__lt=now()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
self.make_club(
|
||||
Club.objects.get(id=settings.SITH_MAIN_CLUB_ID),
|
||||
random.sample(subscribers_now, k=min(30, len(subscribers_now))),
|
||||
random.sample(old_subscribers, k=min(60, len(old_subscribers))),
|
||||
)
|
||||
self.make_club(
|
||||
Club.objects.get(name="Troll Penché"),
|
||||
random.sample(subscribers_now, k=min(20, len(subscribers_now))),
|
||||
random.sample(old_subscribers, k=min(80, len(old_subscribers))),
|
||||
)
|
||||
self.create_club_memberships(subscribers)
|
||||
self.stdout.write("Creating rooms and reservation...")
|
||||
self.create_resources_and_reservations(random.sample(subscribers, k=40))
|
||||
self.stdout.write("Creating uvs...")
|
||||
self.create_ues()
|
||||
self.stdout.write("Creating products...")
|
||||
self.create_products()
|
||||
self.stdout.write("Creating sales and refills...")
|
||||
sellers = random.sample(list(User.objects.all()), 100)
|
||||
sellers = list(User.objects.order_by("?")[:100])
|
||||
self.create_sales(sellers)
|
||||
self.stdout.write("Creating permanences...")
|
||||
self.create_permanences(sellers)
|
||||
@@ -215,6 +192,97 @@ class Command(BaseCommand):
|
||||
memberships = Membership.objects.bulk_create(memberships)
|
||||
Membership._add_club_groups(memberships)
|
||||
|
||||
def create_club_memberships(self, users: list[User]):
|
||||
users_qs = User.objects.filter(id__in=[s.id for s in users])
|
||||
subscribers_now = list(
|
||||
users_qs.annotate(
|
||||
filter=Exists(
|
||||
Subscription.objects.filter(
|
||||
member_id=OuterRef("pk"), subscription_end__gte=now()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
old_subscribers = list(
|
||||
users_qs.annotate(
|
||||
filter=Exists(
|
||||
Subscription.objects.filter(
|
||||
member_id=OuterRef("pk"), subscription_end__lt=now()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
self.make_club(
|
||||
Club.objects.get(id=settings.SITH_MAIN_CLUB_ID),
|
||||
random.sample(subscribers_now, k=min(30, len(subscribers_now))),
|
||||
random.sample(old_subscribers, k=min(60, len(old_subscribers))),
|
||||
)
|
||||
self.make_club(
|
||||
Club.objects.get(name="Troll Penché"),
|
||||
random.sample(subscribers_now, k=min(20, len(subscribers_now))),
|
||||
random.sample(old_subscribers, k=min(80, len(old_subscribers))),
|
||||
)
|
||||
|
||||
def create_resources_and_reservations(self, users: list[User]):
|
||||
"""Generate reservable rooms and reservations slots for those rooms.
|
||||
|
||||
Contrary to the other data generator,
|
||||
this one generates more data than what is expected on the real db.
|
||||
"""
|
||||
ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID)
|
||||
pdf = Club.objects.get(id=settings.SITH_PDF_CLUB_ID)
|
||||
troll = Club.objects.get(name="Troll Penché")
|
||||
rooms = [
|
||||
Room(
|
||||
name=name,
|
||||
club=club,
|
||||
location=location,
|
||||
description=self.faker.text(100),
|
||||
)
|
||||
for name, club, location in [
|
||||
("Champi", ae, "BELFORT"),
|
||||
("Muzik", ae, "BELFORT"),
|
||||
("Pôle Tech", ae, "BELFORT"),
|
||||
("Jolly", troll, "BELFORT"),
|
||||
("Cookut", pdf, "BELFORT"),
|
||||
("Lucky", pdf, "BELFORT"),
|
||||
("Potards", pdf, "SEVENANS"),
|
||||
("Bureau AE", ae, "SEVENANS"),
|
||||
]
|
||||
]
|
||||
rooms = Room.objects.bulk_create(rooms)
|
||||
reservations = []
|
||||
for room in rooms:
|
||||
# how much people use this room.
|
||||
# The higher the number, the more reservations exist,
|
||||
# the smaller the interval between two slot is,
|
||||
# and the more future reservations have already been made ahead of time
|
||||
affluence = random.randint(2, 6)
|
||||
slot_start = make_aware(self.faker.past_datetime("-5y").replace(minute=0))
|
||||
generate_until = make_aware(
|
||||
self.faker.future_datetime(timedelta(days=1) * affluence**2)
|
||||
)
|
||||
while slot_start < generate_until:
|
||||
if slot_start.hour < 8:
|
||||
# if a reservation would start in the middle of the night
|
||||
# make it start the next morning instead
|
||||
slot_start += timedelta(hours=10 - slot_start.hour)
|
||||
duration = timedelta(minutes=15) * (1 + int(random.gammavariate(3, 2)))
|
||||
reservations.append(
|
||||
ReservationSlot(
|
||||
room=room,
|
||||
author=random.choice(users),
|
||||
start_at=slot_start,
|
||||
end_at=slot_start + duration,
|
||||
created_at=slot_start - self.faker.time_delta("+7d"),
|
||||
)
|
||||
)
|
||||
slot_start += duration + (
|
||||
timedelta(minutes=15) * ceil(random.expovariate(affluence / 192))
|
||||
)
|
||||
reservations.sort(key=lambda slot: slot.created_at)
|
||||
ReservationSlot.objects.bulk_create(reservations)
|
||||
|
||||
def create_ues(self):
|
||||
root = User.objects.get(username="root")
|
||||
categories = ["CS", "TM", "OM", "QC", "EC"]
|
||||
@@ -415,7 +483,7 @@ class Command(BaseCommand):
|
||||
Permanency.objects.bulk_create(perms)
|
||||
|
||||
def create_forums(self):
|
||||
forumers = random.sample(list(User.objects.all()), 100)
|
||||
forumers = list(User.objects.order_by("?")[:100])
|
||||
most_actives = random.sample(forumers, 10)
|
||||
categories = list(Forum.objects.filter(is_category=True))
|
||||
new_forums = [
|
||||
@@ -433,7 +501,7 @@ class Command(BaseCommand):
|
||||
for _ in range(100)
|
||||
]
|
||||
ForumTopic.objects.bulk_create(new_topics)
|
||||
topics = list(ForumTopic.objects.all())
|
||||
topics = list(ForumTopic.objects.values_list("id", flat=True))
|
||||
|
||||
def get_author():
|
||||
if random.random() > 0.5:
|
||||
@@ -441,7 +509,7 @@ class Command(BaseCommand):
|
||||
return random.choice(forumers)
|
||||
|
||||
messages = []
|
||||
for t in topics:
|
||||
for topic_id in topics:
|
||||
nb_messages = max(1, int(random.normalvariate(mu=90, sigma=50)))
|
||||
dates = sorted(
|
||||
[
|
||||
@@ -453,7 +521,7 @@ class Command(BaseCommand):
|
||||
messages.extend(
|
||||
[
|
||||
ForumMessage(
|
||||
topic=t,
|
||||
topic_id=topic_id,
|
||||
author=get_author(),
|
||||
date=d,
|
||||
message="\n\n".join(
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import mistune
|
||||
from django.urls import reverse
|
||||
from mistune import HTMLRenderer, Markdown
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mistune import InlineParser, InlineState
|
||||
|
||||
# match __text__, without linebreak in the text, nor backslash prepending an underscore
|
||||
# Examples :
|
||||
# - "__text__" : OK
|
||||
# - "__te xt__" : OK
|
||||
# - "__te_xt__" : nope (underscore in the middle)
|
||||
# - "__te\_xt__" : Ok (the middle underscore is escaped)
|
||||
# - "__te\nxt__" : nope (there is a linebreak in the text)
|
||||
# - "\__text__" : nope (one of the underscores have a backslash prepended)
|
||||
# - "\\__text__" : Ok (the backslash is ignored, because there is another backslash before)
|
||||
UNDERLINED_RE = (
|
||||
r"(?<!\\)(?:\\{2})*" # ignore if there is an odd number of backslashes before
|
||||
r"_{2}" # two underscores
|
||||
r"(?P<underlined>([^\\_]|\\.)+)" # the actual text
|
||||
r"_{2}" # closing underscores
|
||||
)
|
||||
|
||||
SITH_LINK_RE = (
|
||||
r"\[(?P<page_name>[\w\s]+)\]" # [nom du lien]
|
||||
r"\(page:\/\/" # (page://
|
||||
r"(?P<page_slug>[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9])" # actual page name
|
||||
r"\)" # )
|
||||
)
|
||||
|
||||
CUSTOM_DIMENSIONS_IMAGE_RE = (
|
||||
r"\[(?P<img_name>[\w\s]+)\]" # [nom du lien]
|
||||
r"\(img:\/\/" # (img://
|
||||
r"(?P<img_slug>[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9])" # actual page name
|
||||
r"\)" # )
|
||||
)
|
||||
|
||||
|
||||
def parse_underline(_inline: InlineParser, m: re.Match, state: InlineState):
|
||||
state.append_token({"type": "underline", "raw": m.group("underlined")})
|
||||
return m.end()
|
||||
|
||||
|
||||
def underline(md_instance: Markdown):
|
||||
md_instance.inline.register(
|
||||
"underline",
|
||||
UNDERLINED_RE,
|
||||
parse_underline,
|
||||
before="emphasis",
|
||||
)
|
||||
md_instance.renderer.register("underline", lambda _, text: f"<u>{text}</u>")
|
||||
|
||||
|
||||
def parse_sith_link(_inline: InlineParser, m: re.Match, state: InlineState):
|
||||
page_name = m.group("page_name")
|
||||
page_slug = m.group("page_slug")
|
||||
state.append_token(
|
||||
{
|
||||
"type": "link",
|
||||
"children": [{"type": "text", "raw": page_name}],
|
||||
"attrs": {"url": reverse("core:page", kwargs={"page_name": page_slug})},
|
||||
}
|
||||
)
|
||||
return m.end()
|
||||
|
||||
|
||||
def sith_link(md_instance: Markdown):
|
||||
md_instance.inline.register(
|
||||
"sith_link",
|
||||
SITH_LINK_RE,
|
||||
parse_sith_link,
|
||||
before="emphasis",
|
||||
)
|
||||
# no custom renderer here.
|
||||
# we just add another parsing rule, but render it as if it was
|
||||
# a regular markdown link
|
||||
|
||||
|
||||
class SithRenderer(HTMLRenderer):
|
||||
def image(self, text: str, url: str, title=None) -> str:
|
||||
if "?" not in url:
|
||||
return super().image(text, url, title)
|
||||
|
||||
new_url, params = url.rsplit("?", maxsplit=1)
|
||||
m = re.match(r"^(?P<width>\d+(%|px)?)(x(?P<height>\d+(%|px)?))?$", params)
|
||||
if not m:
|
||||
return super().image(text, url, title)
|
||||
|
||||
width, height = m.group("width"), m.group("height")
|
||||
if not width.endswith(("%", "px")):
|
||||
width += "px"
|
||||
style = f"width:{width};"
|
||||
if height is not None:
|
||||
if not height.endswith(("%", "px")):
|
||||
height += "px"
|
||||
style += f"height:{height};"
|
||||
return super().image(text, new_url, title).replace("/>", f'style="{style}" />')
|
||||
|
||||
|
||||
markdown = mistune.create_markdown(
|
||||
renderer=SithRenderer(escape=True),
|
||||
plugins=[
|
||||
underline,
|
||||
sith_link,
|
||||
"strikethrough",
|
||||
"footnotes",
|
||||
"table",
|
||||
"spoiler",
|
||||
"subscript",
|
||||
"superscript",
|
||||
"url",
|
||||
],
|
||||
)
|
||||
@@ -6,10 +6,12 @@
|
||||
* for more efficient tree-shaking and gzip compression.
|
||||
*/
|
||||
|
||||
import { morph } from "@alpinejs/morph";
|
||||
import sort from "@alpinejs/sort";
|
||||
import Alpine from "alpinejs";
|
||||
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
|
||||
import htmx from "htmx.org";
|
||||
import "htmx-ext-alpine-morph";
|
||||
import { limitedChoices } from "#core:alpine/limited-choices";
|
||||
import { expireOldStorage } from "#core:core/localstorage";
|
||||
import { default as navbar } from "#core:core/navbar";
|
||||
@@ -27,7 +29,7 @@ declare module "alpinejs" {
|
||||
}
|
||||
}
|
||||
|
||||
Alpine.plugin([sort, limitedChoices, notifications]);
|
||||
Alpine.plugin([sort, limitedChoices, morph, notifications]);
|
||||
// biome-ignore lint/style/useNamingConvention: it's how it's named
|
||||
Object.assign(window, { Alpine });
|
||||
|
||||
|
||||
+1
@@ -55,6 +55,7 @@ declare interface NDEFRecordInit {
|
||||
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||
declare type NDEFMessageSource = string | BufferSource | NDEFMessageInit;
|
||||
|
||||
// biome-ignore lint/correctness/noUnusedVariables: this is the official definition
|
||||
interface Window {
|
||||
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||
NDEFReader: NDEFReader;
|
||||
|
||||
@@ -46,10 +46,6 @@ details.accordion>.accordion-content {
|
||||
border-bottom-right-radius: 3px;
|
||||
border-bottom-left-radius: 3px;
|
||||
overflow: hidden;
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
padding: .75em 1.5em;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin animation($selector) {
|
||||
|
||||
+63
-25
@@ -16,16 +16,76 @@
|
||||
--event-details-padding: 20px;
|
||||
--event-details-border: 1px solid #EEEEEE;
|
||||
--event-details-border-radius: 4px;
|
||||
--event-details-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%);
|
||||
--event-details-box-shadow: 0 6px 20px 4px rgb(0 0 0 / 16%);
|
||||
--event-details-max-width: 600px;
|
||||
--event-recurring-internal-color: #6f69cd;
|
||||
--event-recurring-unpublished-color: orange;
|
||||
}
|
||||
|
||||
ics-calendar {
|
||||
ics-calendar,
|
||||
room-scheduler {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
||||
a.fc-col-header-cell-cushion,
|
||||
a.fc-col-header-cell-cushion:hover {
|
||||
color: black;
|
||||
}
|
||||
|
||||
a.fc-daygrid-day-number,
|
||||
a.fc-daygrid-day-number:hover {
|
||||
color: rgb(34, 34, 34);
|
||||
}
|
||||
|
||||
td {
|
||||
overflow: visible; // Show events on multiple days
|
||||
}
|
||||
|
||||
td, th {
|
||||
text-align: unset;
|
||||
}
|
||||
|
||||
//Reset from style.scss
|
||||
table {
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
-moz-border-radius: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Reset from style.scss
|
||||
thead {
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
// Reset from style.scss
|
||||
tbody > tr {
|
||||
&:nth-child(even):not(.highlight) {
|
||||
background: white;
|
||||
}
|
||||
}
|
||||
|
||||
.fc .fc-toolbar.fc-footer-toolbar {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
button.text-copy,
|
||||
button.text-copy:focus,
|
||||
button.text-copy:hover {
|
||||
background-color: #67AE6E !important;
|
||||
transition: 500ms ease-in;
|
||||
}
|
||||
|
||||
button.text-copied,
|
||||
button.text-copied:focus,
|
||||
button.text-copied:hover {
|
||||
transition: 500ms ease-out;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
ics-calendar {
|
||||
#event-details {
|
||||
z-index: 10;
|
||||
max-width: 1151px;
|
||||
@@ -62,31 +122,10 @@ ics-calendar {
|
||||
align-items: start;
|
||||
flex-direction: row;
|
||||
background-color: var(--event-details-background-color);
|
||||
margin-top: 0px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
a.fc-col-header-cell-cushion,
|
||||
a.fc-col-header-cell-cushion:hover {
|
||||
color: black;
|
||||
}
|
||||
|
||||
a.fc-daygrid-day-number,
|
||||
a.fc-daygrid-day-number:hover {
|
||||
color: rgb(34, 34, 34);
|
||||
}
|
||||
|
||||
td {
|
||||
overflow: visible; // Show events on multiple days
|
||||
}
|
||||
|
||||
//Reset from style.scss
|
||||
table {
|
||||
box-shadow: none;
|
||||
border-radius: 0px;
|
||||
-moz-border-radius: 0px;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
// Reset from style.scss
|
||||
@@ -138,7 +177,6 @@ ics-calendar {
|
||||
.fc .fc-helpButton-button:hover {
|
||||
background-color: rgba(20, 20, 20, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip.calendar-copy-tooltip {
|
||||
opacity: 1;
|
||||
@@ -16,6 +16,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.card-group {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: $primary-neutral-light-color;
|
||||
border-radius: 5px;
|
||||
@@ -29,12 +36,7 @@
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
|
||||
&:disabled {
|
||||
background-color: darken($primary-neutral-light-color, 5%);
|
||||
opacity: 65%;
|
||||
}
|
||||
|
||||
&.clickable:not(:disabled):hover {
|
||||
&.clickable:hover {
|
||||
background-color: darken($primary-neutral-light-color, 5%);
|
||||
}
|
||||
|
||||
@@ -97,13 +99,23 @@
|
||||
}
|
||||
|
||||
@media screen and (max-width: 765px) {
|
||||
@include row-layout
|
||||
@include row-layout;
|
||||
}
|
||||
|
||||
// When combined with card, card-row display the card in a row layout,
|
||||
// whatever the size of the screen.
|
||||
&.card-row {
|
||||
@include row-layout
|
||||
@include row-layout;
|
||||
|
||||
&.card-row-m {
|
||||
//width: 50%;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
&.card-row-s {
|
||||
//width: 33%;
|
||||
max-width: 33%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
border-radius: 5px;
|
||||
color: black;
|
||||
|
||||
&:not(.link-like):not(:disabled):hover {
|
||||
&:hover {
|
||||
background: hsl(0, 0%, 83%);
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,7 @@ form {
|
||||
display: block;
|
||||
margin: calc(var(--nf-input-size) * 1.5) auto 10px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
|
||||
.fields-centered {
|
||||
padding: 10px 10px 0;
|
||||
|
||||
@@ -123,7 +123,7 @@ $background-color-hovered: #283747;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
a.button {
|
||||
>.button {
|
||||
box-sizing: border-box;
|
||||
height: 35px;
|
||||
background-color: transparent;
|
||||
@@ -139,7 +139,7 @@ $background-color-hovered: #283747;
|
||||
font-size: .9em;
|
||||
width: 120px;
|
||||
|
||||
&:not(.link-like):not(:disabled):hover {
|
||||
&:hover {
|
||||
background-color: $background-color-hovered;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,9 @@
|
||||
border-radius: 5px;
|
||||
padding: 5px 10px;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: opacity 500ms ease-out;
|
||||
|
||||
width: max-content;
|
||||
white-space: normal;
|
||||
|
||||
left: 0;
|
||||
|
||||
@@ -22,9 +22,14 @@
|
||||
</form>
|
||||
<ul class="bars">
|
||||
{% cache 100 "counters_activity" %}
|
||||
{# It would be cleaner to handle the timeout with django-celery-beat,
|
||||
but doing it here is simpler and less error-prone #}
|
||||
{% do Counter.objects.filter(type="BAR").handle_timeout() %}
|
||||
{# The sith has no periodic tasks manager
|
||||
and using cron jobs would be way too overkill here.
|
||||
Thus the barmen timeout is handled in the only place that
|
||||
is loaded on every page : the header bar.
|
||||
However, let's be clear : this has nothing to do here.
|
||||
It's' merely a contrived workaround that should
|
||||
replaced by a proper task manager as soon as possible. #}
|
||||
{% set _ = Counter.objects.filter(type="BAR").handle_timeout() %}
|
||||
{% endcache %}
|
||||
{% for bar in Counter.objects.annotate_has_barman(user).annotate_is_open().filter(type="BAR") %}
|
||||
<li>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<template x-for="(message, index) in $notifications.getAll()">
|
||||
<div class="alert" :class="`alert-${message.tag}`" x-transition>
|
||||
<span class="alert-main" x-text="message.text"></span>
|
||||
<span class="clickable" @click="$store.notifications = $store.notifications.filter((item, i) => i !== index)">
|
||||
<span class="clickable" @click="messages = messages.filter((item, i) => i !== index)">
|
||||
<i class="fa fa-close"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -25,13 +25,14 @@
|
||||
import datetime
|
||||
|
||||
import phonenumbers
|
||||
from aemark import markdown as md
|
||||
from django import template
|
||||
from django.forms import BoundField
|
||||
from django.template.defaultfilters import stringfilter
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import ngettext
|
||||
|
||||
from core.markdown import markdown as md
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
|
||||
+12
-9
@@ -18,7 +18,6 @@ from smtplib import SMTPException
|
||||
|
||||
import freezegun
|
||||
import pytest
|
||||
from aemark import markdown
|
||||
from bs4 import BeautifulSoup
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.contrib.auth.models import Permission
|
||||
@@ -35,6 +34,7 @@ from pytest_django.asserts import assertInHTML, assertRedirects
|
||||
from antispam.models import ToxicDomain
|
||||
from club.models import Club
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.markdown import markdown
|
||||
from core.models import AnonymousUser, Group, Page, User, validate_promo
|
||||
from core.utils import get_last_promo, get_semester_code, get_start_of_semester
|
||||
from core.views import AllowFragment
|
||||
@@ -200,28 +200,31 @@ class TestUserLogin:
|
||||
[
|
||||
(
|
||||
"[nom du lien](page://nomDeLaPage)",
|
||||
'<a href="/page/nomDeLaPage">nom du lien</a>',
|
||||
'<a href="/page/nomDeLaPage/">nom du lien</a>',
|
||||
),
|
||||
("__texte__", "<u>texte</u>"),
|
||||
("~~***__texte__***~~", "<del><em><strong><u>texte</u></strong></em></del>"),
|
||||
(
|
||||
'',
|
||||
'<img src="/img.png" style="width:50%" alt="tst_alt" title="tst_title" />',
|
||||
'<img src="/img.png" alt="tst_alt" title="tst_title" style="width:50%;" />',
|
||||
),
|
||||
(
|
||||
"[texte](page://tst-page)",
|
||||
'<a href="/page/tst-page/">texte</a>',
|
||||
),
|
||||
("[texte](page://tst-page)", '<a href="/page/tst-page">texte</a>'),
|
||||
(
|
||||
"",
|
||||
'<img src="/img.png" style="width:50px;height:450px" alt="" />',
|
||||
'<img src="/img.png" alt="" style="width:50px;height:450px;" />',
|
||||
),
|
||||
("", '<img src="/img.png" alt="" />'),
|
||||
(
|
||||
"",
|
||||
'<img src="/img.png" style="width:50%;height:120%" alt="" />',
|
||||
'<img src="/img.png" alt="" style="width:50%;height:120%;" />',
|
||||
),
|
||||
("", '<img src="/img.png" style="width:50px" alt="" />'),
|
||||
("", '<img src="/img.png" alt="" style="width:50px;" />'),
|
||||
(
|
||||
"",
|
||||
'<img src="/img.png" style="width:50px;height:120%" alt="" />',
|
||||
'<img src="/img.png" alt="" style="width:50px;height:120%;" />',
|
||||
),
|
||||
# when the image dimension has a wrong format, don't touch the url
|
||||
("", '<img src="/img.png?50pxxxxxxxx" alt="" />'),
|
||||
@@ -347,7 +350,7 @@ http://git.an
|
||||
<p><a href="http://git.an">http://git.an</a></p>
|
||||
<h1>Swag</h1>
|
||||
<p><guy>Bibou</guy></p>
|
||||
<script>alert('Guy');</script>
|
||||
<p><script>alert('Guy');</script></p>
|
||||
"""
|
||||
assertInHTML(expected, response.text)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ from datetime import timedelta
|
||||
|
||||
import freezegun
|
||||
import pytest
|
||||
from aemark import markdown
|
||||
from bs4 import BeautifulSoup
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
@@ -14,6 +13,7 @@ from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
||||
|
||||
from club.models import Club, Membership
|
||||
from core.baker_recipes import board_user, subscriber_user
|
||||
from core.markdown import markdown
|
||||
from core.models import AnonymousUser, Page, PageRev, User
|
||||
|
||||
|
||||
|
||||
@@ -200,11 +200,7 @@ class TestFilterInactive(TestCase):
|
||||
]
|
||||
sale_recipe.make(customer=cls.users[3].customer, date=time_active)
|
||||
baker.make(
|
||||
Refilling,
|
||||
customer=cls.users[4].customer,
|
||||
date=time_active,
|
||||
counter=counter,
|
||||
amount=1,
|
||||
Refilling, customer=cls.users[4].customer, date=time_active, counter=counter
|
||||
)
|
||||
sale_recipe.make(customer=cls.users[5].customer, date=time_inactive)
|
||||
|
||||
@@ -459,9 +455,7 @@ def test_user_preferences(client: Client):
|
||||
@pytest.mark.django_db
|
||||
def test_user_stats(client: Client):
|
||||
user = subscriber_user.make()
|
||||
baker.make(
|
||||
Refilling, customer=user.customer, amount=settings.SITH_ACCOUNT_MAX_MONEY
|
||||
)
|
||||
baker.make(Refilling, customer=user.customer, amount=99999)
|
||||
bars = [b[0] for b in settings.SITH_COUNTER_BARS]
|
||||
baker.make(
|
||||
Permanency,
|
||||
|
||||
+3
-4
@@ -40,9 +40,8 @@ from django.forms import (
|
||||
DateInput,
|
||||
DateTimeInput,
|
||||
TextInput,
|
||||
Widget,
|
||||
)
|
||||
from django.utils.timezone import now
|
||||
from django.utils.timezone import localtime, now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||
from PIL import Image
|
||||
@@ -101,8 +100,8 @@ class FutureDateTimeField(forms.DateTimeField):
|
||||
|
||||
default_validators = [validate_future_timestamp]
|
||||
|
||||
def widget_attrs(self, widget: Widget) -> dict[str, str]:
|
||||
return {"min": widget.format_value(now())}
|
||||
def widget_attrs(self, widget: forms.Widget) -> dict[str, str]:
|
||||
return {"min": widget.format_value(localtime())}
|
||||
|
||||
|
||||
# Forms
|
||||
|
||||
@@ -78,7 +78,7 @@ class FragmentMixin(TemplateResponseMixin, AllowFragment, ContextMixin):
|
||||
return render(
|
||||
request,
|
||||
"app/template.jinja",
|
||||
context={"fragment": fragment(request)
|
||||
context={"fragment": fragment(request)}
|
||||
}
|
||||
|
||||
# in urls.py
|
||||
|
||||
+4
-50
@@ -1,68 +1,22 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import checks
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
|
||||
class CurrencyField(models.DecimalField):
|
||||
"""Custom database field used for currency."""
|
||||
|
||||
def __init__(
|
||||
self, verbose_name=None, name=None, min_value=None, max_value=None, **kwargs
|
||||
):
|
||||
kwargs.update({"max_digits": 12, "decimal_places": 2})
|
||||
self.min_value = min_value
|
||||
self.max_value = max_value
|
||||
super().__init__(verbose_name, name, **kwargs)
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["max_digits"] = 12
|
||||
kwargs["decimal_places"] = 2
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def to_python(self, value):
|
||||
if value is None:
|
||||
return None
|
||||
return super().to_python(value).quantize(Decimal("0.01"))
|
||||
|
||||
@cached_property
|
||||
def validators(self):
|
||||
res = []
|
||||
if self.max_value:
|
||||
res.append(MaxValueValidator(self.max_value))
|
||||
if self.min_value:
|
||||
res.append(MinValueValidator(self.min_value))
|
||||
return [*super().validators, *res]
|
||||
|
||||
def check(self, **kwargs): # pragma: no cover
|
||||
# this is executed during runserver, but won't run in prod
|
||||
errors = super().check(**kwargs)
|
||||
for name, val in ("min_value", self.min_value), ("max_value", self.max_value):
|
||||
if not val:
|
||||
continue
|
||||
try:
|
||||
float(val)
|
||||
except ValueError:
|
||||
errors.append(
|
||||
checks.Error(
|
||||
f"CurrencyField.{name} must be a valid float",
|
||||
obj=self,
|
||||
id="sith.E001",
|
||||
)
|
||||
)
|
||||
return errors
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
return super().formfield(
|
||||
**{"min_value": self.min_value, "max_value": self.max_value, **kwargs}
|
||||
)
|
||||
|
||||
def deconstruct(self):
|
||||
name, path, args, kwargs = super().deconstruct()
|
||||
if self.min_value is not None:
|
||||
kwargs["min_value"] = self.min_value
|
||||
if self.max_value is not None:
|
||||
kwargs["max_value"] = self.max_value
|
||||
return name, path, args, kwargs
|
||||
|
||||
|
||||
if settings.TESTING:
|
||||
from model_bakery import baker
|
||||
|
||||
+38
-90
@@ -3,16 +3,12 @@ import math
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import ClassVar
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.forms import BaseModelFormSet
|
||||
from django.http import HttpRequest
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_celery_beat.models import ClockedSchedule
|
||||
@@ -21,7 +17,6 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||
from club.models import Club
|
||||
from club.widgets.ajax_select import AutoCompleteSelectClub
|
||||
from core.models import User, UserQuerySet
|
||||
from core.views import LoginForm
|
||||
from core.views.forms import (
|
||||
FutureDateTimeField,
|
||||
NFCTextInput,
|
||||
@@ -42,7 +37,6 @@ from counter.models import (
|
||||
Customer,
|
||||
Eticket,
|
||||
InvoiceCall,
|
||||
Permanency,
|
||||
Price,
|
||||
Product,
|
||||
ProductFormula,
|
||||
@@ -97,18 +91,30 @@ class StudentCardForm(forms.ModelForm):
|
||||
|
||||
|
||||
class GetUserForm(forms.Form):
|
||||
"""Find a user to show its click page."""
|
||||
"""The Form class aims at providing a valid user_id field in its cleaned data, in order to pass it to some view,
|
||||
reverse function, or any other use.
|
||||
|
||||
The Form implements a nice JS widget allowing the user to type a customer account id, or search the database with
|
||||
some nickname, first name, or last name (TODO)
|
||||
"""
|
||||
|
||||
code = forms.CharField(
|
||||
label="Code",
|
||||
max_length=StudentCard.UID_SIZE,
|
||||
required=False,
|
||||
widget=NFCTextInput(attrs={"autofocus": True}),
|
||||
widget=NFCTextInput,
|
||||
)
|
||||
id = forms.CharField(
|
||||
label=_("Select user"), widget=AutoCompleteSelectUser, required=False
|
||||
label=_("Select user"),
|
||||
help_text=None,
|
||||
widget=AutoCompleteSelectUser,
|
||||
required=False,
|
||||
)
|
||||
|
||||
def as_p(self):
|
||||
self.fields["code"].widget.attrs["autofocus"] = True
|
||||
return super().as_p()
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
customer = None
|
||||
@@ -130,41 +136,11 @@ class GetUserForm(forms.Form):
|
||||
|
||||
if customer is None or not customer.can_buy:
|
||||
raise forms.ValidationError(_("User not found"))
|
||||
cleaned_data["user_id"] = customer.user_id
|
||||
cleaned_data["user_id"] = customer.user.id
|
||||
cleaned_data["user"] = customer.user
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class CounterLoginForm(LoginForm):
|
||||
"""LoginForm to log a barman in a counter.
|
||||
|
||||
To be able to log in a counter, a user must :
|
||||
|
||||
- be part of the sellers of the given counter
|
||||
- not being already logged in any counter
|
||||
"""
|
||||
|
||||
def __init__(self, *args, request: HttpRequest, counter: Counter, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.counter = counter
|
||||
self.request = request
|
||||
|
||||
def confirm_login_allowed(self, user: User):
|
||||
super().confirm_login_allowed(user)
|
||||
if not self.counter.sellers.contains(user):
|
||||
raise ValidationError(
|
||||
message=_("You are not a barman of this counter."), code="not_barman"
|
||||
)
|
||||
if Permanency.objects.filter(end=None, user=user).exists():
|
||||
if user in self.request.barmen:
|
||||
message = _("You are already logged in this counter.")
|
||||
elif user in self.counter.barmen_list:
|
||||
message = _("You are already logged in another counter.")
|
||||
else:
|
||||
message = _("You are already logged on another device")
|
||||
raise ValidationError(message=message, code="already_logged_in")
|
||||
|
||||
|
||||
class RefillForm(forms.ModelForm):
|
||||
allowed_refilling_methods = [
|
||||
Refilling.PaymentMethod.CASH,
|
||||
@@ -173,19 +149,18 @@ class RefillForm(forms.ModelForm):
|
||||
|
||||
error_css_class = "error"
|
||||
required_css_class = "required"
|
||||
amount = forms.FloatField(
|
||||
min_value=0, widget=forms.NumberInput(attrs={"class": "focus"})
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Refilling
|
||||
fields = ["amount", "payment_method"]
|
||||
widgets = {"payment_method": forms.RadioSelect}
|
||||
|
||||
def __init__(
|
||||
self, *args, counter: Counter, operator: User, customer: Customer, **kwargs
|
||||
):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
max_value = settings.SITH_ACCOUNT_MAX_MONEY - customer.amount
|
||||
# server-side max_value validation is done by Refilling.clean
|
||||
self.fields["amount"].widget.attrs["max"] = max_value
|
||||
|
||||
self.fields["payment_method"].choices = (
|
||||
method
|
||||
for method in self.fields["payment_method"].choices
|
||||
@@ -193,9 +168,6 @@ class RefillForm(forms.ModelForm):
|
||||
)
|
||||
if self.fields["payment_method"].initial not in self.allowed_refilling_methods:
|
||||
self.fields["payment_method"].initial = self.allowed_refilling_methods[0]
|
||||
self.instance.counter = counter
|
||||
self.instance.operator = operator
|
||||
self.instance.customer = customer
|
||||
|
||||
|
||||
class CounterEditForm(forms.ModelForm):
|
||||
@@ -437,7 +409,6 @@ class ProductForm(forms.ModelForm):
|
||||
"club",
|
||||
"limit_age",
|
||||
"tray",
|
||||
"clic_limit",
|
||||
"archived",
|
||||
]
|
||||
help_texts = {
|
||||
@@ -569,7 +540,16 @@ class BasketItemForm(forms.Form):
|
||||
quantity = forms.IntegerField(min_value=1, required=True)
|
||||
price_id = forms.IntegerField(min_value=0, required=True)
|
||||
|
||||
def __init__(self, allowed_prices: dict[int, Price], *args, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
customer: Customer,
|
||||
counter: Counter,
|
||||
allowed_prices: dict[int, Price],
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
self.customer = customer # Used by formset
|
||||
self.counter = counter # Used by formset
|
||||
self.allowed_prices = allowed_prices
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -604,15 +584,6 @@ class BasketItemForm(forms.Form):
|
||||
|
||||
|
||||
class BaseBasketForm(forms.BaseFormSet):
|
||||
# Minimum amount of money there must be on the account after the transaction
|
||||
# If None, the min balance check is skipped
|
||||
min_result_balance: ClassVar[int | None] = 0
|
||||
|
||||
def __init__(self, *args, customer: Customer, counter: Counter, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.customer = customer
|
||||
self.counter = counter
|
||||
|
||||
def clean(self):
|
||||
self.forms = [form for form in self.forms if form.cleaned_data != {}]
|
||||
|
||||
@@ -621,8 +592,8 @@ class BaseBasketForm(forms.BaseFormSet):
|
||||
|
||||
self._check_forms_have_errors()
|
||||
self._check_product_are_unique()
|
||||
self._check_recorded_products()
|
||||
self._check_account_balance()
|
||||
self._check_recorded_products(self[0].customer)
|
||||
self._check_enough_money(self[0].counter, self[0].customer)
|
||||
|
||||
def _check_forms_have_errors(self):
|
||||
if any(len(form.errors) > 0 for form in self):
|
||||
@@ -633,35 +604,12 @@ class BaseBasketForm(forms.BaseFormSet):
|
||||
if len(price_ids) != len(self.forms):
|
||||
raise forms.ValidationError(_("Duplicated product entries."))
|
||||
|
||||
@cached_property
|
||||
def total_price(self):
|
||||
refill = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
||||
total_other = sum(
|
||||
form.cleaned_data["total_price"]
|
||||
for form in self.forms
|
||||
if form.price.product.product_type_id != refill
|
||||
)
|
||||
total_refill = sum(
|
||||
form.cleaned_data["total_price"]
|
||||
for form in self.forms
|
||||
if form.price.product.product_type_id == refill
|
||||
)
|
||||
return total_other - total_refill
|
||||
|
||||
def _check_account_balance(self):
|
||||
result_balance = self.customer.amount - self.total_price
|
||||
if (
|
||||
self.min_result_balance is not None
|
||||
and self.min_result_balance > result_balance
|
||||
):
|
||||
def _check_enough_money(self, counter: Counter, customer: Customer):
|
||||
self.total_price = sum([data["total_price"] for data in self.cleaned_data])
|
||||
if self.total_price > customer.amount:
|
||||
raise forms.ValidationError(_("Not enough money"))
|
||||
if result_balance > settings.SITH_ACCOUNT_MAX_MONEY:
|
||||
raise ValidationError(
|
||||
_("There cannot be more than %(money)d€ on an AE account")
|
||||
% {"money": settings.SITH_ACCOUNT_MAX_MONEY}
|
||||
)
|
||||
|
||||
def _check_recorded_products(self):
|
||||
def _check_recorded_products(self, customer: Customer):
|
||||
"""Check for, among other things, ecocups and pitchers"""
|
||||
items = defaultdict(int)
|
||||
for form in self.forms:
|
||||
@@ -670,7 +618,7 @@ class BaseBasketForm(forms.BaseFormSet):
|
||||
returnables = list(
|
||||
ReturnableProduct.objects.filter(
|
||||
Q(product_id__in=ids) | Q(returned_product_id__in=ids)
|
||||
).annotate_balance_for(self.customer)
|
||||
).annotate_balance_for(customer)
|
||||
)
|
||||
limit_reached = []
|
||||
for returnable in returnables:
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
|
||||
from core.models import User
|
||||
from counter.models import Permanency
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.contrib.sessions.backends.base import SessionBase
|
||||
|
||||
|
||||
SESSION_PERMANENCES_KEY = "permanence_ids"
|
||||
|
||||
|
||||
def get_cached_barmen(request: HttpRequest) -> set[User]:
|
||||
if not hasattr(request, "_cached_barmen"):
|
||||
session: SessionBase = request.session
|
||||
|
||||
if session_ids := session.get(SESSION_PERMANENCES_KEY, None):
|
||||
# Get ongoing permanences which id is in session.
|
||||
# Note : we store permanence ids rather than user id to be sure
|
||||
# not to wrongfully mark someone as logged here,
|
||||
# even if it logged out then logged in elsewhere.
|
||||
permanences = (
|
||||
Permanency.objects.filter(end=None, id__in=session_ids)
|
||||
.order_by("id")
|
||||
.select_related("user")
|
||||
)
|
||||
|
||||
# if the list of permanences occurring on this device has changed
|
||||
# since the last page load, change the ids stored in session
|
||||
real_ids = [p.id for p in permanences]
|
||||
if real_ids != session_ids:
|
||||
session[SESSION_PERMANENCES_KEY] = real_ids
|
||||
|
||||
request._cached_barmen = {p.user for p in permanences}
|
||||
else:
|
||||
request._cached_barmen = set()
|
||||
|
||||
return request._cached_barmen
|
||||
|
||||
|
||||
class BarmenMiddleware:
|
||||
"""Inject barmen logged in the current session.
|
||||
|
||||
In a similar fashion as `request.user`, `request.barmen` contains
|
||||
users that are barmen in the current session, and ONLY them ;
|
||||
if a user is logged as a barman on another session,
|
||||
it will not be in `request.barmen`.
|
||||
|
||||
Notes:
|
||||
In case of ended permanence, users will be automatically
|
||||
removed from `request.barmen`.
|
||||
However, in case of newly started permanence, this middleware
|
||||
cannot add new barmen in the session data, so that operation
|
||||
must be explicitly done in the barman login view.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request: HttpRequest):
|
||||
request.barmen = SimpleLazyObject(lambda: get_cached_barmen(request))
|
||||
|
||||
return self.get_response(request)
|
||||
@@ -1,25 +0,0 @@
|
||||
# Generated by Django 5.2.13 on 2026-05-13 11:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("counter", "0039_price")]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(model_name="product", name="buying_groups"),
|
||||
migrations.AddField(
|
||||
model_name="product",
|
||||
name="clic_limit",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
help_text=(
|
||||
"If a limit is set, the product won't be purchasable "
|
||||
"anymore on the eboutic once the latter is reached."
|
||||
),
|
||||
null=True,
|
||||
verbose_name="clic limit",
|
||||
),
|
||||
),
|
||||
migrations.RemoveField(model_name="counter", name="token"),
|
||||
]
|
||||
@@ -1,26 +0,0 @@
|
||||
# Generated by Django 5.2.14 on 2026-06-02 10:45
|
||||
|
||||
import django_countries.fields
|
||||
import phonenumber_field.modelfields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("counter", "0040_product_clic_limit")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="billinginfo",
|
||||
name="country",
|
||||
field=django_countries.fields.CountryField(
|
||||
max_length=2, verbose_name="Country"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="billinginfo",
|
||||
name="phone_number",
|
||||
field=phonenumber_field.modelfields.PhoneNumberField(
|
||||
max_length=128, region=None, verbose_name="Phone number"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,30 +0,0 @@
|
||||
# Generated by Django 5.2.15 on 2026-06-07 12:08
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
import counter.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("counter", "0041_alter_billinginfo_country_and_more")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="customer",
|
||||
name="amount",
|
||||
field=counter.fields.CurrencyField(
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
max_digits=12,
|
||||
max_value=250,
|
||||
verbose_name="amount",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="refilling",
|
||||
name="amount",
|
||||
field=counter.fields.CurrencyField(
|
||||
decimal_places=2, max_digits=12, min_value=0.01, verbose_name="amount"
|
||||
),
|
||||
),
|
||||
]
|
||||
+33
-71
@@ -22,19 +22,18 @@ import string
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import timezone as tz
|
||||
from decimal import Decimal
|
||||
from typing import Literal, Self
|
||||
from typing import TYPE_CHECKING, Literal, Self
|
||||
|
||||
from dict2xml import dict2xml
|
||||
from django.conf import settings
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models
|
||||
from django.db.models import Exists, F, Max, OuterRef, Q, QuerySet, Subquery, Sum, Value
|
||||
from django.db.models import Exists, F, OuterRef, Q, QuerySet, Subquery, Sum, Value
|
||||
from django.db.models.functions import Coalesce, Concat, Length
|
||||
from django.forms import ValidationError
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
from django_countries.fields import CountryField
|
||||
@@ -48,6 +47,9 @@ from core.utils import get_start_of_semester
|
||||
from counter.fields import CurrencyField
|
||||
from subscription.models import Subscription
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
|
||||
def get_eboutic() -> Counter:
|
||||
return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
|
||||
@@ -99,9 +101,7 @@ class Customer(models.Model):
|
||||
|
||||
user = models.OneToOneField(User, primary_key=True, on_delete=models.CASCADE)
|
||||
account_id = models.CharField(_("account id"), max_length=10, unique=True)
|
||||
amount: CurrencyField = CurrencyField(
|
||||
_("amount"), max_value=settings.SITH_ACCOUNT_MAX_MONEY, default=0
|
||||
)
|
||||
amount = CurrencyField(_("amount"), default=0)
|
||||
|
||||
objects = CustomerQuerySet.as_manager()
|
||||
|
||||
@@ -158,15 +158,13 @@ class Customer(models.Model):
|
||||
unique_fields=["customer", "returnable"],
|
||||
)
|
||||
|
||||
@cached_property
|
||||
@property
|
||||
def can_buy(self) -> bool:
|
||||
"""Check if whether this customer has the right to purchase any item."""
|
||||
subscription_end = self.user.subscriptions.aggregate(
|
||||
res=Max("subscription_end")
|
||||
).get("res")
|
||||
if subscription_end is None:
|
||||
subscription = self.user.subscriptions.order_by("subscription_end").last()
|
||||
if subscription is None:
|
||||
return False
|
||||
return (date.today() - subscription_end) < timedelta(days=90)
|
||||
return (date.today() - subscription.subscription_end) < timedelta(days=90)
|
||||
|
||||
@classmethod
|
||||
def get_or_create(cls, user: User) -> tuple[Customer, bool]:
|
||||
@@ -232,8 +230,15 @@ class BillingInfo(models.Model):
|
||||
address_2 = models.CharField(_("Address 2"), max_length=50, blank=True, null=True)
|
||||
zip_code = models.CharField(_("Zip code"), max_length=16) # code postal
|
||||
city = models.CharField(_("City"), max_length=50)
|
||||
country = CountryField(_("Country"))
|
||||
phone_number = PhoneNumberField(_("Phone number"))
|
||||
country = CountryField(blank_label=_("Country"))
|
||||
|
||||
# This table was created during the A22 semester.
|
||||
# However, later on, CA asked for the phone number to be added to the billing info.
|
||||
# As the table was already created, this new field had to be nullable,
|
||||
# even tough it is required by the bank and shouldn't be null.
|
||||
# If one day there is no null phone number remaining,
|
||||
# please make the field non-nullable.
|
||||
phone_number = PhoneNumberField(_("Phone number"), null=True, blank=False)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.first_name} {self.last_name}"
|
||||
@@ -348,40 +353,6 @@ class ProductType(OrderedModel):
|
||||
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
||||
|
||||
|
||||
class ProductQuerySet(models.QuerySet):
|
||||
def under_clic_limit(self) -> Self:
|
||||
"""Filter product which clic limit isn't reached yet.
|
||||
|
||||
The clic limit is reached when the amount of sales
|
||||
and of items in a basket for less than 15 minutes
|
||||
is greater or equal than `Product.clic_limit`.
|
||||
"""
|
||||
# import here to avoid circular import
|
||||
from eboutic.models import BasketItem
|
||||
|
||||
nb_click_subquery = Subquery(
|
||||
Selling.objects.filter(product_id=OuterRef("id"))
|
||||
.values("product_id")
|
||||
.annotate(res=Sum("quantity", default=0))
|
||||
.values("res")[:1]
|
||||
)
|
||||
nb_basket_items_subquery = Subquery(
|
||||
BasketItem.objects.filter(
|
||||
product_id=OuterRef("id"),
|
||||
basket__date__gt=now()
|
||||
- settings.SITH_EBOUTIC_BASKET_TIMEOUT
|
||||
- settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT,
|
||||
)
|
||||
.values("product_id")
|
||||
.annotate(res=Sum("quantity"))
|
||||
.values("res")[:1]
|
||||
)
|
||||
return self.annotate(
|
||||
clicked=Coalesce(nb_click_subquery, 0),
|
||||
reserved=Coalesce(nb_basket_items_subquery, 0),
|
||||
).filter(Q(clic_limit=None) | Q(clic_limit__gt=(F("clicked") + F("reserved"))))
|
||||
|
||||
|
||||
class Product(models.Model):
|
||||
"""A product, with all its related information."""
|
||||
|
||||
@@ -399,7 +370,8 @@ class Product(models.Model):
|
||||
)
|
||||
code = models.CharField(_("code"), max_length=16, blank=True)
|
||||
purchase_price = CurrencyField(
|
||||
_("purchase price"), help_text=_("Initial cost of purchasing the product")
|
||||
_("purchase price"),
|
||||
help_text=_("Initial cost of purchasing the product"),
|
||||
)
|
||||
icon = ResizedImageField(
|
||||
height=70,
|
||||
@@ -416,21 +388,13 @@ class Product(models.Model):
|
||||
tray = models.BooleanField(
|
||||
_("tray price"), help_text=_("Buy five, get the sixth free"), default=False
|
||||
)
|
||||
clic_limit = models.PositiveSmallIntegerField(
|
||||
_("clic limit"),
|
||||
help_text=_(
|
||||
"If a limit is set, the product won't be purchasable "
|
||||
"anymore on the eboutic once the latter is reached."
|
||||
),
|
||||
null=True,
|
||||
blank=True,
|
||||
buying_groups = models.ManyToManyField(
|
||||
Group, related_name="products", verbose_name=_("buying groups"), blank=True
|
||||
)
|
||||
archived = models.BooleanField(_("archived"), default=False)
|
||||
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
|
||||
|
||||
objects = ProductQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("product")
|
||||
|
||||
@@ -616,6 +580,7 @@ class Counter(models.Model):
|
||||
view_groups = models.ManyToManyField(
|
||||
Group, related_name="viewable_counters", blank=True
|
||||
)
|
||||
token = models.CharField(_("token"), max_length=30, null=True, blank=True)
|
||||
|
||||
objects = CounterQuerySet.as_manager()
|
||||
|
||||
@@ -768,8 +733,10 @@ class Counter(models.Model):
|
||||
# but they share the same primary key
|
||||
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
|
||||
|
||||
def get_prices_for(self, customer: Customer) -> PriceQuerySet:
|
||||
return (
|
||||
def get_prices_for(
|
||||
self, customer: Customer, *, order_by: Sequence[str] | None = None
|
||||
) -> list[Price]:
|
||||
qs = (
|
||||
Price.objects.filter(
|
||||
product__counters=self, product__product_type__isnull=False
|
||||
)
|
||||
@@ -777,6 +744,9 @@ class Counter(models.Model):
|
||||
.select_related("product", "product__product_type")
|
||||
.prefetch_related("groups")
|
||||
)
|
||||
if order_by:
|
||||
qs = qs.order_by(*order_by)
|
||||
return list(qs)
|
||||
|
||||
|
||||
class CounterSellers(models.Model):
|
||||
@@ -827,7 +797,7 @@ class Refilling(models.Model):
|
||||
counter = models.ForeignKey(
|
||||
Counter, related_name="refillings", blank=False, on_delete=models.CASCADE
|
||||
)
|
||||
amount: CurrencyField = CurrencyField(_("amount"), min_value=0.01)
|
||||
amount = CurrencyField(_("amount"))
|
||||
operator = models.ForeignKey(
|
||||
User,
|
||||
related_name="refillings_as_operator",
|
||||
@@ -881,14 +851,6 @@ class Refilling(models.Model):
|
||||
return False
|
||||
return user.is_owner(self.counter) and self.payment_method != "CARD"
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if (self.amount + self.customer.amount) > settings.SITH_ACCOUNT_MAX_MONEY:
|
||||
raise ValidationError(
|
||||
_("There cannot be more than %(money)d€ on an AE account")
|
||||
% {"money": settings.SITH_ACCOUNT_MAX_MONEY}
|
||||
)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
self.customer.amount -= self.amount
|
||||
self.customer.save()
|
||||
@@ -1117,7 +1079,7 @@ class Permanency(models.Model):
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
start = models.DateTimeField(_("start date"))
|
||||
end = models.DateTimeField(_("end date"), null=True, blank=True, db_index=True)
|
||||
end = models.DateTimeField(_("end date"), null=True, db_index=True)
|
||||
activity = models.DateTimeField(_("last activity date"), auto_now=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
+14
-7
@@ -20,34 +20,41 @@
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
#
|
||||
#
|
||||
import random
|
||||
|
||||
from django.db.models.signals import pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from core.middleware import get_signal_request
|
||||
from core.models import OperationLog
|
||||
from counter.models import Refilling, Selling
|
||||
from counter.models import Counter, Refilling, Selling
|
||||
|
||||
|
||||
def write_log(instance: Selling | Refilling, operation_type):
|
||||
def write_log(instance, operation_type):
|
||||
def get_user():
|
||||
request = get_signal_request()
|
||||
|
||||
if not request:
|
||||
return None
|
||||
|
||||
if request.barmen:
|
||||
return random.choice(list(request.barmen))
|
||||
# Get a random barmen if deletion is from a counter
|
||||
session = getattr(request, "session", {})
|
||||
session_token = session.get("counter_token", None)
|
||||
if session_token:
|
||||
counter = Counter.objects.filter(token=session_token).first()
|
||||
if counter and len(counter.barmen_list) > 0:
|
||||
return counter.get_random_barman()
|
||||
|
||||
# Get the current logged user if not from a counter
|
||||
if request.user.is_authenticated:
|
||||
if request.user and not request.user.is_anonymous:
|
||||
return request.user
|
||||
|
||||
# Return None by default
|
||||
return None
|
||||
|
||||
OperationLog(
|
||||
label=str(instance), operator=get_user(), operation_type=operation_type
|
||||
label=str(instance),
|
||||
operator=get_user(),
|
||||
operation_type=operation_type,
|
||||
).save()
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { RecursivePartial, TomSettings } from "tom-select/src/types";
|
||||
import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base";
|
||||
import { registerComponent } from "#core:utils/web-components";
|
||||
import type { RecursivePartial, TomSettings } from "tom-select/dist/types/types";
|
||||
import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base.ts";
|
||||
import { registerComponent } from "#core:utils/web-components.ts";
|
||||
|
||||
const productParsingRegex = /^(\d+x)?(.*)/i;
|
||||
const codeParsingRegex = / \((\w+)\)$/;
|
||||
|
||||
function parseProduct(query: string): [number, string] {
|
||||
const parsed = productParsingRegex.exec(query) as RegExpExecArray;
|
||||
const parsed = productParsingRegex.exec(query);
|
||||
return [Number.parseInt(parsed[1] || "1", 10), parsed[2]];
|
||||
}
|
||||
|
||||
@@ -63,6 +63,13 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
this.widget.hook("after", "onOptionSelect", () => {
|
||||
/* Focus the next element if it's an input */
|
||||
if (this.nextElementSibling.nodeName === "INPUT") {
|
||||
(this.nextElementSibling as HTMLInputElement).focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
protected tomSelectSettings(): RecursivePartial<TomSettings> {
|
||||
/* We disable the dropdown on focus because we're going to always autofocus the widget */
|
||||
@@ -73,7 +80,9 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
|
||||
// We need to manually set weights or it results on an inconsistent
|
||||
// behavior between production and development environment
|
||||
searchField: [
|
||||
// @ts-expect-error documentation says it's fine, specified type is wrong
|
||||
{ field: "code", weight: 2 },
|
||||
// @ts-expect-error documentation says it's fine, specified type is wrong
|
||||
{ field: "text", weight: 0.5 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BasketItem } from "#counter:counter/basket";
|
||||
import type {
|
||||
CounterConfig,
|
||||
CounterItem,
|
||||
ErrorMessage,
|
||||
ProductFormula,
|
||||
} from "#counter:counter/types";
|
||||
import type { CounterProductSelect } from "./components/counter-product-select-index";
|
||||
@@ -23,24 +24,21 @@ document.addEventListener("alpine:init", () => {
|
||||
}
|
||||
}
|
||||
|
||||
this.codeField = this.$refs.codeField as CounterProductSelect;
|
||||
this.codeField.widget.hook("after", "onOptionSelect", () => {
|
||||
this.handleCode();
|
||||
});
|
||||
this.codeField = this.$refs.codeField;
|
||||
this.codeField.widget.focus();
|
||||
|
||||
// It's quite tricky to manually apply attributes to the management part
|
||||
// of a formset so we dynamically apply it here
|
||||
this.$refs.basketManagementForm
|
||||
.querySelector("#id_form-TOTAL_FORMS")
|
||||
?.setAttribute(":value", "getBasketSize()");
|
||||
.setAttribute(":value", "getBasketSize()");
|
||||
},
|
||||
|
||||
removeFromBasket(id: string) {
|
||||
delete this.basket[id];
|
||||
},
|
||||
|
||||
addToBasket(id: string, quantity: number) {
|
||||
addToBasket(id: string, quantity: number): ErrorMessage {
|
||||
const item: BasketItem =
|
||||
this.basket[id] || new BasketItem(config.products[id], 0);
|
||||
|
||||
@@ -49,7 +47,7 @@ document.addEventListener("alpine:init", () => {
|
||||
|
||||
if (item.quantity <= 0) {
|
||||
delete this.basket[id];
|
||||
return;
|
||||
return "";
|
||||
}
|
||||
|
||||
this.basket[id] = item;
|
||||
@@ -71,7 +69,7 @@ document.addEventListener("alpine:init", () => {
|
||||
const products = new Set(
|
||||
Object.values(this.basket).map((item: BasketItem) => item.product.productId),
|
||||
);
|
||||
const formula = config.formulas.find((f: ProductFormula) => {
|
||||
const formula: ProductFormula = config.formulas.find((f: ProductFormula) => {
|
||||
return f.products.every((p: number) => products.has(p));
|
||||
});
|
||||
if (formula === undefined) {
|
||||
@@ -79,13 +77,9 @@ document.addEventListener("alpine:init", () => {
|
||||
}
|
||||
// Now that the formula is found, remove the items composing it from the basket
|
||||
for (const product of formula.products) {
|
||||
const item = Object.entries(this.basket).find(
|
||||
const key = Object.entries(this.basket).find(
|
||||
([_, i]: [string, BasketItem]) => i.product.productId === product,
|
||||
);
|
||||
if (item === undefined) {
|
||||
continue;
|
||||
}
|
||||
const key = item[0];
|
||||
)[0];
|
||||
this.basket[key].quantity -= 1;
|
||||
if (this.basket[key].quantity <= 0) {
|
||||
this.removeFromBasket(key);
|
||||
@@ -95,7 +89,7 @@ document.addEventListener("alpine:init", () => {
|
||||
const result = Object.values(config.products)
|
||||
.filter((item: CounterItem) => item.productId === formula.result)
|
||||
.reduce((acc, curr) => (acc.price.amount < curr.price.amount ? acc : curr));
|
||||
this.addToBasket(result.price.id.toString(), 1);
|
||||
this.addToBasket(result.price.id, 1);
|
||||
this.alertMessage.display(
|
||||
interpolate(
|
||||
gettext("Formula %(formula)s applied"),
|
||||
@@ -122,18 +116,14 @@ document.addEventListener("alpine:init", () => {
|
||||
},
|
||||
|
||||
onRefillingSuccess(event: CustomEvent) {
|
||||
if (
|
||||
event.type !== "htmx:after-swap" ||
|
||||
event.detail.failed ||
|
||||
event.detail.elt.querySelector(".errorlist")
|
||||
) {
|
||||
if (event.type !== "htmx:after-request" || event.detail.failed) {
|
||||
return;
|
||||
}
|
||||
this.customerBalance += Number.parseFloat(
|
||||
(event.detail.target.querySelector("#id_amount") as HTMLInputElement).value,
|
||||
);
|
||||
document.getElementById("selling-accordion")?.setAttribute("open", "");
|
||||
this.codeField?.widget.focus();
|
||||
document.getElementById("selling-accordion").setAttribute("open", "");
|
||||
this.codeField.widget.focus();
|
||||
},
|
||||
|
||||
finish() {
|
||||
@@ -143,7 +133,7 @@ document.addEventListener("alpine:init", () => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
(this.$refs.basketForm as HTMLFormElement).submit();
|
||||
this.$refs.basketForm.submit();
|
||||
},
|
||||
|
||||
cancel() {
|
||||
@@ -151,8 +141,6 @@ document.addEventListener("alpine:init", () => {
|
||||
},
|
||||
|
||||
handleCode() {
|
||||
if (!this.codeField) throw Error("Unexpected null codeField.");
|
||||
|
||||
const [quantity, code] = this.codeField.getSelectedProduct() as [number, string];
|
||||
|
||||
if (this.codeField.getOperationCodes().includes(code.toUpperCase())) {
|
||||
@@ -166,7 +154,6 @@ document.addEventListener("alpine:init", () => {
|
||||
this.addToBasket(code, quantity);
|
||||
}
|
||||
this.codeField.widget.clear();
|
||||
this.codeField.widget.setTextboxValue("");
|
||||
this.codeField.widget.focus();
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -42,28 +42,7 @@
|
||||
min-width: 350px;
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
margin-left: 0;
|
||||
|
||||
.basket-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
.product-name {
|
||||
flex: 1 2 0;
|
||||
min-width: 0;
|
||||
text-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
margin-top: .5rem;
|
||||
margin-bottom: .5rem;
|
||||
list-style-type: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,15 +56,10 @@
|
||||
<div class="accordion-content">
|
||||
{% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %}
|
||||
|
||||
<form method="post" action="" @submit.prevent="handleCode">
|
||||
<form method="post" action=""
|
||||
class="code_form" @submit.prevent="handleCode">
|
||||
|
||||
<counter-product-select
|
||||
name="code"
|
||||
x-ref="codeField"
|
||||
autofocus
|
||||
required
|
||||
placeholder="{% trans %}Select a product...{% endtrans %}"
|
||||
>
|
||||
<counter-product-select name="code" x-ref="codeField" autofocus required placeholder="{% trans %}Select a product...{% endtrans %}">
|
||||
<option value=""></option>
|
||||
<optgroup label="{% trans %}Operations{% endtrans %}">
|
||||
<option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option>
|
||||
@@ -73,11 +68,13 @@
|
||||
{%- for category, prices in categories.items() -%}
|
||||
<optgroup label="{{ category }}">
|
||||
{%- for price in prices -%}
|
||||
<option value="{{ price.id }}">{{ price.full_label }} ({{ price.product.code }})</option>
|
||||
<option value="{{ price.id }}">{{ price.full_label }}</option>
|
||||
{%- endfor -%}
|
||||
</optgroup>
|
||||
{%- endfor -%}
|
||||
</counter-product-select>
|
||||
|
||||
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
|
||||
</form>
|
||||
|
||||
{% for error in form.non_form_errors() %}
|
||||
@@ -105,9 +102,7 @@
|
||||
{{ form.management_form }}
|
||||
</div>
|
||||
<ul>
|
||||
<li x-show="getBasketSize() === 0">
|
||||
<em>{% trans %}This basket is empty{% endtrans %}</em>
|
||||
</li>
|
||||
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
|
||||
<template x-for="(item, index) in Object.values(basket)" :key="item.product.price.id">
|
||||
<li>
|
||||
<template x-for="error in item.errors">
|
||||
@@ -115,15 +110,12 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="basket-row">
|
||||
<div>
|
||||
<button @click.prevent="addToBasket(item.product.price.id, -1)">-</button>
|
||||
<span class="quantity" x-text="item.quantity"></span>
|
||||
<button @click.prevent="addToBasket(item.product.price.id, 1)">+</button>
|
||||
</div>
|
||||
|
||||
<span class="product-name" x-text="item.product.name"></span>
|
||||
<span x-text="`${item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })} €`"></span>
|
||||
<span x-text="item.product.name"></span> :
|
||||
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span>
|
||||
<span x-show="item.getBonusQuantity() > 0"
|
||||
x-text="`${item.getBonusQuantity()} x P`"></span>
|
||||
|
||||
@@ -131,7 +123,6 @@
|
||||
class="remove-item"
|
||||
@click.prevent="removeFromBasket(item.product.price.id)"
|
||||
><i class="fa fa-trash-can delete-action"></i></button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
@@ -176,17 +167,13 @@
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
<details
|
||||
class="accordion"
|
||||
name="selling"
|
||||
@toggle="if ($event.newState === 'open') $el.querySelector('input[type=number]')?.focus()"
|
||||
>
|
||||
<details class="accordion" name="selling">
|
||||
<summary>{% trans %}Refilling{% endtrans %}</summary>
|
||||
{% if object.type == "BAR" %}
|
||||
{% if refilling_fragment %}
|
||||
<div
|
||||
class="accordion-content"
|
||||
@htmx:after-swap="onRefillingSuccess"
|
||||
@htmx:after-request="onRefillingSuccess"
|
||||
>
|
||||
{{ refilling_fragment }}
|
||||
</div>
|
||||
|
||||
@@ -32,11 +32,12 @@
|
||||
</ul>
|
||||
<p><strong>{% trans %}Total: {% endtrans %}{{ last_total }} €</strong></p>
|
||||
{% endif %}
|
||||
{% if can_click %}
|
||||
{% if barmen %}
|
||||
<p>{% trans %}Enter client code:{% endtrans %}</p>
|
||||
<form method="post" action="" id="select-user-form">
|
||||
<form method="post" action="">
|
||||
{% csrf_token %}
|
||||
{{ form }}
|
||||
<input type="hidden" name="counter_token" value="{{ counter.token }}" />
|
||||
{{ form.as_p() }}
|
||||
<p><input type="submit" value="{% trans %}validate{% endtrans %}" /></p>
|
||||
</form>
|
||||
{% else %}
|
||||
@@ -44,36 +45,17 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if counter.type == 'BAR' %}
|
||||
<h3>{% trans %}Barmen:{% endtrans %}</h3>
|
||||
|
||||
{% if barmen_here %}
|
||||
<div class="row gap-2x">
|
||||
<div>
|
||||
<h4>{% trans %}On this device{% endtrans %}</h4>
|
||||
{% for b in barmen_here %}
|
||||
<p>{{ barman_logout_link(b) }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
<h4>{% trans %}Elsewhere{% endtrans %}</h4>
|
||||
{% if barmen_here|length == barmen|length %}
|
||||
{# all logged barmen are logged in this session #}
|
||||
<p><em>{% trans %}No barman logged elsewhere{% endtrans %}</em></p>
|
||||
{% else %}
|
||||
{% for b in barmen %}
|
||||
{%- if b not in barmen_here -%}
|
||||
<p>{{ barman_logout_link(b) }}</p>
|
||||
{%- endif -%}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<h3>{% trans %}Barman: {% endtrans %}</h3>
|
||||
{% for b in barmen %}
|
||||
<p>{{ barman_logout_link(b) }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{{ login_fragment }}
|
||||
<form method="post" action="{{ url('counter:login', counter_id=counter.id) }}">
|
||||
{% csrf_token %}
|
||||
{{ login_form.as_p() }}
|
||||
<p><input type="submit" value="{% trans %}login{% endtrans %}" /></p>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -81,10 +63,10 @@
|
||||
{{ super() }}
|
||||
<script type="text/javascript">
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
{# The login form annoyingly takes priority over the code form
|
||||
This is due to the loading time of the web component
|
||||
We can't rely on DOMContentLoaded to know if the component is there so we
|
||||
periodically run a script until the field is there #}
|
||||
// The login form annoyingly takes priority over the code form
|
||||
// This is due to the loading time of the web component
|
||||
// We can't rely on DOMContentLoaded to know if the component is there so we
|
||||
// periodically run a script until the field is there
|
||||
const autofocus = () => {
|
||||
const field = document.querySelector("input[id='id_code']");
|
||||
if (field === null){
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<form hx-post="{{ action }}" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
{{ form }}
|
||||
<input type="submit" value="{% trans %}Confirm{% endtrans %}"/>
|
||||
</form>
|
||||
@@ -118,7 +118,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset><div>{{ form.clic_limit.as_field_group() }}</div></fieldset>
|
||||
<fieldset><div>{{ form.counters.as_field_group() }}</div></fieldset>
|
||||
|
||||
<h3 class="margin-bottom">{% trans %}Prices{% endtrans %}</h3>
|
||||
|
||||
+48
-195
@@ -17,11 +17,9 @@ from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission, make_password
|
||||
from django.contrib.messages import DEFAULT_LEVELS, get_messages
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import resolve_url
|
||||
from django.test import Client, TestCase
|
||||
@@ -39,7 +37,6 @@ from core.models import BanGroup, Group, User
|
||||
from counter.baker_recipes import price_recipe, product_recipe, sale_recipe
|
||||
from counter.models import (
|
||||
Counter,
|
||||
CounterSellers,
|
||||
Customer,
|
||||
Permanency,
|
||||
ProductType,
|
||||
@@ -69,14 +66,10 @@ class TestFullClickBase(TestCase):
|
||||
cls.subscriber = subscriber_user.make()
|
||||
|
||||
cls.counter = baker.make(Counter, type="BAR")
|
||||
cls.counter.sellers.add(cls.barmen, cls.board_admin)
|
||||
|
||||
cls.other_counter = baker.make(Counter, type="BAR")
|
||||
CounterSellers.objects.bulk_create(
|
||||
[
|
||||
CounterSellers(counter=cls.counter, user=cls.barmen),
|
||||
CounterSellers(counter=cls.counter, user=cls.board_admin),
|
||||
CounterSellers(counter=cls.other_counter, user=cls.barmen),
|
||||
]
|
||||
)
|
||||
cls.other_counter.sellers.add(cls.barmen)
|
||||
|
||||
cls.yet_another_counter = baker.make(Counter, type="BAR")
|
||||
|
||||
@@ -121,10 +114,7 @@ class TestRefilling(TestFullClickBase):
|
||||
) -> HttpResponse:
|
||||
used_client = client if client is not None else self.client
|
||||
return used_client.post(
|
||||
reverse(
|
||||
"counter:refilling_create",
|
||||
kwargs={"customer_id": user.pk, "counter_id": self.counter.pk},
|
||||
),
|
||||
reverse("counter:refilling_create", kwargs={"customer_id": user.pk}),
|
||||
{"amount": str(amount), "payment_method": Refilling.PaymentMethod.CASH},
|
||||
HTTP_REFERER=reverse(
|
||||
"counter:click", kwargs={"counter_id": counter.id, "user_id": user.pk}
|
||||
@@ -144,28 +134,23 @@ class TestRefilling(TestFullClickBase):
|
||||
assert self.updated_amount(self.customer) == 0
|
||||
|
||||
def test_refilling_no_refer_fail(self):
|
||||
"""Check that the refill fails is the HTTP_REFERER header is missing"""
|
||||
|
||||
def refill():
|
||||
return self.client.post(
|
||||
reverse(
|
||||
"counter:refilling_create",
|
||||
kwargs={
|
||||
"customer_id": self.customer.pk,
|
||||
"counter_id": self.counter.pk,
|
||||
},
|
||||
kwargs={"customer_id": self.customer.pk},
|
||||
),
|
||||
{"amount": "10", "payment_method": "CASH"},
|
||||
)
|
||||
|
||||
self.client.force_login(self.club_admin)
|
||||
assert refill().status_code == 403
|
||||
assert refill()
|
||||
|
||||
self.client.force_login(self.root)
|
||||
assert refill().status_code == 403
|
||||
assert refill()
|
||||
|
||||
self.client.force_login(self.subscriber)
|
||||
assert refill().status_code == 403
|
||||
assert refill()
|
||||
|
||||
assert self.updated_amount(self.customer) == 0
|
||||
|
||||
@@ -201,17 +186,6 @@ class TestRefilling(TestFullClickBase):
|
||||
== 404
|
||||
)
|
||||
|
||||
def test_refilling_above_limit_fails(self):
|
||||
"""Test that it's forbidden to refill a customer above the limit."""
|
||||
self.login_in_bar()
|
||||
limit = settings.SITH_ACCOUNT_MAX_MONEY
|
||||
# create a refilling to check that current balance is taken into account
|
||||
baker.make(Refilling, customer=self.customer.customer, amount=limit // 2)
|
||||
response = self.refill_user(self.customer, self.counter, (limit // 2) + 1)
|
||||
assert response.status_code == 200 # no redirect = failure
|
||||
self.customer.customer.refresh_from_db()
|
||||
assert self.updated_amount(self.customer) == limit // 2
|
||||
|
||||
def test_refilling_counter_success(self):
|
||||
self.login_in_bar()
|
||||
|
||||
@@ -468,19 +442,9 @@ class TestCounterClick(TestFullClickBase):
|
||||
|
||||
def test_click_not_connected(self):
|
||||
force_refill_user(self.customer, 10)
|
||||
|
||||
# trying to click on a bar without being logged should result
|
||||
# in a redirect to the counter page with an error message
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.snack.id, 2)])
|
||||
assertRedirects(res, self.counter.get_absolute_url())
|
||||
messages = list(get_messages(res.wsgi_request))
|
||||
assert len(messages) == 1
|
||||
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
|
||||
assert (
|
||||
messages[0].message == "Vous ne pouvez pas cliquer des gens sur ce comptoir"
|
||||
)
|
||||
|
||||
# trying to click on an office counter without permission should 403
|
||||
res = self.submit_basket(
|
||||
self.customer, [BasketItem(self.snack.id, 2)], counter=self.club_counter
|
||||
)
|
||||
@@ -535,19 +499,6 @@ class TestCounterClick(TestFullClickBase):
|
||||
|
||||
assert self.updated_amount(self.customer) == Decimal(10)
|
||||
|
||||
def test_unrecord_above_limit_fails(self):
|
||||
"""Test that it's forbidden to give back a recorded product
|
||||
if it puts the account balance above the limit.
|
||||
"""
|
||||
self.login_in_bar()
|
||||
limit = settings.SITH_ACCOUNT_MAX_MONEY
|
||||
# put the account balance just at the limit
|
||||
baker.make(Refilling, customer=self.customer.customer, amount=limit)
|
||||
response = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)])
|
||||
assert response.status_code == 200 # no redirect = failure
|
||||
self.customer.customer.refresh_from_db()
|
||||
assert self.updated_amount(self.customer) == limit
|
||||
|
||||
def test_annotate_has_barman_queryset(self):
|
||||
"""Test if the custom queryset method `annotate_has_barman` works as intended."""
|
||||
counters = Counter.objects.annotate_has_barman(self.barmen)
|
||||
@@ -645,7 +596,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
product=iter(_product_recipe.make(archived=False, _quantity=2)),
|
||||
groups=[group],
|
||||
)
|
||||
customer_prices = list(counter.get_prices_for(customer))
|
||||
customer_prices = counter.get_prices_for(customer)
|
||||
assert unarchived_prices == customer_prices
|
||||
|
||||
|
||||
@@ -767,154 +718,59 @@ class TestCounterStats(TestCase):
|
||||
class TestBarmanConnection(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.barman = subscriber_user.make()
|
||||
cls.barman.set_password("plop")
|
||||
cls.barman.save()
|
||||
cls.counter = baker.make(Counter, type="BAR", sellers=[cls.barman])
|
||||
cls.login_url = reverse("counter:login", kwargs={"counter_id": cls.counter.id})
|
||||
cls.detail_url = reverse(
|
||||
"counter:details", kwargs={"counter_id": cls.counter.id}
|
||||
)
|
||||
cls.krophil = User.objects.get(username="krophil")
|
||||
cls.skia = User.objects.get(username="skia")
|
||||
cls.skia.customer.account = 800
|
||||
cls.krophil.customer.save()
|
||||
cls.skia.customer.save()
|
||||
|
||||
cls.counter = Counter.objects.get(id=2)
|
||||
|
||||
def test_barman_granted(self):
|
||||
response = self.client.post(
|
||||
self.login_url, {"username": self.barman.username, "password": "plop"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["HX-Redirect"] == self.detail_url
|
||||
last_perm = Permanency.objects.last()
|
||||
assert last_perm.counter == self.counter
|
||||
assert last_perm.user == self.barman
|
||||
assert last_perm.end is None
|
||||
response = self.client.get(
|
||||
self.detail_url, {"username": self.barman.username, "password": "plop"}
|
||||
)
|
||||
assert self.barman in response.wsgi_request.barmen
|
||||
assert response.context_data.get("barmen") == [self.barman]
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
assert soup.find("form", id="select-user-form") is not None
|
||||
|
||||
def assert_counter_login_fails(self, user: User):
|
||||
initial_perms = set(self.counter.permanencies.filter(user=user, end=None))
|
||||
response = self.client.post(
|
||||
self.login_url, {"username": user.username, "password": "plop"}
|
||||
)
|
||||
assert "HX-Redirect" not in response.headers
|
||||
assert (
|
||||
set(self.counter.permanencies.filter(user=user, end=None)) == initial_perms
|
||||
)
|
||||
if initial_perms:
|
||||
# the user was already logged in, and we already tested
|
||||
# that it didn't re-login, so we can skip the next assertions.
|
||||
return
|
||||
|
||||
self.counter.refresh_from_db()
|
||||
assert response.wsgi_request.barmen.isdisjoint(set(self.counter.barmen_list))
|
||||
|
||||
response = self.client.get(self.detail_url)
|
||||
assert response.context_data.get("barmen") == []
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
assert soup.find("form", id="select-user-form") is None
|
||||
|
||||
def test_barman_not_seller(self):
|
||||
"""Test when the barman is not a seller of the counter"""
|
||||
not_barman = subscriber_user.make()
|
||||
not_barman.set_password("plop")
|
||||
not_barman.save()
|
||||
self.assert_counter_login_fails(not_barman)
|
||||
|
||||
def test_barman_already_logged(self):
|
||||
"""Test when the barman is already logged in the current counter."""
|
||||
self.client.post(
|
||||
self.login_url, {"username": self.barman.username, "password": "plop"}
|
||||
reverse("counter:login", args=[self.counter.id]),
|
||||
{"username": "krophil", "password": "plop"},
|
||||
)
|
||||
self.assert_counter_login_fails(self.barman)
|
||||
response = self.client.get(reverse("counter:details", args=[self.counter.id]))
|
||||
|
||||
def test_barman_already_logged_in_another_device(self):
|
||||
"""Test when the barman is already logged in the current counter on another device."""
|
||||
other_client = Client()
|
||||
other_client.post(
|
||||
self.login_url, {"username": self.barman.username, "password": "plop"}
|
||||
)
|
||||
self.assert_counter_login_fails(self.barman)
|
||||
assert "<p>Entrez un code client : </p>" in str(response.content)
|
||||
|
||||
def test_barman_login_elsewhere(self):
|
||||
"""Test when the barman log himself out then log in on another device."""
|
||||
def test_counters_list_barmen(self):
|
||||
self.client.post(
|
||||
self.login_url, {"username": self.barman.username, "password": "plop"}
|
||||
reverse("counter:login", args=[self.counter.id]),
|
||||
{"username": "krophil", "password": "plop"},
|
||||
)
|
||||
other_client = Client()
|
||||
other_client.post(
|
||||
reverse("counter:logout", kwargs={"counter_id": self.counter.id}),
|
||||
data={"user_id": self.barman.id},
|
||||
)
|
||||
response = other_client.post(
|
||||
self.login_url, {"username": self.barman.username, "password": "plop"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["HX-Redirect"] == self.detail_url
|
||||
# the barmen should now be logged in `other_client`...
|
||||
response = other_client.get(
|
||||
self.detail_url, {"username": self.barman.username, "password": "plop"}
|
||||
)
|
||||
assert self.barman in response.wsgi_request.barmen
|
||||
response = self.client.get(reverse("counter:activity", args=[self.counter.id]))
|
||||
|
||||
# ... but not in `self.client`
|
||||
response = self.client.get(
|
||||
self.detail_url, {"username": self.barman.username, "password": "plop"}
|
||||
)
|
||||
assert self.barman not in response.wsgi_request.barmen
|
||||
assert '<li><a href="/user/10/">Kro Phil'</a></li>' in str(response.content)
|
||||
|
||||
def test_barman_already_logged_elsewhere(self):
|
||||
"""Test when the barman is already logged in another counter."""
|
||||
other_counter = baker.make(Counter, type="BAR")
|
||||
CounterSellers.objects.create(counter=other_counter, user=self.barman)
|
||||
def test_barman_denied(self):
|
||||
self.client.post(
|
||||
reverse("counter:login", kwargs={"counter_id": other_counter.id}),
|
||||
{"username": self.barman.username, "password": "plop"},
|
||||
reverse("counter:login", args=[self.counter.id]),
|
||||
{"username": "skia", "password": "plop"},
|
||||
)
|
||||
response_get = self.client.get(
|
||||
reverse("counter:details", args=[self.counter.id])
|
||||
)
|
||||
self.assert_counter_login_fails(self.barman)
|
||||
|
||||
def test_login_on_non_bar_counter(self):
|
||||
counter = baker.make(Counter, type="OFFICE")
|
||||
CounterSellers.objects.create(counter=counter, user=self.barman)
|
||||
url = reverse("counter:login", kwargs={"counter_id": counter.id})
|
||||
response = self.client.get(url)
|
||||
assert response.status_code == 403
|
||||
response = self.client.post(
|
||||
url, {"username": self.barman.username, "password": "plop"}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "<p>Merci de vous identifier</p>" in str(response_get.content)
|
||||
|
||||
def test_logout_then_login(self):
|
||||
"""Test that the login is successful if it is after a previous logout.
|
||||
def test_counters_list_no_barmen(self):
|
||||
self.client.post(
|
||||
reverse("counter:login", args=[self.counter.id]),
|
||||
{"username": "krophil", "password": "plop"},
|
||||
)
|
||||
response = self.client.get(reverse("counter:activity", args=[self.counter.id]))
|
||||
|
||||
This is a regression test for #1440
|
||||
"""
|
||||
self.client.post( # login
|
||||
self.login_url, {"username": self.barman.username, "password": "plop"}
|
||||
)
|
||||
self.client.post( # logout
|
||||
reverse("counter:logout", kwargs={"counter_id": self.counter.id}),
|
||||
data={"user_id": self.barman.id},
|
||||
)
|
||||
response = self.client.post( # and re-login
|
||||
self.login_url, {"username": self.barman.username, "password": "plop"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["HX-Redirect"] == self.detail_url
|
||||
response = self.client.get(
|
||||
self.detail_url, {"username": self.barman.username, "password": "plop"}
|
||||
)
|
||||
assert self.barman in response.wsgi_request.barmen
|
||||
assert '<li><a href="/user/1/">S' Kia</a></li>' not in str(response.content)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_barman_timeout(client: Client):
|
||||
def test_barman_timeout():
|
||||
"""Test that barmen timeout is well managed."""
|
||||
bar = baker.make(Counter, type="BAR")
|
||||
user = baker.make(User)
|
||||
CounterSellers.objects.create(counter=bar, user=user)
|
||||
bar.sellers.add(user)
|
||||
baker.make(Permanency, counter=bar, user=user, start=now())
|
||||
|
||||
qs = Counter.objects.annotate_is_open().filter(pk=bar.pk)
|
||||
@@ -930,8 +786,6 @@ def test_barman_timeout(client: Client):
|
||||
bar = qs[0]
|
||||
assert not bar.is_open
|
||||
assert bar.barmen_list == []
|
||||
res = client.get("")
|
||||
assert res.wsgi_request.barmen == set()
|
||||
|
||||
|
||||
class TestClubCounterClickAccess(TestCase):
|
||||
@@ -981,14 +835,14 @@ class TestClubCounterClickAccess(TestCase):
|
||||
|
||||
def test_barman(self):
|
||||
"""Sellers should be able to click on office counters"""
|
||||
CounterSellers.objects.create(counter=self.counter, user=self.user)
|
||||
self.counter.sellers.add(self.user)
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.get(self.click_url)
|
||||
assert res.status_code == 200
|
||||
|
||||
def test_both_barman_and_board_member(self):
|
||||
"""If the user is barman and board member, he should be authorized as well."""
|
||||
CounterSellers.objects.create(counter=self.counter, user=self.user)
|
||||
self.counter.sellers.add(self.user)
|
||||
baker.make(
|
||||
Membership, club=self.counter.club, user=self.user, role=self.board_role
|
||||
)
|
||||
@@ -1014,15 +868,14 @@ class TestCounterLogout:
|
||||
)
|
||||
assertRedirects(
|
||||
res,
|
||||
reverse("counter:details", kwargs={"counter_id": permanence.counter_id}),
|
||||
reverse(
|
||||
"counter:details", kwargs={"counter_id": permanence.counter_id}
|
||||
),
|
||||
)
|
||||
permanence.refresh_from_db()
|
||||
assert permanence.end == permanence.activity
|
||||
assert permanence.user not in res.wsgi_request.barmen
|
||||
assert permanence.end == now()
|
||||
|
||||
def test_logout_doesnt_change_old_permanences(self, client: Client):
|
||||
# regression test for #1141
|
||||
# https://github.com/ae-utbm/sith/pull/1141
|
||||
perm_counter = baker.make(Counter, type="BAR")
|
||||
permanence = baker.make(
|
||||
Permanency,
|
||||
@@ -1043,6 +896,6 @@ class TestCounterLogout:
|
||||
data={"user_id": permanence.user_id},
|
||||
)
|
||||
permanence.refresh_from_db()
|
||||
assert permanence.end == permanence.activity
|
||||
assert permanence.end == now()
|
||||
old_permanence.refresh_from_db()
|
||||
assert old_permanence.end == old_end
|
||||
|
||||
@@ -15,7 +15,6 @@ from core.models import User
|
||||
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
|
||||
from counter.models import (
|
||||
Counter,
|
||||
CounterSellers,
|
||||
Customer,
|
||||
Refilling,
|
||||
ReturnableProduct,
|
||||
@@ -39,7 +38,7 @@ class TestStudentCard(TestCase):
|
||||
cls.subscriber = subscriber_user.make()
|
||||
|
||||
cls.counter = baker.make(Counter, type="BAR")
|
||||
CounterSellers.objects.create(counter=cls.counter, user=cls.barmen)
|
||||
cls.counter.sellers.add(cls.barmen)
|
||||
|
||||
cls.club_counter = baker.make(Counter)
|
||||
role = baker.make(ClubRole, club=cls.club_counter.club, is_board=True)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import itertools
|
||||
from io import BytesIO
|
||||
from typing import Callable
|
||||
from uuid import uuid4
|
||||
@@ -9,7 +8,6 @@ from django.core.cache import cache
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
from PIL import Image
|
||||
@@ -18,10 +16,9 @@ from pytest_django.asserts import assertNumQueries, assertRedirects
|
||||
from club.models import Club
|
||||
from core.baker_recipes import board_user, subscriber_user
|
||||
from core.models import Group, User
|
||||
from counter.baker_recipes import product_recipe, sale_recipe
|
||||
from counter.baker_recipes import product_recipe
|
||||
from counter.forms import ProductForm, ProductPriceFormSet
|
||||
from counter.models import Price, Product, ProductType, Selling
|
||||
from eboutic.models import Basket, BasketItem
|
||||
from counter.models import Price, Product, ProductType
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -225,59 +222,3 @@ def test_price_for_user():
|
||||
assert list(qs.for_user(users[0])) == [prices[0], prices[1], prices[4]]
|
||||
assert list(qs.for_user(users[1])) == [prices[0], prices[4]]
|
||||
assert list(qs.for_user(users[2])) == [prices[0], prices[3]]
|
||||
|
||||
|
||||
class TestProductClicLimit(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.products = product_recipe.make(
|
||||
clic_limit=itertools.chain([5, 10, 15], itertools.repeat(None)),
|
||||
_quantity=6,
|
||||
_bulk_create=True,
|
||||
)
|
||||
cls.qs = Product.objects.filter(id__in=[p.id for p in cls.products])
|
||||
|
||||
def test_no_sales_or_basket(self):
|
||||
"""Test that it works if no sales has been made yet"""
|
||||
assert list(self.qs.under_clic_limit()) == self.products
|
||||
|
||||
def test_with_sales(self):
|
||||
"""Test that it works when there are existing sales"""
|
||||
sales = sale_recipe.make(
|
||||
product=itertools.cycle(self.products),
|
||||
_quantity=len(self.products) * 5,
|
||||
_bulk_create=True,
|
||||
)
|
||||
Selling.objects.filter(id__in=[s.id for s in sales]).update(quantity=2)
|
||||
assert list(self.qs.under_clic_limit()) == self.products[2:]
|
||||
|
||||
def test_with_sales_and_basket(self):
|
||||
"""Test that it works when there are existing sales and basket items."""
|
||||
sales = sale_recipe.make(
|
||||
product=itertools.cycle(self.products),
|
||||
_quantity=len(self.products) * 5,
|
||||
_bulk_create=True,
|
||||
)
|
||||
Selling.objects.filter(id__in=[s.id for s in sales]).update(quantity=1)
|
||||
basket = baker.make(
|
||||
Basket, date=now() - settings.SITH_EBOUTIC_BASKET_TIMEOUT / 2
|
||||
)
|
||||
items = baker.make(
|
||||
BasketItem,
|
||||
product=itertools.cycle(self.products),
|
||||
basket=basket,
|
||||
_quantity=len(self.products) * 5,
|
||||
)
|
||||
BasketItem.objects.filter(id__in=[i.id for i in items]).update(quantity=1)
|
||||
assert list(self.qs.under_clic_limit()) == self.products[2:]
|
||||
|
||||
# expired basket items shouldn't be accounted when computing clic limit
|
||||
item = BasketItem.objects.filter(product=self.products[1])[0]
|
||||
item.basket = baker.make(
|
||||
Basket,
|
||||
date=now()
|
||||
- settings.SITH_EBOUTIC_BASKET_TIMEOUT
|
||||
- settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT,
|
||||
)
|
||||
item.save()
|
||||
assert list(self.qs.under_clic_limit()) == self.products[1:]
|
||||
|
||||
+3
-4
@@ -41,6 +41,7 @@ from counter.views.admin import (
|
||||
ReturnableProductUpdateView,
|
||||
SellingDeleteView,
|
||||
)
|
||||
from counter.views.auth import counter_login, counter_logout
|
||||
from counter.views.cash import (
|
||||
CashSummaryEditView,
|
||||
CashSummaryListView,
|
||||
@@ -56,9 +57,7 @@ from counter.views.eticket import (
|
||||
from counter.views.home import (
|
||||
CounterActivityView,
|
||||
CounterLastOperationsView,
|
||||
CounterLoginFragment,
|
||||
CounterMain,
|
||||
counter_logout,
|
||||
)
|
||||
from counter.views.invoice import InvoiceCallView
|
||||
from counter.views.student_card import StudentCardDeleteView, StudentCardFormFragment
|
||||
@@ -67,7 +66,7 @@ urlpatterns = [
|
||||
path("<int:counter_id>/", CounterMain.as_view(), name="details"),
|
||||
path("<int:counter_id>/click/<int:user_id>/", CounterClick.as_view(), name="click"),
|
||||
path(
|
||||
"<int:counter_id>/refill/<int:customer_id>/",
|
||||
"refill/<int:customer_id>/",
|
||||
RefillingCreateView.as_view(),
|
||||
name="refilling_create",
|
||||
),
|
||||
@@ -83,7 +82,7 @@ urlpatterns = [
|
||||
),
|
||||
path("<int:counter_id>/activity/", CounterActivityView.as_view(), name="activity"),
|
||||
path("<int:counter_id>/stats/", CounterStatView.as_view(), name="stats"),
|
||||
path("<int:counter_id>/login/", CounterLoginFragment.as_view(), name="login"),
|
||||
path("<int:counter_id>/login/", counter_login, name="login"),
|
||||
path("<int:counter_id>/logout/", counter_logout, name="logout"),
|
||||
path("eticket/<int:selling_id>/pdf/", EticketPDFView.as_view(), name="eticket_pdf"),
|
||||
path(
|
||||
|
||||
+16
-3
@@ -3,6 +3,8 @@ from urllib.parse import urlparse
|
||||
from django.http import HttpRequest
|
||||
from django.urls import resolve
|
||||
|
||||
from counter.models import Counter
|
||||
|
||||
|
||||
def is_logged_in_counter(request: HttpRequest) -> bool:
|
||||
"""Check if the request is sent from a device logged to a counter.
|
||||
@@ -18,13 +20,24 @@ def is_logged_in_counter(request: HttpRequest) -> bool:
|
||||
or the request path belongs to the counter app
|
||||
(eg. the barman went back to the main by missclick and go back
|
||||
to the counter)
|
||||
- There are barmen logged in the current session
|
||||
- The current session has a counter token associated with it.
|
||||
- A counter with this token exists.
|
||||
- The counter is open
|
||||
"""
|
||||
referer_ok = (
|
||||
"HTTP_REFERER" in request.META
|
||||
and resolve(urlparse(request.META["HTTP_REFERER"]).path).app_name == "counter"
|
||||
)
|
||||
if not referer_ok and request.resolver_match.app_name != "counter":
|
||||
has_token = (
|
||||
(referer_ok or request.resolver_match.app_name == "counter")
|
||||
and "counter_token" in request.session
|
||||
and request.session["counter_token"]
|
||||
)
|
||||
if not has_token:
|
||||
return False
|
||||
|
||||
return bool(request.barmen)
|
||||
return (
|
||||
Counter.objects.annotate_is_open()
|
||||
.filter(token=request.session["counter_token"], is_open=True)
|
||||
.exists()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
|
||||
from django.http import HttpRequest, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import now
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from core.views.forms import LoginForm
|
||||
from counter.models import Counter, Permanency
|
||||
|
||||
|
||||
@require_POST
|
||||
def counter_login(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
|
||||
"""Log a user in a counter.
|
||||
|
||||
A successful login will result in the beginning of a counter duty
|
||||
for the user.
|
||||
"""
|
||||
counter = get_object_or_404(Counter, pk=counter_id)
|
||||
form = LoginForm(request, data=request.POST)
|
||||
if not form.is_valid():
|
||||
return redirect(counter.get_absolute_url() + "?credentials")
|
||||
user = form.get_user()
|
||||
if not counter.sellers.contains(user) or user in counter.barmen_list:
|
||||
return redirect(counter.get_absolute_url() + "?sellers")
|
||||
if len(counter.barmen_list) == 0:
|
||||
counter.gen_token()
|
||||
request.session["counter_token"] = counter.token
|
||||
counter.permanencies.create(user=user, start=timezone.now())
|
||||
return redirect(counter)
|
||||
|
||||
|
||||
@require_POST
|
||||
def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
|
||||
"""End the permanency of a user in this counter."""
|
||||
Permanency.objects.filter(
|
||||
counter=counter_id, user=request.POST["user_id"], end=None
|
||||
).update(end=now())
|
||||
return redirect("counter:details", counter_id=counter_id)
|
||||
+29
-34
@@ -12,10 +12,8 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
import random
|
||||
from collections import defaultdict
|
||||
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
@@ -23,8 +21,7 @@ from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404, redirect, resolve_url
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import SafeString
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import CreateView, FormView
|
||||
from django.views.generic import FormView
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
from ninja.main import HttpRequest
|
||||
|
||||
@@ -36,7 +33,6 @@ from counter.models import (
|
||||
Counter,
|
||||
Customer,
|
||||
ProductFormula,
|
||||
Refilling,
|
||||
ReturnableProduct,
|
||||
Selling,
|
||||
)
|
||||
@@ -50,7 +46,7 @@ def get_operator(request: HttpRequest, counter: Counter, customer: Customer) ->
|
||||
return request.user
|
||||
if counter.customer_is_barman(customer):
|
||||
return customer.user
|
||||
return random.choice(list(request.barmen))
|
||||
return counter.get_random_barman()
|
||||
|
||||
|
||||
class CounterClick(
|
||||
@@ -73,16 +69,16 @@ class CounterClick(
|
||||
current_tab = "counter"
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["form_kwargs"] = {
|
||||
"customer": self.customer,
|
||||
"counter": self.object,
|
||||
"form_kwargs": {
|
||||
"allowed_prices": {price.id: price for price in self.prices}
|
||||
},
|
||||
"allowed_prices": {price.id: price for price in self.prices},
|
||||
}
|
||||
return kwargs
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.customer = get_object_or_404(Customer, user_id=self.kwargs["user_id"])
|
||||
self.customer = get_object_or_404(Customer, user__id=self.kwargs["user_id"])
|
||||
obj: Counter = self.get_object()
|
||||
|
||||
if not self.customer.can_buy or self.customer.user.is_banned_counter:
|
||||
@@ -100,13 +96,14 @@ class CounterClick(
|
||||
# or a seller of this counter.
|
||||
raise PermissionDenied
|
||||
|
||||
if obj.type == "BAR" and not (
|
||||
request.barmen and request.barmen.issubset(set(obj.barmen_list))
|
||||
if obj.type == "BAR" and (
|
||||
not obj.is_open
|
||||
or "counter_token" not in request.session
|
||||
or request.session["counter_token"] != obj.token
|
||||
):
|
||||
messages.error(request, _("You cannot click users on this counter"))
|
||||
return redirect(obj) # Redirect to counter
|
||||
|
||||
self.prices = list(obj.get_prices_for(self.customer))
|
||||
self.prices = obj.get_prices_for(self.customer)
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
@@ -202,7 +199,7 @@ class CounterClick(
|
||||
)
|
||||
if self.object.can_refill():
|
||||
res["refilling_fragment"] = RefillingCreateView.as_fragment()(
|
||||
self.request, customer=self.customer, counter=self.object
|
||||
self.request, customer=self.customer
|
||||
)
|
||||
return res
|
||||
|
||||
@@ -226,10 +223,9 @@ class CounterClick(
|
||||
return kwargs
|
||||
|
||||
|
||||
class RefillingCreateView(FragmentMixin, CreateView):
|
||||
class RefillingCreateView(FragmentMixin, FormView):
|
||||
"""This is a fragment only view which integrates with counter_click.jinja"""
|
||||
|
||||
model = Refilling
|
||||
form_class = RefillForm
|
||||
template_name = "counter/fragments/create_refill.jinja"
|
||||
|
||||
@@ -241,35 +237,34 @@ class RefillingCreateView(FragmentMixin, CreateView):
|
||||
if not is_logged_in_counter(request):
|
||||
raise PermissionDenied
|
||||
|
||||
self.counter: Counter = get_object_or_404(Counter, id=self.kwargs["counter_id"])
|
||||
self.counter: Counter = get_object_or_404(
|
||||
Counter, token=request.session["counter_token"]
|
||||
)
|
||||
|
||||
if not (
|
||||
request.barmen
|
||||
and request.barmen.issubset(self.counter.barmen_list)
|
||||
and self.counter.can_refill()
|
||||
):
|
||||
if not self.counter.can_refill():
|
||||
raise PermissionDenied
|
||||
|
||||
self.operator = get_operator(request, self.counter, self.customer)
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def render_fragment(self, request, **kwargs) -> SafeString:
|
||||
self.customer = kwargs.pop("customer")
|
||||
self.counter = kwargs.pop("counter")
|
||||
self.object = None
|
||||
return super().render_fragment(request, **kwargs)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {
|
||||
"counter": self.counter,
|
||||
"operator": get_operator(self.request, self.counter, self.customer),
|
||||
"customer": self.customer,
|
||||
}
|
||||
def form_valid(self, form):
|
||||
res = super().form_valid(form)
|
||||
form.clean()
|
||||
form.instance.counter = self.counter
|
||||
form.instance.operator = self.operator
|
||||
form.instance.customer = self.customer
|
||||
form.instance.save()
|
||||
return res
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["action"] = reverse(
|
||||
"counter:refilling_create",
|
||||
kwargs={"customer_id": self.customer.pk, "counter_id": self.counter.pk},
|
||||
"counter:refilling_create", kwargs={"customer_id": self.customer.pk}
|
||||
)
|
||||
return kwargs
|
||||
|
||||
|
||||
+52
-99
@@ -15,122 +15,78 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import F
|
||||
from django.http import HttpRequest, HttpResponseRedirect
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.safestring import SafeString
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
from django.views.generic.edit import FormView
|
||||
from django.views.generic.edit import FormMixin, ProcessFormView
|
||||
|
||||
from core.auth.mixins import CanViewMixin
|
||||
from core.views import FragmentMixin, UseFragmentsMixin
|
||||
from counter.forms import CounterLoginForm, GetUserForm
|
||||
from counter.middleware import SESSION_PERMANENCES_KEY
|
||||
from counter.models import Counter, Permanency
|
||||
from core.views.forms import LoginForm
|
||||
from counter.forms import GetUserForm
|
||||
from counter.models import Counter
|
||||
from counter.utils import is_logged_in_counter
|
||||
from counter.views.mixins import CounterTabsMixin
|
||||
|
||||
|
||||
class CounterLoginFragment(FragmentMixin, SingleObjectMixin, FormView):
|
||||
model = Counter
|
||||
form_class = CounterLoginForm
|
||||
reload_on_redirect = True
|
||||
pk_url_kwarg = "counter_id"
|
||||
template_name = "counter/fragments/login.jinja"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
if self.object.type != "BAR":
|
||||
# barmen have to log in only if it is a bar,
|
||||
# so calling this view on a non-bar counter makes no sense
|
||||
raise PermissionDenied
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {
|
||||
"request": self.request,
|
||||
"counter": self.object,
|
||||
}
|
||||
|
||||
def form_valid(self, form: CounterLoginForm):
|
||||
user = form.get_user()
|
||||
perm = self.object.permanencies.create(user=user, start=timezone.now())
|
||||
self.request.session.setdefault(SESSION_PERMANENCES_KEY, []).append(perm.id)
|
||||
self.request.session.modified = True
|
||||
self.success_url = reverse(
|
||||
"counter:details", kwargs={"counter_id": self.object.id}
|
||||
)
|
||||
return super().form_valid(form)
|
||||
|
||||
def render_fragment(self, request, **kwargs) -> SafeString:
|
||||
self.object = kwargs.pop("counter")
|
||||
return super().render_fragment(request, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(**kwargs) | {
|
||||
"action": reverse("counter:login", kwargs={"counter_id": self.object.id})
|
||||
}
|
||||
|
||||
|
||||
@require_POST
|
||||
def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
|
||||
"""End the permanency of a user in this counter."""
|
||||
Permanency.objects.filter(
|
||||
counter=counter_id, user=request.POST["user_id"], end=None
|
||||
).update(end=F("activity"))
|
||||
return redirect("counter:details", counter_id=counter_id)
|
||||
|
||||
|
||||
class CounterMain(
|
||||
CounterTabsMixin, UseFragmentsMixin, CanViewMixin, SingleObjectMixin, FormView
|
||||
CounterTabsMixin, CanViewMixin, DetailView, ProcessFormView, FormMixin
|
||||
):
|
||||
"""The public (barman) view."""
|
||||
|
||||
model = Counter
|
||||
queryset = Counter.objects.exclude(type="EBOUTIC")
|
||||
template_name = "counter/counter_main.jinja"
|
||||
pk_url_kwarg = "counter_id"
|
||||
form_class = GetUserForm
|
||||
form_class = (
|
||||
GetUserForm # Form to enter a client code and get the corresponding user id
|
||||
)
|
||||
current_tab = "counter"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.object: Counter = self.get_object()
|
||||
if self.object.type == "BAR":
|
||||
self.object.update_activity()
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().exclude(type="EBOUTIC")
|
||||
|
||||
def get_fragment_context_data(self) -> dict[str, SafeString]:
|
||||
login_fragment = (
|
||||
CounterLoginFragment.as_fragment()(self.request, counter=self.object)
|
||||
if self.object.type == "BAR"
|
||||
else ""
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
if self.object.type == "BAR" and not (
|
||||
"counter_token" in self.request.session
|
||||
and self.request.session["counter_token"] == self.object.token
|
||||
): # Check the token to avoid the bar to be stolen
|
||||
return HttpResponseRedirect(
|
||||
reverse_lazy(
|
||||
"counter:details",
|
||||
args=self.args,
|
||||
kwargs={"counter_id": self.object.id},
|
||||
)
|
||||
return super().get_fragment_context_data() | {"login_fragment": login_fragment}
|
||||
+ "?bad_location"
|
||||
)
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""We handle here the login form for the barman."""
|
||||
if self.request.method == "POST":
|
||||
self.object = self.get_object()
|
||||
self.object.update_activity()
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["login_form"] = LoginForm()
|
||||
kwargs["login_form"].fields["username"].widget.attrs["autofocus"] = True
|
||||
kwargs[
|
||||
"login_form"
|
||||
].cleaned_data = {} # add_error fails if there are no cleaned_data
|
||||
if "credentials" in self.request.GET:
|
||||
kwargs["login_form"].add_error(None, _("Bad credentials"))
|
||||
if "sellers" in self.request.GET:
|
||||
kwargs["login_form"].add_error(None, _("User is not barman"))
|
||||
kwargs["form"] = self.get_form()
|
||||
kwargs["form"].cleaned_data = {} # same as above
|
||||
if "bad_location" in self.request.GET:
|
||||
kwargs["form"].add_error(
|
||||
None, _("Bad location, someone is already logged in somewhere else")
|
||||
)
|
||||
if self.object.type == "BAR":
|
||||
kwargs["barmen"] = self.object.barmen_list
|
||||
kwargs["barmen_here"] = list(
|
||||
self.request.barmen.intersection(self.object.barmen_list)
|
||||
)
|
||||
kwargs["can_click"] = (
|
||||
self.object.type == "BAR"
|
||||
and self.request.barmen
|
||||
and self.request.barmen.issubset(set(self.object.barmen_list))
|
||||
) or (
|
||||
self.object.type == "OFFICE"
|
||||
and (
|
||||
self.object.sellers.contains(self.request.user)
|
||||
or self.object.club.has_rights_in_club(self.request.user)
|
||||
)
|
||||
)
|
||||
elif self.request.user.is_authenticated:
|
||||
kwargs["barmen"] = [self.request.user]
|
||||
if "last_basket" in self.request.session:
|
||||
kwargs["last_basket"] = self.request.session.pop("last_basket")
|
||||
kwargs["last_customer"] = self.request.session.pop("last_customer")
|
||||
@@ -140,17 +96,14 @@ class CounterMain(
|
||||
)
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form: GetUserForm):
|
||||
def form_valid(self, form):
|
||||
"""We handle here the redirection, passing the user id of the asked customer."""
|
||||
self.success_url = reverse(
|
||||
"counter:click",
|
||||
kwargs={
|
||||
"counter_id": self.kwargs["counter_id"],
|
||||
"user_id": form.cleaned_data["user_id"],
|
||||
},
|
||||
)
|
||||
self.kwargs["user_id"] = form.cleaned_data["user_id"]
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy("counter:click", args=self.args, kwargs=self.kwargs)
|
||||
|
||||
|
||||
class CounterLastOperationsView(CounterTabsMixin, CanViewMixin, DetailView):
|
||||
"""Provide the last operations to allow barmen to delete them."""
|
||||
|
||||
@@ -63,6 +63,7 @@ from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
# liste des autres migrations à appliquer avant celle-ci
|
||||
]
|
||||
@@ -116,16 +117,14 @@ Par exemple :
|
||||
```python
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def forwards_func(apps, schema_editor):
|
||||
print("Appplication de la migration")
|
||||
|
||||
|
||||
def reverse_func(apps, schema_editor):
|
||||
print("Annulation de la migration")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
@@ -165,7 +164,6 @@ On écrirait donc, dans l'application `pedagogy` :
|
||||
from django.db import models
|
||||
from core.models import User
|
||||
|
||||
|
||||
class UserUe(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
ue = models.CharField(max_length=10)
|
||||
@@ -176,7 +174,6 @@ Et nous aurions le fichier de migration suivant :
|
||||
from django.db import migrations, models
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
@@ -292,7 +289,6 @@ La commande vous donnera ceci :
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
replaces = [("pedagogy", "0004_userue"), ("pedagogy", "0005_alter_userue_ue")]
|
||||
|
||||
|
||||
@@ -191,6 +191,7 @@ for user in richest.annotate(amount=F("customer__amount"))[:100]:
|
||||
|
||||
On aurait même pu réorganiser ça :
|
||||
```python
|
||||
|
||||
from core.models import User
|
||||
from django.db.models import F
|
||||
|
||||
@@ -238,7 +239,10 @@ nous écrivons donc instinctivement :
|
||||
from counter.models import Counter
|
||||
|
||||
foyer = Counter.objects.get(name="Foyer")
|
||||
total_amount = sum(sale.amount * sale.unit_price for sale in foyer.sellings.all())
|
||||
total_amount = sum(
|
||||
sale.amount * sale.unit_price
|
||||
for sale in foyer.sellings.all()
|
||||
)
|
||||
```
|
||||
|
||||
On pourrait penser qu'il n'y a pas de problème.
|
||||
|
||||
@@ -27,11 +27,12 @@ SITH_SUBSCRIPTIONS = {
|
||||
"cursus-alternant": {"name": _("Alternating cursus"), "price": 30, "duration": 6},
|
||||
"membre-honoraire": {"name": _("Honorary member"), "price": 0, "duration": 666},
|
||||
"un-jour": {"name": _("One day"), "price": 0, "duration": 0.00555333},
|
||||
|
||||
# On rajoute ici notre cotisation
|
||||
# Elle se nomme "Un mois"
|
||||
# Coûte 6€
|
||||
# Dure 1 mois (on raisonne en semestre, ici, c'est 1/6 de semestre)
|
||||
"un-mois": {"name": _("One month"), "price": 6, "duration": 0.166},
|
||||
"un-mois": {"name": _("One month"), "price": 6, "duration": 0.166}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ from django.templatetags.static import static
|
||||
|
||||
# Sélectionnez le fichier de bannière pour le weekmail de l'automne 2018
|
||||
|
||||
|
||||
def get_banner(self):
|
||||
return "http://" + settings.SITH_URL + static("com/img/weekmail_bannerA18.jpg")
|
||||
```
|
||||
|
||||
@@ -147,15 +147,14 @@ Voici quelques exemples :
|
||||
env = Env()
|
||||
env.read_env()
|
||||
|
||||
|
||||
async def main():
|
||||
async with aiohttp.ClientSession(
|
||||
base_url="https://ae.utbm.fr/api/", headers={"X-APIKey": env.str("API_KEY")}
|
||||
base_url="https://ae.utbm.fr/api/",
|
||||
headers={"X-APIKey": env.str("API_KEY")}
|
||||
) as session:
|
||||
async with session.get("club/1") as res:
|
||||
print(await res.json())
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
|
||||
## Fonctionnement général
|
||||
|
||||
La boutique en ligne nécessite une interaction
|
||||
avec la banque pour son fonctionnement.
|
||||
|
||||
@@ -11,32 +9,3 @@ Nous ne pouvons donc que vous redirigez vers la doc du crédit
|
||||
agricole :
|
||||
[https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/](https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/)
|
||||
|
||||
## Limite de clic et expiration des paniers
|
||||
|
||||
Certains produits peuvent avoir un quota de vente.
|
||||
Une fois ce dernier atteint, il ne doit plus être possible de les acheter.
|
||||
|
||||
Pour éviter que cette limite soit dépassée si jamais plusieurs utilisateurs
|
||||
commandent et achètent ce produit à peu près en même temps,
|
||||
un produit est considéré comme « réservé » une fois placé dans un panier.
|
||||
La création du panier s'effectue lors de la soumission du formulaire sur l'eboutic.
|
||||
Une fois la transaction accomplie, le panier est supprimé.
|
||||
|
||||
Cependant, il reste un problème :
|
||||
que faire des utilisateurs qui créent un panier, mais ne terminent
|
||||
pas la transaction ?
|
||||
Pour résoudre ce cas, les paniers ont une durée de validité,
|
||||
définie dans le `settings.py`, grâce à deux variables :
|
||||
|
||||
- `settings.SITH_EBOUTIC_BASKET_TIMEOUT` :
|
||||
le temps pendant lequel un utilisateur peut payer avec son compte AE
|
||||
ou démarrer une etransaction
|
||||
- `settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT` :
|
||||
le temps alloué à l'utilisateur pour effectuer une etransaction ;
|
||||
au-delà de cette durée, la banque refusera le paiement
|
||||
et notifiera le sith de l'erreur.
|
||||
|
||||
Une fois expiré le temps défini par
|
||||
`settings.SITH_EBOUTIC_BASKET_TIMEOUT + settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT`,
|
||||
les produits contenus dans le panier sont à nouveau
|
||||
disponibles à la vente.
|
||||
|
||||
@@ -182,7 +182,7 @@ qui seront alors injectés.
|
||||
def get_context_data(**kwargs):
|
||||
return super().get_context_data(**kwargs) | {
|
||||
"create_fragment": FooCreateFragment.as_fragment()(),
|
||||
"update_fragment": FooUpdateFragment.as_fragment()(foo_id=1),
|
||||
"update_fragment": FooUpdateFragment.as_fragment()(foo_id=1)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -319,9 +319,11 @@ from core.views.mixins import UseFragmentsMixin
|
||||
class FooCompositeFormView(UseFragmentsMixin, TemplateView):
|
||||
fragments = {
|
||||
"create_fragment": FooCreateFragment,
|
||||
"update_fragment": FooUpdateFragment,
|
||||
"update_fragment": FooUpdateFragment
|
||||
}
|
||||
fragment_data = {
|
||||
"update_fragment": {"foo": 4}
|
||||
}
|
||||
fragment_data = {"update_fragment": {"foo": 4}}
|
||||
template_name = "app/foo.jinja"
|
||||
```
|
||||
|
||||
@@ -340,7 +342,9 @@ from core.views.mixins import UseFragmentsMixin
|
||||
|
||||
|
||||
class FooCompositeFormView(UseFragmentsMixin, TemplateView):
|
||||
fragments = {"create_fragment": FooCreateFragment}
|
||||
fragments = {
|
||||
"create_fragment": FooCreateFragment
|
||||
}
|
||||
template_name = "app/foo.jinja"
|
||||
|
||||
def get_fragment_context_data(self):
|
||||
|
||||
@@ -123,7 +123,6 @@ Par exemple, prenons le modèle suivant :
|
||||
```python
|
||||
from django.db import models
|
||||
|
||||
|
||||
class News(models.Model):
|
||||
# ...
|
||||
|
||||
@@ -200,7 +199,6 @@ Pour les vues sous forme de fonction, il y a le décorateur
|
||||
from django.views import View
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
|
||||
|
||||
class NewsModerateView(PermissionRequiredMixin, SingleObjectMixin, View):
|
||||
model = News
|
||||
pk_url_kwarg = "news_id"
|
||||
@@ -227,7 +225,6 @@ Pour les vues sous forme de fonction, il y a le décorateur
|
||||
from django.urls import reverse
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
|
||||
@permission_required("com.moderate_news")
|
||||
@require_POST
|
||||
def moderate_news(request, news_id: int):
|
||||
@@ -266,7 +263,6 @@ from core.auth.mixins import PermissionOrAuthorRequiredMixin
|
||||
|
||||
from django.views.generic import UpdateView
|
||||
|
||||
|
||||
class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
|
||||
model = News
|
||||
pk_url_kwarg = "news_id"
|
||||
@@ -328,8 +324,8 @@ Voici un exemple d'implémentation de ce système :
|
||||
|
||||
from core.models import User, Group
|
||||
|
||||
|
||||
class Article(models.Model):
|
||||
|
||||
title = models.CharField(_("title"), max_length=100)
|
||||
content = models.TextField(_("content"))
|
||||
|
||||
@@ -374,7 +370,6 @@ Voici un exemple d'implémentation de ce système :
|
||||
|
||||
from core.models import User, Group
|
||||
|
||||
|
||||
class Article(models.Model):
|
||||
title = models.CharField(_("title"), max_length=100)
|
||||
content = models.TextField(_("content"))
|
||||
@@ -534,7 +529,10 @@ class NewsQuerySet(models.QuerySet): # (1)!
|
||||
return self
|
||||
# sinon, on retourne les nouvelles modérées ou dont l'utilisateur
|
||||
# est l'auteur
|
||||
return self.filter(models.Q(is_moderated=True) | models.Q(author=user))
|
||||
return self.filter(
|
||||
models.Q(is_moderated=True)
|
||||
| models.Q(author=user)
|
||||
)
|
||||
|
||||
|
||||
class News(models.Model):
|
||||
|
||||
+1
-10
@@ -1,6 +1,3 @@
|
||||
from typing import Any
|
||||
|
||||
from ninja import Status
|
||||
from ninja_extra import ControllerBase, api_controller, route
|
||||
from ninja_extra.exceptions import NotFound
|
||||
|
||||
@@ -11,19 +8,13 @@ from eboutic.models import Basket
|
||||
|
||||
@api_controller("/etransaction", permissions=[CanView])
|
||||
class EtransactionInfoController(ControllerBase):
|
||||
@route.get(
|
||||
"/data/{basket_id}",
|
||||
url_name="etransaction_data",
|
||||
response={200: dict[str, Any], 410: str},
|
||||
)
|
||||
@route.get("/data/{basket_id}", url_name="etransaction_data")
|
||||
def fetch_etransaction_data(self, basket_id: int):
|
||||
"""Generate the data to pay an eboutic command with paybox.
|
||||
|
||||
The data is generated with the basket that is used by the current session.
|
||||
"""
|
||||
basket: Basket = self.get_object_or_exception(Basket, pk=basket_id)
|
||||
if basket.is_expired:
|
||||
return Status(410, "This basket is expired.")
|
||||
try:
|
||||
return dict(basket.get_e_transaction_data())
|
||||
except BillingInfo.DoesNotExist as e:
|
||||
|
||||
+37
-36
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Self
|
||||
|
||||
from dict2xml import dict2xml
|
||||
@@ -23,7 +24,6 @@ from django.conf import settings
|
||||
from django.db import DataError, models
|
||||
from django.db.models import F, OuterRef, Subquery, Sum
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.models import User
|
||||
@@ -39,6 +39,30 @@ from counter.models import (
|
||||
)
|
||||
|
||||
|
||||
class BillingInfoState(Enum):
|
||||
VALID = 1
|
||||
EMPTY = 2
|
||||
MISSING_PHONE_NUMBER = 3
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, info: BillingInfo | None) -> BillingInfoState:
|
||||
if info is None:
|
||||
return cls.EMPTY
|
||||
for attr in [
|
||||
"first_name",
|
||||
"last_name",
|
||||
"address_1",
|
||||
"zip_code",
|
||||
"city",
|
||||
"country",
|
||||
]:
|
||||
if getattr(info, attr) == "":
|
||||
return cls.EMPTY
|
||||
if info.phone_number is None:
|
||||
return cls.MISSING_PHONE_NUMBER
|
||||
return cls.VALID
|
||||
|
||||
|
||||
class Basket(models.Model):
|
||||
"""Basket is built when the user connects to an eboutic page."""
|
||||
|
||||
@@ -71,19 +95,6 @@ class Basket(models.Model):
|
||||
]
|
||||
)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Return True if this basket is expired.
|
||||
|
||||
An expired basket can no longer be used tp pay with sith account
|
||||
or to start an etransaction.
|
||||
|
||||
Warnings:
|
||||
Users have an additional time if they pay with an etransaction,
|
||||
so an expired basket may be purchased after its expiration in that case.
|
||||
"""
|
||||
return (self.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT) <= now()
|
||||
|
||||
def generate_sales(
|
||||
self, counter, seller: User, payment_method: Selling.PaymentMethod
|
||||
):
|
||||
@@ -122,22 +133,15 @@ class Basket(models.Model):
|
||||
]
|
||||
|
||||
def get_e_transaction_data(self) -> list[tuple[str, str]]:
|
||||
"""Get data for etransaction payment.
|
||||
|
||||
Raises:
|
||||
Customer.DoesNotExist: if the user linked to this basket
|
||||
has no customer account
|
||||
BillingInfo.DoesNotExist: if the user linked to this basket has no
|
||||
billing infos, or incorrect billing infos.
|
||||
ValueError: if this is called on a basket which payment delay is expired.
|
||||
"""
|
||||
user = self.user
|
||||
if not hasattr(user, "customer"):
|
||||
raise Customer.DoesNotExist
|
||||
if self.is_expired:
|
||||
raise ValueError("This method cannot be called on an expired basket.")
|
||||
customer = user.customer
|
||||
if not hasattr(user.customer, "billing_infos"):
|
||||
if (
|
||||
not hasattr(user.customer, "billing_infos")
|
||||
or BillingInfoState.from_model(user.customer.billing_infos)
|
||||
!= BillingInfoState.VALID
|
||||
):
|
||||
raise BillingInfo.DoesNotExist
|
||||
cart = {
|
||||
"shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}}
|
||||
@@ -151,10 +155,6 @@ class Basket(models.Model):
|
||||
("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT),
|
||||
("PBX_TOTAL", str(int(self.total * 100))),
|
||||
("PBX_DEVISE", "978"), # This is Euro
|
||||
(
|
||||
"PBX_DISPLAY",
|
||||
str(int(settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT.total_seconds())),
|
||||
),
|
||||
("PBX_CMD", str(self.id)),
|
||||
("PBX_PORTEUR", user.email),
|
||||
("PBX_RETOUR", "Amount:M;BasketID:R;Auto:A;Error:E;Sig:K"),
|
||||
@@ -219,14 +219,16 @@ class Invoice(models.Model):
|
||||
if self.validated:
|
||||
raise DataError(_("Invoice already validated"))
|
||||
customer, _created = Customer.get_or_create(user=self.user)
|
||||
kwargs = {"counter": get_eboutic(), "customer": customer, "date": self.date}
|
||||
kwargs = {
|
||||
"counter": get_eboutic(),
|
||||
"customer": customer,
|
||||
"date": self.date,
|
||||
"payment_method": Selling.PaymentMethod.CARD,
|
||||
}
|
||||
for i in self.items.select_related("product"):
|
||||
if i.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
|
||||
Refilling.objects.create(
|
||||
**kwargs,
|
||||
operator=self.user,
|
||||
amount=i.unit_price * i.quantity,
|
||||
payment_method=Refilling.PaymentMethod.CARD,
|
||||
**kwargs, operator=self.user, amount=i.unit_price * i.quantity
|
||||
)
|
||||
else:
|
||||
Selling.objects.create(
|
||||
@@ -237,7 +239,6 @@ class Invoice(models.Model):
|
||||
seller=self.user,
|
||||
unit_price=i.unit_price,
|
||||
quantity=i.quantity,
|
||||
payment_method=Selling.PaymentMethod.CARD,
|
||||
)
|
||||
self.validated = True
|
||||
self.save()
|
||||
|
||||
@@ -1,76 +1,22 @@
|
||||
import { type Notification, NotificationLevel } from "#core:utils/notifications";
|
||||
import { etransactioninfoFetchEtransactionData } from "#openapi";
|
||||
|
||||
interface Basket {
|
||||
id: number;
|
||||
timeout: Date;
|
||||
}
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data(
|
||||
"etransaction",
|
||||
(initialData: Record<string, string>, basket: Basket) => ({
|
||||
Alpine.data("etransaction", (initialData, basketId: number) => ({
|
||||
data: initialData,
|
||||
isCbAvailable: Object.keys(initialData).length > 0,
|
||||
isSithAvailable: true,
|
||||
|
||||
init() {
|
||||
const now = new Date();
|
||||
const timeout = basket.timeout.getTime() - now.getTime();
|
||||
if (timeout > 0) {
|
||||
// if not going inside this condition, it means that
|
||||
// basket was already outdated at initial page load,
|
||||
// in which case disabling buttons and displaying
|
||||
// error message has been done at rendering time
|
||||
setTimeout(() => this.timeoutBasket(), timeout);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Make this basket into a timeout state.
|
||||
* All submission inputs are disabled, and an error message is displayed.
|
||||
*/
|
||||
timeoutBasket() {
|
||||
this.isCbAvailable = false;
|
||||
this.isSithAvailable = false;
|
||||
const message = gettext("Basket expired");
|
||||
|
||||
const existingNotif: Notification | undefined = this.$notifications
|
||||
.getAll()
|
||||
.find(
|
||||
(n: Notification) =>
|
||||
n.tag === NotificationLevel.Error && n.text === message,
|
||||
);
|
||||
if (existingNotif === undefined) {
|
||||
this.$notifications.error(message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh the data used for etransaction.
|
||||
*
|
||||
* Note: if this is called while the basket is expired, it will be a no-op
|
||||
*/
|
||||
async fill() {
|
||||
if (new Date() > basket.timeout) {
|
||||
// refresh etransaction data only if the basket is still valid.
|
||||
this.timeoutBasket();
|
||||
return;
|
||||
}
|
||||
this.isCbAvailable = false;
|
||||
const res = await etransactioninfoFetchEtransactionData({
|
||||
path: {
|
||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||
path: { basket_id: basket.id },
|
||||
basket_id: basketId,
|
||||
},
|
||||
});
|
||||
if (res.response.ok) {
|
||||
this.data = res.data as Record<string, string>;
|
||||
this.data = res.data;
|
||||
this.isCbAvailable = true;
|
||||
} else if (res.response.status === 410) {
|
||||
// The basket is expired, so no payment method should be available at all.
|
||||
// This shouldn't happen, because we don't send the request
|
||||
// when the timeout is passed, but we are better safe than sorry
|
||||
this.timeoutBasket();
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -5,14 +5,13 @@ interface BasketItem {
|
||||
name: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
isRefill: boolean;
|
||||
}
|
||||
|
||||
const BASKET_CACHE_KEY = "basket";
|
||||
const BASKET_CACHE_VERSION = 2;
|
||||
const BASKET_CACHE_VERSION = 1;
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("basket", (validPrices: number[], lastPurchaseTime?: number) => ({
|
||||
Alpine.data("basket", (lastPurchaseTime?: number) => ({
|
||||
basket: [] as BasketItem[],
|
||||
|
||||
init() {
|
||||
@@ -20,31 +19,25 @@ document.addEventListener("alpine:init", () => {
|
||||
this.$watch("basket", () => {
|
||||
this.saveBasket();
|
||||
});
|
||||
// Invalidate basket if a purchase was made
|
||||
if (lastPurchaseTime !== null && localStorage.basketTimestamp !== undefined) {
|
||||
if (
|
||||
new Date(lastPurchaseTime) >=
|
||||
new Date(Number.parseInt(localStorage.basketTimestamp, 10))
|
||||
) {
|
||||
this.basket = [];
|
||||
}
|
||||
}
|
||||
document
|
||||
.getElementById("id_form-TOTAL_FORMS")
|
||||
?.setAttribute(":value", "basket.length");
|
||||
.setAttribute(":value", "basket.length");
|
||||
},
|
||||
|
||||
loadBasket(): BasketItem[] {
|
||||
const cached = versionedLocalStorage.getItem<BasketItem[]>(BASKET_CACHE_KEY, {
|
||||
version: BASKET_CACHE_VERSION,
|
||||
});
|
||||
if (!cached) {
|
||||
return [];
|
||||
}
|
||||
if (
|
||||
lastPurchaseTime &&
|
||||
localStorage.basketTimestamp &&
|
||||
new Date(lastPurchaseTime) >=
|
||||
new Date(Number.parseInt(localStorage.basketTimestamp, 10))
|
||||
) {
|
||||
// Invalidate basket if a purchase was made
|
||||
return [];
|
||||
}
|
||||
// The basket is cached and not expired, so return it,
|
||||
// but without items that are invalid
|
||||
// (e.g. because the product is archived, or sold out)
|
||||
return cached.filter((item) => validPrices.includes(item.priceId));
|
||||
return cached ?? [];
|
||||
},
|
||||
|
||||
saveBasket() {
|
||||
@@ -65,19 +58,6 @@ document.addEventListener("alpine:init", () => {
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the total of money that would be added to the AE account on basket purchase.
|
||||
*/
|
||||
getTotalAdded() {
|
||||
return this.basket
|
||||
.filter((item) => item.isRefill || item.unitPrice < 0)
|
||||
.reduce(
|
||||
(acc: number, item: BasketItem) =>
|
||||
acc + Math.abs(item.quantity * item.unitPrice),
|
||||
0,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Add 1 to the quantity of an item in the basket
|
||||
* @param {BasketItem} item
|
||||
@@ -100,7 +80,7 @@ document.addEventListener("alpine:init", () => {
|
||||
|
||||
if (this.basket[index].quantity === 0) {
|
||||
this.basket = this.basket.filter(
|
||||
(e: BasketItem) => e.priceId !== this.basket[index].priceId,
|
||||
(e: BasketItem) => e.priceId !== this.basket[index].id,
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -117,16 +97,14 @@ document.addEventListener("alpine:init", () => {
|
||||
* @param id The id of the product to add
|
||||
* @param name The name of the product
|
||||
* @param price The unit price of the product
|
||||
* @param isRefill true if the product is a refill bond
|
||||
* @returns The created item
|
||||
*/
|
||||
createItem(id: number, name: string, price: number, isRefill: boolean): BasketItem {
|
||||
createItem(id: number, name: string, price: number): BasketItem {
|
||||
const newItem = {
|
||||
priceId: id,
|
||||
name,
|
||||
quantity: 0,
|
||||
unitPrice: price,
|
||||
isRefill,
|
||||
} as BasketItem;
|
||||
|
||||
this.basket.push(newItem);
|
||||
@@ -141,17 +119,16 @@ document.addEventListener("alpine:init", () => {
|
||||
* @param id The id of the product to add
|
||||
* @param name The name of the product
|
||||
* @param price The unit price of the product
|
||||
* @param isRefill true if the product is a refill bond
|
||||
*/
|
||||
addFromCatalog(id: number, name: string, price: number, isRefill: boolean) {
|
||||
const item = this.basket.find((e: BasketItem) => e.priceId === id);
|
||||
addFromCatalog(id: number, name: string, price: number) {
|
||||
let item = this.basket.find((e: BasketItem) => e.priceId === id);
|
||||
|
||||
// if the item is not in the basket, we create it
|
||||
// else we add + 1 to it
|
||||
if (item) {
|
||||
this.add(item);
|
||||
} else {
|
||||
this.createItem(id, name, price, isRefill);
|
||||
item = this.createItem(id, name, price);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -21,10 +21,9 @@
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#billing-infos-fragment"
|
||||
x-show="collapsed"
|
||||
x-cloak
|
||||
>
|
||||
{% csrf_token %}
|
||||
{{ form }}
|
||||
{{ form.as_p() }}
|
||||
<br>
|
||||
<input
|
||||
type="submit" class="btn btn-blue clickable"
|
||||
|
||||
@@ -16,20 +16,17 @@
|
||||
<h3>{% trans %}Eboutic{% endtrans %}</h3>
|
||||
|
||||
<script type="text/javascript">
|
||||
const billingInfos = {{ billing_infos|safe }};
|
||||
let billingInfos = {{ billing_infos|safe }};
|
||||
</script>
|
||||
|
||||
<div x-data='etransaction(
|
||||
billingInfos,
|
||||
{ id: {{ basket.id }}, timeout: new Date("{{ basket.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT }}") }
|
||||
)'>
|
||||
<div x-data="etransaction(billingInfos, {{ basket.id }})">
|
||||
<p>{% trans %}Basket: {% endtrans %}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Article</td>
|
||||
<td>{% trans %}Quantity{% endtrans %}</td>
|
||||
<td>{% trans %}Unit price{% endtrans %}</td>
|
||||
<td>Quantity</td>
|
||||
<td>Unit price</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -63,41 +60,10 @@
|
||||
<div @htmx:after-request="fill">
|
||||
{{ billing_infos_form }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include "core/base/notifications.jinja" %}
|
||||
{% if settings.SITH_EBOUTIC_CB_ENABLED or (basket.total <= user.account_balance and not basket.contains_refilling_item) %}
|
||||
{# don't display the cgv form if no payment mean is available #}
|
||||
<form id="cgv-form" x-ref="cgvForm">
|
||||
{# In order to have one CGV button for both payment means,
|
||||
we have a third dummy form, containing only the cgv button,
|
||||
which validation is triggered when one of the two other forms is submitted.
|
||||
If the validation of this form fails, the submit event will be cancelled. #}
|
||||
<div class="form-group">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="cgv-checkbox"
|
||||
name="cgv"
|
||||
required
|
||||
{% if basket.is_expired %}
|
||||
disabled="disabled"
|
||||
{% else %}
|
||||
:disabled="!isCbAvailable && !isSithAvailable"
|
||||
{% endif %}
|
||||
>
|
||||
<label for="cgv-checkbox">
|
||||
{% trans trimmed %}I have read and I accept{% endtrans %}
|
||||
<a href="{{ url('core:page', 'cgv') }}">{% trans %}the general terms and conditions{% endtrans%}</a>
|
||||
{%trans%}of the student association of the UTBM{% endtrans %}
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if settings.SITH_EBOUTIC_CB_ENABLED %}
|
||||
<form
|
||||
method="post"
|
||||
id="bank-payment-form"
|
||||
action="{{ settings.SITH_EBOUTIC_ET_URL }}"
|
||||
@submit="if (!$refs.cgvForm.reportValidity()) $event.preventDefault()"
|
||||
>
|
||||
<template x-for="[key, value] in Object.entries(data)" :key="key">
|
||||
<input type="hidden" :name="key" :value="value">
|
||||
@@ -106,11 +72,7 @@
|
||||
x-cloak
|
||||
type="submit"
|
||||
id="bank-submit-button"
|
||||
{% if basket.is_expired %}
|
||||
disabled="disabled"
|
||||
{% else %}
|
||||
:disabled="!isCbAvailable"
|
||||
{% endif %}
|
||||
class="btn btn-blue"
|
||||
value="{% trans %}Pay with credit card{% endtrans %}"
|
||||
/>
|
||||
@@ -129,23 +91,9 @@
|
||||
{% elif basket.total > user.account_balance %}
|
||||
<p>{% trans %}AE account payment disabled because you do not have enough money remaining.{% endtrans %}</p>
|
||||
{% else %}
|
||||
<form
|
||||
method="post"
|
||||
action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}"
|
||||
id="sith-payment-form"
|
||||
@submit="if (!$refs.cgvForm.reportValidity()) $event.preventDefault()"
|
||||
>
|
||||
<form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form">
|
||||
{% csrf_token %}
|
||||
<input
|
||||
{% if basket.is_expired %}
|
||||
disabled="disabled"
|
||||
{% else %}
|
||||
:disabled="!isSithAvailable"
|
||||
{% endif %}
|
||||
class="btn btn-blue"
|
||||
type="submit"
|
||||
value="{% trans %}Pay with Sith account{% endtrans %}"
|
||||
/>
|
||||
<input class="btn btn-blue" type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -30,17 +30,7 @@
|
||||
{% block content %}
|
||||
<h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1>
|
||||
|
||||
<div
|
||||
id="eboutic"
|
||||
x-data="basket(
|
||||
[{%- for prices in categories -%}
|
||||
{%- for p in prices -%}
|
||||
{% if not p.sold_out %}{{ p.id }},{% endif %}
|
||||
{%- endfor -%}
|
||||
{%- endfor -%}],
|
||||
{{ last_purchase_time }},
|
||||
)"
|
||||
>
|
||||
<div id="eboutic" x-data="basket({{ last_purchase_time }})">
|
||||
<div id="basket">
|
||||
<h3>Panier</h3>
|
||||
<form method="post" action="">
|
||||
@@ -58,17 +48,6 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<template x-if="(getTotalAdded() + {{ customer_amount }}) > {{ settings.SITH_ACCOUNT_MAX_MONEY }}">
|
||||
<div class="alert alert-red">
|
||||
<div class="alert-main">
|
||||
{% trans trimmed limit=settings.SITH_ACCOUNT_MAX_MONEY %}
|
||||
You cannot purchase the current basket,
|
||||
because it would put your AE account balance
|
||||
above the {{ limit }}€ limit
|
||||
{% endtrans %}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<ul class="item-list">
|
||||
{# Starting money #}
|
||||
<li>
|
||||
@@ -120,12 +99,9 @@
|
||||
<i class="fa fa-trash"></i>
|
||||
{% trans %}Clear{% endtrans %}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-blue"
|
||||
:disabled="(getTotalAdded() + {{ customer_amount }}) > {{ settings.SITH_ACCOUNT_MAX_MONEY }}"
|
||||
>
|
||||
<button class="btn btn-blue">
|
||||
<i class="fa fa-check"></i>
|
||||
{% trans %}Validate{% endtrans %}
|
||||
<input type="submit" value="{% trans %}Validate{% endtrans %}"/>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -148,6 +124,56 @@
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<section>
|
||||
<div class="category-header">
|
||||
<h3 class="margin-bottom">{% trans %}Eurockéennes 2025 partnership{% endtrans %}</h3>
|
||||
{% if user.is_subscribed %}
|
||||
<div id="eurock-partner" style="
|
||||
min-height: 600px;
|
||||
background-color: lightgrey;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
">
|
||||
<p style="text-align: center;">
|
||||
{% trans trimmed %}
|
||||
Our partner uses Weezevent to sell tickets.
|
||||
Weezevent may collect user info according to
|
||||
its own privacy policy.
|
||||
By clicking the accept button you consent to
|
||||
their terms of services.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
|
||||
<a href="https://weezevent.com/fr/politique-de-confidentialite/">{% trans %}Privacy policy{% endtrans %}</a>
|
||||
|
||||
<button
|
||||
hx-get="{{ url("eboutic:eurock") }}"
|
||||
hx-target="#eurock-partner"
|
||||
hx-swap="outerHTML"
|
||||
hx-trigger="click, load[document.cookie.includes('weezevent_accept=true')]"
|
||||
@htmx:after-request="document.cookie = 'weezevent_accept=true'"
|
||||
>{% trans %}Accept{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>
|
||||
{%- trans trimmed %}
|
||||
You must be subscribed to benefit from the partnership with the Eurockéennes.
|
||||
{% endtrans -%}
|
||||
</p>
|
||||
<p>
|
||||
{%- trans trimmed %}
|
||||
This partnership offers a discount of up to 33%
|
||||
on tickets for Friday, Saturday and Sunday,
|
||||
as well as the 3-day package from Friday to Sunday.
|
||||
{% endtrans -%}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% for prices in categories %}
|
||||
{% set category = prices[0].product.product_type %}
|
||||
<section>
|
||||
@@ -161,15 +187,9 @@
|
||||
{% for price in prices %}
|
||||
<button
|
||||
id="{{ price.id }}"
|
||||
class="card clickable shadow"
|
||||
class="card product-button clickable shadow"
|
||||
:class="{selected: basket.some((i) => i.priceId === {{ price.id }})}"
|
||||
@click='addFromCatalog(
|
||||
{{ price.id }},
|
||||
{{ price.full_label|tojson }},
|
||||
{{ price.amount }},
|
||||
{{ (price.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING)|lower }}
|
||||
)'
|
||||
{% if price.sold_out %}disabled{% endif %}
|
||||
@click='addFromCatalog({{ price.id }}, {{ price.full_label|tojson }}, {{ price.amount }})'
|
||||
>
|
||||
{% if price.product.icon %}
|
||||
<img
|
||||
@@ -182,9 +202,6 @@
|
||||
{% endif %}
|
||||
<div class="card-content">
|
||||
<h4 class="card-title">{{ price.full_label }}</h4>
|
||||
{% if price.sold_out -%}
|
||||
<p><em>{% trans %}Product sold out{% endtrans %}</em></p>
|
||||
{%- endif %}
|
||||
<p>{{ price.amount }} €</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<a title="Logiciel billetterie en ligne"
|
||||
href="https://www.weezevent.com?c=sys_widget"
|
||||
class="weezevent-widget-integration"
|
||||
target="_blank"
|
||||
data-src="https://widget.weezevent.com/ticket/8aaba226-f7a3-4192-a64e-72ff8f5b35b7?id_evenement=1419869&locale=fr-FR&code=28747"
|
||||
data-width="650"
|
||||
data-height="600"
|
||||
data-resize="1"
|
||||
data-nopb="0"
|
||||
data-type="neo"
|
||||
data-width_auto="1"
|
||||
data-noscroll="0"
|
||||
data-id="1419869">
|
||||
Billetterie Weezevent
|
||||
</a>
|
||||
<script type="text/javascript" src="https://widget.weezevent.com/weez.js" async defer></script>
|
||||
@@ -1,19 +1,14 @@
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import freezegun
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse
|
||||
from django.test import TestCase
|
||||
from django.test.client import Client
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import localdate, now
|
||||
from django.utils.timezone import localdate
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
import eboutic.models
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import Group, User
|
||||
from counter.baker_recipes import (
|
||||
@@ -135,11 +130,9 @@ def test_eboutic_basket_expiry(
|
||||
_bulk_create=True,
|
||||
)
|
||||
|
||||
soup = BeautifulSoup(client.get(reverse("eboutic:main")).text, "lxml")
|
||||
assert (
|
||||
# remove any space from the value before asserting
|
||||
re.sub(r"\s+", "", soup.find(id="eboutic").attrs["x-data"])
|
||||
== f"basket([],{int(expected.timestamp() * 1000) if expected else 'null'},)"
|
||||
f'x-data="basket({int(expected.timestamp() * 1000) if expected else "null"})"'
|
||||
in client.get(reverse("eboutic:main")).text
|
||||
)
|
||||
|
||||
|
||||
@@ -238,66 +231,26 @@ class TestEboutic(TestCase):
|
||||
|
||||
def test_add_forbidden_product(self):
|
||||
self.client.force_login(self.new_customer)
|
||||
for product in self.beer, self.cotiz, self.not_in_counter:
|
||||
response = self.submit_basket([BasketItem(product.id, 1)])
|
||||
response = self.submit_basket([BasketItem(self.beer.id, 1)])
|
||||
assert response.status_code == 200
|
||||
assert not Basket.objects.exists()
|
||||
assert Basket.objects.first() is None
|
||||
|
||||
def test_sold_out_product(self):
|
||||
sold_out = product_recipe.make(
|
||||
clic_limit=3, counters=[self.eboutic], product_type=baker.make(ProductType)
|
||||
)
|
||||
price = price_recipe.make(product=sold_out, groups=[self.group_cotiz], amount=0)
|
||||
sale_recipe.make(
|
||||
product=sold_out,
|
||||
customer=self.subscriber.customer,
|
||||
unit_price=0,
|
||||
quantity=1,
|
||||
)
|
||||
baker.make(
|
||||
eboutic.models.BasketItem,
|
||||
basket=baker.make(Basket),
|
||||
product=sold_out,
|
||||
quantity=2,
|
||||
)
|
||||
self.client.force_login(self.subscriber)
|
||||
response = self.submit_basket([BasketItem(price.id, 1)])
|
||||
response = self.submit_basket([BasketItem(self.cotiz.id, 1)])
|
||||
assert response.status_code == 200
|
||||
assert Basket.objects.count() == 1
|
||||
with freezegun.freeze_time(
|
||||
now()
|
||||
+ settings.SITH_EBOUTIC_BASKET_TIMEOUT
|
||||
+ settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT
|
||||
):
|
||||
# after a while, unpaid basket items should expire and make the
|
||||
# product available again.
|
||||
response = self.submit_basket([BasketItem(price.id, 1)])
|
||||
assertRedirects(
|
||||
response,
|
||||
reverse("eboutic:checkout", kwargs={"basket_id": Basket.objects.last().id}),
|
||||
)
|
||||
assert Basket.objects.count() == 2
|
||||
assert Basket.objects.first() is None
|
||||
|
||||
def test_refill_limit(self):
|
||||
"""Test that an eboutic basket cannot refill an account above the limit."""
|
||||
self.client.force_login(self.subscriber)
|
||||
product = product_recipe.make(
|
||||
product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING,
|
||||
counters=[self.eboutic],
|
||||
)
|
||||
price = price_recipe.make(
|
||||
product=product,
|
||||
groups=[self.group_cotiz],
|
||||
amount=settings.SITH_ACCOUNT_MAX_MONEY // 10,
|
||||
)
|
||||
response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)])
|
||||
assert response.status_code == 200
|
||||
assert Basket.objects.first() is None
|
||||
|
||||
response = self.submit_basket([BasketItem(price.id, 10)])
|
||||
assert Basket.objects.count() == 1
|
||||
assertRedirects(response, reverse("eboutic:checkout", kwargs={"basket_id": 1}))
|
||||
self.client.force_login(self.new_customer)
|
||||
response = self.submit_basket([BasketItem(self.cotiz.id, 1)])
|
||||
assert response.status_code == 200
|
||||
assert Basket.objects.first() is None
|
||||
|
||||
response = self.submit_basket([BasketItem(price.id, 11)])
|
||||
assert Basket.objects.count() == 1
|
||||
assert response.status_code == 200 # no redirect = form validation failed
|
||||
response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)])
|
||||
assert response.status_code == 200
|
||||
assert Basket.objects.first() is None
|
||||
|
||||
def test_create_basket(self):
|
||||
self.client.force_login(self.new_customer)
|
||||
|
||||
@@ -37,9 +37,12 @@ class TestBillingInfo:
|
||||
|
||||
def test_edit_infos(self, client: Client, payload: dict[str, str]):
|
||||
user = subscriber_user.make()
|
||||
baker.make(BillingInfo, customer=user.customer, phone_number="06 01 02 03 04")
|
||||
baker.make(BillingInfo, customer=user.customer)
|
||||
client.force_login(user)
|
||||
response = client.post(reverse("eboutic:billing_infos"), payload)
|
||||
response = client.post(
|
||||
reverse("eboutic:billing_infos"),
|
||||
payload,
|
||||
)
|
||||
user.refresh_from_db()
|
||||
infos = BillingInfo.objects.get(customer__user=user)
|
||||
assert response.status_code == 302
|
||||
|
||||
@@ -3,7 +3,6 @@ import urllib
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import freezegun
|
||||
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||
from cryptography.hazmat.primitives.hashes import SHA1
|
||||
@@ -18,7 +17,7 @@ from pytest_django.asserts import assertRedirects
|
||||
|
||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||
from counter.baker_recipes import price_recipe, product_recipe
|
||||
from counter.models import Product, ProductType, Refilling, Selling
|
||||
from counter.models import Product, ProductType, Selling
|
||||
from counter.tests.test_counter import force_refill_user
|
||||
from eboutic.models import Basket, BasketItem
|
||||
|
||||
@@ -106,7 +105,7 @@ class TestPaymentSith(TestPaymentBase):
|
||||
),
|
||||
reverse("eboutic:payment_result", kwargs={"result": "success"}),
|
||||
)
|
||||
assert not Basket.objects.filter(id=self.basket.id).exists()
|
||||
assert Basket.objects.filter(id=self.basket.id).first() is None
|
||||
self.customer.customer.refresh_from_db()
|
||||
assert self.customer.customer.amount == Decimal(1)
|
||||
|
||||
@@ -140,7 +139,10 @@ class TestPaymentSith(TestPaymentBase):
|
||||
assert len(messages) == 1
|
||||
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
|
||||
assert messages[0].message == "Solde insuffisant"
|
||||
assert not Basket.objects.filter(id=self.basket.id).exists()
|
||||
|
||||
assert Basket.objects.contains(self.basket), (
|
||||
"After an unsuccessful request, the basket should be kept"
|
||||
)
|
||||
|
||||
def test_refilling_in_basket(self):
|
||||
BasketItem.from_price(self.refilling.prices.first(), 1, self.basket).save()
|
||||
@@ -155,7 +157,7 @@ class TestPaymentSith(TestPaymentBase):
|
||||
response,
|
||||
reverse("eboutic:payment_result", kwargs={"result": "failure"}),
|
||||
)
|
||||
assert not Basket.objects.filter(id=self.basket.id).exists()
|
||||
assert Basket.objects.filter(id=self.basket.id).first() is not None
|
||||
messages = list(get_messages(response.wsgi_request))
|
||||
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
|
||||
assert (
|
||||
@@ -165,24 +167,6 @@ class TestPaymentSith(TestPaymentBase):
|
||||
self.customer.customer.refresh_from_db()
|
||||
assert self.customer.customer.amount == initial_account_balance
|
||||
|
||||
def test_basket_expired(self):
|
||||
self.client.force_login(self.customer)
|
||||
initial_account_balance = self.customer.customer.amount
|
||||
with freezegun.freeze_time(settings.SITH_EBOUTIC_BASKET_TIMEOUT):
|
||||
response = self.client.post(
|
||||
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
|
||||
)
|
||||
assertRedirects(
|
||||
response,
|
||||
reverse("eboutic:payment_result", kwargs={"result": "failure"}),
|
||||
)
|
||||
messages = list(get_messages(response.wsgi_request))
|
||||
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
|
||||
assert messages[0].message == "Panier expiré"
|
||||
assert not Basket.objects.filter(id=self.basket.id).exists()
|
||||
self.customer.customer.refresh_from_db()
|
||||
assert self.customer.customer.amount == initial_account_balance
|
||||
|
||||
|
||||
class TestPaymentCard(TestPaymentBase):
|
||||
def generate_bank_valid_answer(self, basket: Basket):
|
||||
@@ -252,10 +236,6 @@ class TestPaymentCard(TestPaymentBase):
|
||||
|
||||
self.customer.customer.refresh_from_db()
|
||||
assert self.customer.customer.amount == price.amount * 2
|
||||
refill = self.customer.customer.refillings.last()
|
||||
assert refill is not None
|
||||
assert refill.amount == price.amount * 2
|
||||
assert refill.payment_method == Refilling.PaymentMethod.CARD
|
||||
|
||||
def test_multiple_responses(self):
|
||||
bank_response = self.generate_bank_valid_answer(self.basket)
|
||||
|
||||
@@ -31,6 +31,7 @@ from eboutic.views import (
|
||||
EbouticMainView,
|
||||
EbouticPayWithSith,
|
||||
EtransactionAutoAnswer,
|
||||
EurockPartnerFragment,
|
||||
payment_result,
|
||||
)
|
||||
|
||||
@@ -50,4 +51,5 @@ urlpatterns = [
|
||||
EtransactionAutoAnswer.as_view(),
|
||||
name="etransation_autoanswer",
|
||||
),
|
||||
path("eurock/", EurockPartnerFragment.as_view(), name="eurock"),
|
||||
]
|
||||
|
||||
+36
-41
@@ -33,21 +33,19 @@ from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||
from django.db import DatabaseError, transaction
|
||||
from django.db.models import Exists, OuterRef, Subquery
|
||||
from django.db.models import Subquery
|
||||
from django.db.models.fields import forms
|
||||
from django.db.utils import cached_property
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.formats import localize
|
||||
from django.utils.timezone import localtime
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_GET
|
||||
from django.views.generic import DetailView, FormView, UpdateView, View
|
||||
from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View
|
||||
from django.views.generic.edit import SingleObjectMixin
|
||||
from django_countries.fields import Country
|
||||
|
||||
from core.auth.mixins import CanViewMixin
|
||||
from core.auth.mixins import CanViewMixin, IsSubscriberMixin
|
||||
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
||||
from counter.forms import BaseBasketForm, BasketItemForm, BillingInfoForm
|
||||
from counter.models import (
|
||||
@@ -58,7 +56,7 @@ from counter.models import (
|
||||
Selling,
|
||||
get_eboutic,
|
||||
)
|
||||
from eboutic.models import Basket, BasketItem, Invoice, InvoiceItem
|
||||
from eboutic.models import Basket, BasketItem, BillingInfoState, Invoice, InvoiceItem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
||||
@@ -66,7 +64,9 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class BaseEbouticBasketForm(BaseBasketForm):
|
||||
min_result_balance = None # user can pay by card, so no minimum enforced
|
||||
def _check_enough_money(self, *args, **kwargs):
|
||||
# Disable money check
|
||||
...
|
||||
|
||||
|
||||
EbouticBasketForm = forms.formset_factory(
|
||||
@@ -86,15 +86,13 @@ class EbouticMainView(LoginRequiredMixin, FormView):
|
||||
form_class = EbouticBasketForm
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["form_kwargs"] = {
|
||||
"customer": self.customer,
|
||||
"counter": get_eboutic(),
|
||||
"form_kwargs": {
|
||||
"allowed_prices": {
|
||||
price.id: price for price in self.prices if not price.sold_out
|
||||
}
|
||||
},
|
||||
"allowed_prices": {price.id: price for price in self.prices},
|
||||
}
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, formset):
|
||||
if len(formset) == 0:
|
||||
@@ -118,14 +116,9 @@ class EbouticMainView(LoginRequiredMixin, FormView):
|
||||
|
||||
@cached_property
|
||||
def prices(self) -> list[Price]:
|
||||
eboutic = get_eboutic()
|
||||
sold_out_subquery = ~Exists(
|
||||
eboutic.products.under_clic_limit().filter(id=OuterRef("product_id"))
|
||||
)
|
||||
return list(
|
||||
eboutic.get_prices_for(self.customer)
|
||||
.annotate(sold_out=sold_out_subquery)
|
||||
.order_by("product__product_type__order", "product_id", "amount")
|
||||
return get_eboutic().get_prices_for(
|
||||
self.customer,
|
||||
order_by=["product__product_type__order", "product_id", "amount"],
|
||||
)
|
||||
|
||||
@cached_property
|
||||
@@ -185,7 +178,7 @@ def payment_result(request, result: str) -> HttpResponse:
|
||||
class BillingInfoFormFragment(
|
||||
LoginRequiredMixin, FragmentMixin, SuccessMessageMixin, UpdateView
|
||||
):
|
||||
"""Update or create billing info"""
|
||||
"""Update billing info"""
|
||||
|
||||
model = BillingInfo
|
||||
form_class = BillingInfoForm
|
||||
@@ -194,7 +187,9 @@ class BillingInfoFormFragment(
|
||||
|
||||
def get_initial(self):
|
||||
if self.object is None:
|
||||
return {"country": Country(code="FR")}
|
||||
return {
|
||||
"country": Country(code="FR"),
|
||||
}
|
||||
return {}
|
||||
|
||||
def render_fragment(self, request, **kwargs) -> SafeString:
|
||||
@@ -216,13 +211,24 @@ class BillingInfoFormFragment(
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["billing_infos_state"] = BillingInfoState.from_model(self.object)
|
||||
kwargs["action"] = reverse("eboutic:billing_infos")
|
||||
if not self.object:
|
||||
match BillingInfoState.from_model(self.object):
|
||||
case BillingInfoState.EMPTY:
|
||||
messages.warning(
|
||||
self.request,
|
||||
_(
|
||||
"You must fill your billing infos "
|
||||
"if you want to pay with your credit card"
|
||||
"You must fill your billing infos if you want to pay with your credit card"
|
||||
),
|
||||
)
|
||||
case BillingInfoState.MISSING_PHONE_NUMBER:
|
||||
messages.warning(
|
||||
self.request,
|
||||
_(
|
||||
"The Crédit Agricole changed its policy related to the billing "
|
||||
+ "information that must be provided in order to pay with a credit card. "
|
||||
+ "If you want to pay with your credit card, you must add a phone number "
|
||||
+ "to the data you already provided.",
|
||||
),
|
||||
)
|
||||
return kwargs
|
||||
@@ -249,15 +255,6 @@ class EbouticCheckout(CanViewMixin, UseFragmentsMixin, DetailView):
|
||||
kwargs["customer_amount"] = None
|
||||
kwargs["billing_infos"] = {}
|
||||
|
||||
if self.object.is_expired:
|
||||
messages.error(self.request, _("Basket expired"))
|
||||
else:
|
||||
timeout = self.object.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT
|
||||
messages.warning(
|
||||
self.request,
|
||||
_("Basket available until %(until)s")
|
||||
% {"until": localize(localtime(timeout).time())},
|
||||
)
|
||||
with contextlib.suppress(BillingInfo.DoesNotExist):
|
||||
kwargs["billing_infos"] = json.dumps(
|
||||
dict(self.object.get_e_transaction_data())
|
||||
@@ -271,14 +268,9 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
basket = self.get_object()
|
||||
if basket.is_expired:
|
||||
messages.error(self.request, _("Basket expired"))
|
||||
basket.delete()
|
||||
return redirect("eboutic:payment_result", "failure")
|
||||
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
||||
if basket.items.filter(product__product_type_id=refilling).exists():
|
||||
messages.error(self.request, _("You can't buy a refilling with sith money"))
|
||||
basket.delete()
|
||||
return redirect("eboutic:payment_result", "failure")
|
||||
|
||||
eboutic = get_eboutic()
|
||||
@@ -296,7 +288,6 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
|
||||
except DatabaseError as e:
|
||||
sentry_sdk.capture_exception(e)
|
||||
except ValidationError as e:
|
||||
basket.delete()
|
||||
messages.error(self.request, e.message)
|
||||
return redirect("eboutic:payment_result", "failure")
|
||||
|
||||
@@ -359,3 +350,7 @@ class EtransactionAutoAnswer(View):
|
||||
return HttpResponse(
|
||||
"Payment failed with error: " + request.GET["Error"], status=202
|
||||
)
|
||||
|
||||
|
||||
class EurockPartnerFragment(IsSubscriberMixin, TemplateView):
|
||||
template_name = "eboutic/eurock_fragment.jinja"
|
||||
|
||||
+30
-138
@@ -1,18 +1,6 @@
|
||||
from datetime import timedelta
|
||||
from itertools import groupby, islice
|
||||
from operator import attrgetter
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.forms.models import ModelChoiceIterator, ModelChoiceIteratorValue
|
||||
from django.utils.timezone import localdate, localtime
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from club.forms import ClubRoleChoiceField
|
||||
from club.models import ClubRole, Membership
|
||||
from club.widgets.ajax_select import AutoCompleteSelectMultipleClub
|
||||
from core.models import User
|
||||
from core.views.forms import SelectDateTime
|
||||
from core.views.widgets.ajax_select import (
|
||||
@@ -91,19 +79,26 @@ class VoteForm(forms.Form):
|
||||
class RoleForm(forms.ModelForm):
|
||||
"""Form for creating a role."""
|
||||
|
||||
required_css_class = "required"
|
||||
error_css_class = "error"
|
||||
|
||||
class Meta:
|
||||
model = Role
|
||||
fields = ["club_role", "title", "description", "max_choice"]
|
||||
field_classes = {"club_role": ClubRoleChoiceField}
|
||||
fields = ["title", "election", "description", "max_choice"]
|
||||
widgets = {"election": AutoCompleteSelect}
|
||||
|
||||
def __init__(self, *args, election: Election, **kwargs):
|
||||
def __init__(self, *args, **kwargs):
|
||||
election_id = kwargs.pop("election_id", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.instance.election = election
|
||||
self.fields["club_role"].queryset = ClubRole.objects.filter(
|
||||
is_board=True, club__in=election.clubs.all()
|
||||
if election_id:
|
||||
self.fields["election"].queryset = Election.objects.filter(
|
||||
id=election_id
|
||||
).all()
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
title = cleaned_data.get("title")
|
||||
election = cleaned_data.get("election")
|
||||
if Role.objects.filter(title=title, election=election).exists():
|
||||
raise forms.ValidationError(
|
||||
_("This role already exists for this election"), code="invalid"
|
||||
)
|
||||
|
||||
|
||||
@@ -113,21 +108,21 @@ class ElectionListForm(forms.ModelForm):
|
||||
fields = ("title", "election")
|
||||
widgets = {"election": AutoCompleteSelect}
|
||||
|
||||
def __init__(self, *args, election: Election, **kwargs):
|
||||
def __init__(self, *args, **kwargs):
|
||||
election_id = kwargs.pop("election_id", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.instance.election = election
|
||||
if election_id:
|
||||
self.fields["election"].queryset = Election.objects.filter(
|
||||
id=election_id
|
||||
).all()
|
||||
|
||||
|
||||
class ElectionForm(forms.ModelForm):
|
||||
required_css_class = "required"
|
||||
error_css_class = "error"
|
||||
|
||||
class Meta:
|
||||
model = Election
|
||||
fields = [
|
||||
"title",
|
||||
"description",
|
||||
"clubs",
|
||||
"archived",
|
||||
"start_candidature",
|
||||
"end_candidature",
|
||||
@@ -139,124 +134,21 @@ class ElectionForm(forms.ModelForm):
|
||||
"candidature_groups",
|
||||
]
|
||||
widgets = {
|
||||
"clubs": AutoCompleteSelectMultipleClub,
|
||||
"edit_groups": AutoCompleteSelectMultipleGroup,
|
||||
"view_groups": AutoCompleteSelectMultipleGroup,
|
||||
"vote_groups": AutoCompleteSelectMultipleGroup,
|
||||
"candidature_groups": AutoCompleteSelectMultipleGroup,
|
||||
"start_date": SelectDateTime,
|
||||
"end_date": SelectDateTime,
|
||||
"start_candidature": SelectDateTime,
|
||||
"end_candidature": SelectDateTime,
|
||||
}
|
||||
|
||||
|
||||
class ElectionCreateForm(ElectionForm):
|
||||
"""ElectionForm, but specifically for creation."""
|
||||
|
||||
def __init__(self, *args, initial: dict | None = None, **kwargs):
|
||||
# propose sound default timestamps :
|
||||
# start of candidatures at tomorrow 00h01, start of votes a week later.
|
||||
start = localtime().replace(hour=0, minute=1, second=0) + timedelta(days=1)
|
||||
default_initial = {
|
||||
"start_candidature": start,
|
||||
"end_candidature": start + timedelta(days=7, minutes=-2), # 23h59
|
||||
"start_date": start + timedelta(days=7), # 00h01
|
||||
"end_date": start + timedelta(days=14, minutes=-2), # 23h59
|
||||
"view_groups": [settings.SITH_GROUP_PUBLIC_ID],
|
||||
"vote_groups": [settings.SITH_GROUP_SUBSCRIBERS_ID],
|
||||
"candidature_groups": [settings.SITH_GROUP_SUBSCRIBERS_ID],
|
||||
}
|
||||
if initial:
|
||||
default_initial.update(initial)
|
||||
super().__init__(*args, initial=default_initial, **kwargs)
|
||||
|
||||
def save(self, commit=True): # noqa: FBT002
|
||||
instance = super().save(commit=commit)
|
||||
if commit:
|
||||
ElectionList.objects.create(title="Candidat⸱e libre", election=instance)
|
||||
return instance
|
||||
|
||||
|
||||
class ElectionWinnerChoiceIterator(ModelChoiceIterator):
|
||||
"""Iterate over the candidates that gathered enough votes"""
|
||||
|
||||
def __iter__(self):
|
||||
# for each role, yield only the N first candidates,
|
||||
# where N is the election role max_choice
|
||||
qs = (
|
||||
self.queryset.annotate(nb_votes=Count("votes"))
|
||||
.order_by("role__order", "-nb_votes")
|
||||
.select_related("role", "user", "role__club_role", "role__club_role__club")
|
||||
start_date = forms.DateTimeField(
|
||||
label=_("Start date"), widget=SelectDateTime, required=True
|
||||
)
|
||||
yield from (
|
||||
(
|
||||
f"{role.title} \u2013 {role.club_role.club.name}",
|
||||
[self.choice(cand) for cand in islice(candidates, role.max_choice)],
|
||||
end_date = forms.DateTimeField(
|
||||
label=_("End date"), widget=SelectDateTime, required=True
|
||||
)
|
||||
for role, candidates in groupby(qs, key=attrgetter("role"))
|
||||
start_candidature = forms.DateTimeField(
|
||||
label=_("Start candidature"), widget=SelectDateTime, required=True
|
||||
)
|
||||
|
||||
def choice(self, obj: Candidature):
|
||||
return (
|
||||
ModelChoiceIteratorValue(self.field.prepare_value(obj), obj),
|
||||
obj.user.get_full_name(),
|
||||
end_candidature = forms.DateTimeField(
|
||||
label=_("End candidature"), widget=SelectDateTime, required=True
|
||||
)
|
||||
|
||||
|
||||
class ElectionWinnerChoiceField(forms.ModelMultipleChoiceField):
|
||||
"""Custom `ModelChoiceField` for `[ClubRole][club.models.ClubRole]`.
|
||||
|
||||
If only one club is involved, behave like the base `ModelChoiceField`.
|
||||
If dealing with the roles of multiple clubs, group the roles
|
||||
into a different `optgroup` for each club.
|
||||
"""
|
||||
|
||||
iterator = ElectionWinnerChoiceIterator
|
||||
widget = forms.CheckboxSelectMultiple
|
||||
|
||||
|
||||
class ApplyElectionResultForm(forms.Form):
|
||||
"""Form to select winners of an election, and automatically apply the results."""
|
||||
|
||||
candidates = ElectionWinnerChoiceField(Candidature.objects.none())
|
||||
|
||||
def __init__(self, *args, election: Election, **kwargs):
|
||||
self.election = election
|
||||
super().__init__(*args, **kwargs)
|
||||
qs = Candidature.objects.filter(
|
||||
role__election=election, role__club_role__isnull=False
|
||||
)
|
||||
# pass all candidates to the ModelChoiceField ;
|
||||
# its inner choice iterator will take care of filtering only the winners.
|
||||
self.fields["candidates"].queryset = qs
|
||||
# By default, mark every candidate as selected.
|
||||
# Election results are usually completely validated during the AG,
|
||||
# so it makes more sense UX-wise to eventually unselect a candidate
|
||||
# than to select everyone.
|
||||
self.fields["candidates"].initial = qs.values_list("id", flat=True)
|
||||
|
||||
def save(self):
|
||||
if self.errors:
|
||||
return
|
||||
candidates: list[Candidature] = list(self.cleaned_data["candidates"])
|
||||
with transaction.atomic():
|
||||
Membership.objects.filter(
|
||||
role__in=[c.role.club_role for c in candidates],
|
||||
end_date=None,
|
||||
start_date__lt=self.election.end_date,
|
||||
).update(end_date=localdate())
|
||||
memberships = [
|
||||
Membership(
|
||||
user_id=c.user_id,
|
||||
club_id=c.role.club_role.club_id,
|
||||
role=c.role.club_role,
|
||||
description=(
|
||||
c.role.title if c.role.title != c.role.club_role.name else ""
|
||||
),
|
||||
)
|
||||
for c in candidates
|
||||
]
|
||||
Membership.objects.bulk_create(memberships)
|
||||
Membership._add_club_groups(memberships)
|
||||
return memberships
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# Generated by Django 5.2.14 on 2026-05-30 20:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("club", "0017_linktype_clublink"),
|
||||
("election", "0005_alter_candidature_program_alter_candidature_user"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="election",
|
||||
name="clubs",
|
||||
field=models.ManyToManyField(
|
||||
help_text="The club(s) this election is held for.",
|
||||
related_name="elections",
|
||||
to="club.club",
|
||||
verbose_name="clubs",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="role",
|
||||
name="club_role",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text=(
|
||||
"A club role. Filling this will allow automatic "
|
||||
"completion of title and description, "
|
||||
"and automatic assignation after the elections."
|
||||
),
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="election_roles",
|
||||
to="club.clubrole",
|
||||
verbose_name="club role",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="role",
|
||||
name="description",
|
||||
field=models.TextField(blank=True, default="", verbose_name="description"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="role",
|
||||
name="max_choice",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
default=1, verbose_name="max choice"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="role",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("title", "election"),
|
||||
name="title_election_unique_constraint",
|
||||
violation_error_code="invalid",
|
||||
violation_error_message="This role already exists for this election",
|
||||
),
|
||||
),
|
||||
]
|
||||
+5
-46
@@ -5,7 +5,6 @@ from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from ordered_model.models import OrderedModel
|
||||
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.models import Group, User
|
||||
|
||||
|
||||
@@ -14,12 +13,6 @@ class Election(models.Model):
|
||||
|
||||
title = models.CharField(_("title"), max_length=255)
|
||||
description = models.TextField(_("description"), null=True, blank=True)
|
||||
clubs = models.ManyToManyField(
|
||||
Club,
|
||||
related_name="elections",
|
||||
verbose_name=_("clubs"),
|
||||
help_text=_("The club(s) this election is held for."),
|
||||
)
|
||||
start_candidature = models.DateTimeField(_("start candidature"), blank=False)
|
||||
end_candidature = models.DateTimeField(_("end candidature"), blank=False)
|
||||
start_date = models.DateTimeField(_("start date"), blank=False)
|
||||
@@ -101,18 +94,9 @@ class Election(models.Model):
|
||||
results[role.title] = role.results(total_vote)
|
||||
return results
|
||||
|
||||
@cached_property
|
||||
def results_applied(self) -> bool:
|
||||
"""Returns True if one or more roles of this election have been applied."""
|
||||
return Membership.objects.filter(
|
||||
role__election_roles__election=self,
|
||||
end_date=None,
|
||||
start_date__gte=self.end_date,
|
||||
).exists()
|
||||
|
||||
|
||||
class Role(OrderedModel):
|
||||
"""This class allows to create a new role available for a candidature."""
|
||||
"""This class allows to create a new role avaliable for a candidature."""
|
||||
|
||||
election = models.ForeignKey(
|
||||
Election,
|
||||
@@ -121,42 +105,17 @@ class Role(OrderedModel):
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
title = models.CharField(_("title"), max_length=255)
|
||||
description = models.TextField(_("description"), default="", blank=True)
|
||||
max_choice = models.PositiveSmallIntegerField(_("max choice"), default=1)
|
||||
club_role = models.ForeignKey(
|
||||
ClubRole,
|
||||
related_name="election_roles",
|
||||
verbose_name=_("club role"),
|
||||
help_text=_(
|
||||
"A club role. Filling this will allow automatic "
|
||||
"completion of title and description, "
|
||||
"and automatic assignation after the elections."
|
||||
),
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
order_with_respect_to = "election"
|
||||
|
||||
class Meta(OrderedModel.Meta):
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["title", "election"],
|
||||
name="title_election_unique_constraint",
|
||||
violation_error_message=_("This role already exists for this election"),
|
||||
violation_error_code="invalid",
|
||||
)
|
||||
]
|
||||
description = models.TextField(_("description"), null=True, blank=True)
|
||||
max_choice = models.IntegerField(_("max choice"), default=1)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} - {self.election.title}"
|
||||
|
||||
def results(self, total_vote: int) -> dict[str, dict[str, int | float]]:
|
||||
if total_vote == 0:
|
||||
candidates = self.candidatures.values_list("user__username", flat=True)
|
||||
candidates = self.candidatures.values_list("user__username")
|
||||
return {
|
||||
key: {"vote": 0, "percent": 0} for key in ["blank vote", *candidates]
|
||||
key: {"vote": 0, "percent": 0} for key in ["blank_votes", *candidates]
|
||||
}
|
||||
total_vote *= self.max_choice
|
||||
results = {"total vote": total_vote}
|
||||
|
||||
@@ -30,24 +30,12 @@
|
||||
{%- else %}
|
||||
{% trans %}Polls will open {% endtrans %}
|
||||
<time datetime="{{ election.start_date }}">{{ election.start_date|localtime|date(DATETIME_FORMAT)}}</time>
|
||||
{% trans %}at{% endtrans %}
|
||||
<time>{{ election.start_date|localtime|time(DATETIME_FORMAT) }}</time>
|
||||
{% trans %} at {% endtrans %}<time>{{ election.start_date|localtime|time(DATETIME_FORMAT)}}</time>
|
||||
{% trans %}and will close {% endtrans %}
|
||||
{%- endif %}
|
||||
<time datetime="{{ election.end_date }}">{{ election.end_date|localtime|date(DATETIME_FORMAT)}}</time>
|
||||
{% trans %}at{% endtrans %}
|
||||
<time>{{ election.end_date|localtime|time(DATETIME_FORMAT) }}</time>
|
||||
{% trans %} at {% endtrans %}<time>{{ election.end_date|localtime|time(DATETIME_FORMAT)}}</time>
|
||||
</p>
|
||||
{%- if election.is_vote_finished and user.can_edit(election) %}
|
||||
<details class="accordion" name="apply-result">
|
||||
<summary>{% trans %}Apply election result{% endtrans %}</summary>
|
||||
<div
|
||||
class="accordion-content aria-busy-grow"
|
||||
hx-get="{{ url("election:apply_result", election_id=election.id) }}"
|
||||
hx-trigger="toggle from:closest details once"
|
||||
></div>
|
||||
</details>
|
||||
{% endif %}
|
||||
{%- if user_has_voted %}
|
||||
<p class="election__elector-infos">
|
||||
{%- if election.is_vote_active %}
|
||||
@@ -59,27 +47,17 @@
|
||||
{%- endif %}
|
||||
</section>
|
||||
<section class="election_vote">
|
||||
<form
|
||||
action="{{ url('election:vote', election.id) }}"
|
||||
method="post"
|
||||
class="election__vote-form"
|
||||
name="vote-form"
|
||||
id="vote-form"
|
||||
>
|
||||
<form action="{{ url('election:vote', election.id) }}" method="post" class="election__vote-form" name="vote-form" id="vote-form">
|
||||
{% csrf_token %}
|
||||
<table class="election_table">
|
||||
<thead class="lists">
|
||||
<tr>
|
||||
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">
|
||||
{% trans %}Blank vote{% endtrans %}
|
||||
</th>
|
||||
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">{% trans %}Blank vote{% endtrans %}</th>
|
||||
{%- for election_list in election_lists %}
|
||||
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">
|
||||
<span>{{ election_list.title }}</span>
|
||||
{% if user.can_edit(election_list) and election.is_vote_editable -%}
|
||||
<a href="{{ url('election:delete_list', list_id=election_list.id) }}">
|
||||
<i class="fa-regular fa-trash-can delete-action"></i>
|
||||
</a>
|
||||
<a href="{{ url('election:delete_list', list_id=election_list.id) }}"><i class="fa-regular fa-trash-can delete-action"></i></a>
|
||||
{% endif %}
|
||||
</th>
|
||||
{%- endfor %}
|
||||
@@ -125,45 +103,22 @@
|
||||
<button disabled><i class="fa fa-arrow-down"></i></button>
|
||||
<button disabled><i class="fa fa-caret-down"></i></button>
|
||||
{%- else -%}
|
||||
<button
|
||||
type="button"
|
||||
onclick="window.location.replace('?role={{ role.id }}&action=bottom');"
|
||||
>
|
||||
<i class="fa fa-arrow-down"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick="window.location.replace('?role={{ role.id }}&action=down');"
|
||||
>
|
||||
<i class="fa fa-caret-down"></i>
|
||||
</button>
|
||||
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=bottom');"><i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=down');"><i class="fa fa-caret-down"></i></button>
|
||||
{%- endif -%}
|
||||
{%- if loop.first -%}
|
||||
<button disabled><i class="fa fa-caret-up"></i></button>
|
||||
<button disabled><i class="fa fa-arrow-up"></i></button>
|
||||
{%- else -%}
|
||||
<button
|
||||
type="button"
|
||||
onclick="window.location.replace('?role={{ role.id }}&action=up');"
|
||||
>
|
||||
<i
|
||||
class="fa fa-caret-up"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick="window.location.replace('?role={{ role.id }}&action=top');"
|
||||
><i class="fa fa-arrow-up"></i>
|
||||
</button>
|
||||
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=up');"><i class="fa fa-caret-up"></i></button>
|
||||
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=top');"><i class="fa fa-arrow-up"></i></button>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
{%- endif -%}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="role_candidates">
|
||||
<td
|
||||
class="list_per_role"
|
||||
style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%"
|
||||
>
|
||||
<td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%">
|
||||
{%- if role.max_choice == 1 and show_vote_buttons %}
|
||||
<div class="radio-btn">
|
||||
{% set input_id = "blank_vote_" + role.id|string %}
|
||||
@@ -176,46 +131,26 @@
|
||||
{%- if election.is_vote_finished %}
|
||||
{%- set results = election_results[role.title]['blank vote'] %}
|
||||
<div class="election__results">
|
||||
<strong>
|
||||
{{ results.vote }} {% trans %}votes{% endtrans %} ({{ "%.2f" % results.percent }} %)
|
||||
</strong>
|
||||
<strong>{{ results.vote }} {% trans %}votes{% endtrans %} ({{ "%.2f" % results.percent }} %)</strong>
|
||||
</div>
|
||||
{%- endif %}
|
||||
</td>
|
||||
{%- for election_list in election_lists %}
|
||||
<td
|
||||
class="list_per_role"
|
||||
style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%"
|
||||
>
|
||||
<td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%">
|
||||
<ul class="candidates">
|
||||
{%- for candidature in election_list.candidatures.select_related("user", "user__profile_pict").filter(role=role) %}
|
||||
<li class="candidate">
|
||||
{%- if show_vote_buttons %}
|
||||
{% set input_id = "candidature_" + candidature.id|string %}
|
||||
<input
|
||||
id="{{ input_id }}"
|
||||
type="{{ 'checkbox' if role.max_choice > 1 else 'radio' }}"
|
||||
{% if candidature.id|string in role_data %}checked{% endif %}
|
||||
{% if user_has_voted %}disabled{% endif %}
|
||||
name="{{ role.title }}"
|
||||
value="{{ candidature.id }}"
|
||||
>
|
||||
<input id="{{ input_id }}" type="{{ 'checkbox' if role.max_choice > 1 else 'radio' }}" {{ 'checked' if candidature.id|string in role_data else '' }} {{ 'disabled' if user_has_voted else '' }} name="{{ role.title }}" value="{{ candidature.id }}">
|
||||
<label for="{{ input_id }}">
|
||||
{%- endif %}
|
||||
<figure>
|
||||
{%- if user.can_view(candidature.user) %}
|
||||
{% if candidature.user.profile_pict %}
|
||||
<img
|
||||
class="candidate__picture"
|
||||
src="{{ candidature.user.profile_pict.get_download_url() }}"
|
||||
alt="{% trans %}Profile{% endtrans %}"
|
||||
>
|
||||
<img class="candidate__picture" src="{{ candidature.user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}">
|
||||
{% else %}
|
||||
<img
|
||||
class="candidate__picture"
|
||||
src="{{ static('core/img/unknown.jpg') }}"
|
||||
alt="{% trans %}Profile{% endtrans %}"
|
||||
>
|
||||
<img class="candidate__picture" src="{{ static('core/img/unknown.jpg') }}" alt="{% trans %}Profile{% endtrans %}">
|
||||
{% endif %}
|
||||
{%- endif %}
|
||||
<figcaption class="candidate__details">
|
||||
@@ -229,12 +164,8 @@
|
||||
{%- if user.can_edit(candidature) -%}
|
||||
{%- if election.is_vote_editable -%}
|
||||
<div class="edit_btns">
|
||||
<a href="{{ url('election:update_candidate', candidature_id=candidature.id) }}">
|
||||
<i class="fa-regular fa-pen-to-square edit-action"></i>
|
||||
</a>
|
||||
<a href="{{ url('election:delete_candidate', candidature_id=candidature.id) }}">
|
||||
<i class="fa-regular fa-trash-can delete-action"></i>
|
||||
</a>
|
||||
<a href="{{url('election:update_candidate', candidature_id=candidature.id)}}"><i class="fa-regular fa-pen-to-square edit-action"></i>️</a>
|
||||
<a href="{{url('election:delete_candidate', candidature_id=candidature.id)}}"><i class="fa-regular fa-trash-can delete-action"></i></a>
|
||||
</div>
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
{% block head %}
|
||||
{{ super() -}}
|
||||
<style>
|
||||
<style type="text/css">
|
||||
small {
|
||||
font-size: smaller;
|
||||
}
|
||||
@@ -20,9 +20,6 @@
|
||||
|
||||
{% block content %}
|
||||
<h3>{% trans %}Current elections{% endtrans %}</h3>
|
||||
<a class="btn btn-blue" href="{{ url("election:create") }}">
|
||||
<i class="fa fa-plus"></i>{% trans %}New election{% endtrans %}
|
||||
</a>
|
||||
{%- for election in object_list %}
|
||||
<hr>
|
||||
<section>
|
||||
@@ -35,7 +32,7 @@
|
||||
{% trans %} at {% endtrans %}<time>{{ election.start_candidature|localtime|time(DATETIME_FORMAT) }}</time>
|
||||
{% trans %}to{% endtrans %}
|
||||
<time datetime="{{ election.end_candidature }}">{{ election.end_candidature|localtime|date(DATETIME_FORMAT) }}</time>
|
||||
{% trans %} at {% endtrans %}<time>{{ election.end_candidature|localtime|time(DATETIME_FORMAT) }}</time>
|
||||
{% trans %} at {% endtrans %}<time>{{ election.end_candidature|time(DATETIME_FORMAT) }}</time>
|
||||
</p>
|
||||
<p>
|
||||
{% trans %}Polls open from{% endtrans %}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<div id="apply-election-result-fragment">
|
||||
{% if not form.candidates.field.choices %}
|
||||
<em>{% trans %}No result to apply{% endtrans %}</em>
|
||||
<p>
|
||||
{% trans trimmed %}
|
||||
This may be because no role of this election
|
||||
was linked to a club role.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
{% elif form.election.results_applied %}
|
||||
<em>
|
||||
{%- trans trimmed -%}
|
||||
The results of this election have been applied
|
||||
{%- endtrans -%}
|
||||
</em>
|
||||
<p>
|
||||
{% for club in clubs %}
|
||||
<a href="{{ url("club:club_members", club_id=club.id) }}" class="btn btn-blue">
|
||||
<i class="fa fa-arrow-up-right-from-square"></i>
|
||||
{% trans club=club.name %}{{ club }} members{% endtrans %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% else %}
|
||||
<div class="alert alert-yellow">
|
||||
<div class="alert-main">
|
||||
<strong class="alert-title">{% trans %}Warning{% endtrans %}</strong>
|
||||
<p>
|
||||
{%- trans trimmed -%}
|
||||
Only election roles linked to a club role will be automatically applied.
|
||||
{%- endtrans -%}
|
||||
</p>
|
||||
<p>
|
||||
{%- trans trimmed -%}
|
||||
Don't forget to manually apply the eventual remaining roles afterward.
|
||||
{%- endtrans -%}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
hx-post="{{ url("election:apply_result", election_id=form.election.id) }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#apply-election-result-fragment"
|
||||
hx-disabled-elt="find input[type='submit']"
|
||||
>
|
||||
{% csrf_token %}
|
||||
{{ form }}
|
||||
<input type="submit" class="btn btn-blue">
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -1,53 +0,0 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans name=object_name %}Election role{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if object %}
|
||||
<h1>{% trans election=election %}Create role for election "{{ election }}"{% endtrans %}</h1>
|
||||
{% else %}
|
||||
<h1>{% trans election=election %}Edit role for election "{{ election }}"{% endtrans %}</h1>
|
||||
{% endif %}
|
||||
<form action="" method="post" x-data="{role: null, title: '', description: ''}">
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
{{ form.club_role.label_tag() }}
|
||||
{{ form.club_role.errors }}
|
||||
{{ form.club_role|add_attr("x-model.fill=role,autofocus=true") }}
|
||||
<button
|
||||
class="btn btn-blue"
|
||||
@click.prevent="title = roles[role]?.title ?? '';
|
||||
description = roles[role]?.description ?? '';"
|
||||
>
|
||||
{% trans %}autofill form{% endtrans %}
|
||||
</button>
|
||||
<span class="helptext">{{ form.club_role.help_text }}</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.title.label_tag() }}
|
||||
{{ form.title.errors }}
|
||||
{{ form.title|add_attr("x-model.fill=title") }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.description.label_tag() }}
|
||||
{{ form.description.errors }}
|
||||
{{ form.description|add_attr("x-model.fill=description") }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.max_choice.as_field_group() }}
|
||||
</div>
|
||||
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
const roles = {
|
||||
{%- for role in form.club_role.field.queryset -%}
|
||||
{{ role.id }}: { title: {{ role.name|tojson }}, description: {{ role.description|tojson }} },
|
||||
{%- endfor -%}
|
||||
};
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -2,15 +2,13 @@ from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import localtime, now
|
||||
from django.utils.timezone import now
|
||||
from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.models import Club
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import Group, User
|
||||
from election.models import Candidature, Election, ElectionList, Role, Vote
|
||||
@@ -40,6 +38,7 @@ class TestElectionDetail(TestElection):
|
||||
reverse("election:detail", args=str(self.election.id))
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "La roue tourne" in str(response.content)
|
||||
|
||||
|
||||
class TestElectionUpdateView(TestElection):
|
||||
@@ -214,42 +213,3 @@ def test_election_results():
|
||||
"total vote": 100,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_election(client: Client):
|
||||
user_group = baker.make(Group)
|
||||
user = baker.make(
|
||||
User,
|
||||
user_permissions=[Permission.objects.get(codename="add_election")],
|
||||
groups=[user_group],
|
||||
)
|
||||
club = baker.make(Club)
|
||||
client.force_login(user)
|
||||
url = reverse("election:create")
|
||||
|
||||
res = client.get(url)
|
||||
assert res.status_code == 200
|
||||
|
||||
start = localtime().replace(hour=0, minute=1, second=0) + timedelta(days=1)
|
||||
res = client.post(
|
||||
url,
|
||||
data={
|
||||
"title": "foo",
|
||||
"clubs": [club.id],
|
||||
"view_groups": [user_group.id],
|
||||
"start_candidature": start,
|
||||
"end_candidature": start + timedelta(days=7, minutes=-2),
|
||||
"start_date": start + timedelta(days=7),
|
||||
"end_date": start + timedelta(days=14, minutes=-2),
|
||||
},
|
||||
)
|
||||
election = Election.objects.last()
|
||||
assertRedirects(
|
||||
res, reverse("election:detail", kwargs={"election_id": election.id})
|
||||
)
|
||||
assert election.title == "foo"
|
||||
assert list(election.clubs.all()) == [club]
|
||||
assert list(election.election_lists.values_list("title", flat=True)) == [
|
||||
"Candidat⸱e libre"
|
||||
]
|
||||
@@ -1,205 +0,0 @@
|
||||
import itertools
|
||||
from datetime import timedelta
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import localdate, now
|
||||
from model_bakery import baker, seq
|
||||
from model_bakery.recipe import Recipe
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import Group, User
|
||||
from election.forms import ApplyElectionResultForm
|
||||
from election.models import Candidature, Election, ElectionList, Role, Vote
|
||||
|
||||
|
||||
class TestApplyResult(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
# setup is a little bit complicated, but we have to make a whole
|
||||
# election to test result application, including the election,
|
||||
# the lists, the roles, the candidates and the votes.
|
||||
cls.club = baker.make(Club)
|
||||
cls.club_roles = baker.make(
|
||||
ClubRole,
|
||||
club=cls.club,
|
||||
is_presidency=iter([True, False, False]),
|
||||
is_board=True,
|
||||
_quantity=3,
|
||||
_bulk_create=True,
|
||||
)
|
||||
cls.election = baker.make(
|
||||
Election,
|
||||
clubs=[cls.club],
|
||||
edit_groups=[baker.make(Group)],
|
||||
end_date=now() - timedelta(minutes=1),
|
||||
)
|
||||
lists = baker.make(
|
||||
ElectionList, election=cls.election, _quantity=2, _bulk_create=True
|
||||
)
|
||||
role_recipe = Recipe(Role, election=cls.election, title=seq("election role "))
|
||||
roles = [
|
||||
*role_recipe.make(
|
||||
club_role=iter(cls.club_roles), _quantity=len(cls.club_roles)
|
||||
),
|
||||
role_recipe.make(),
|
||||
]
|
||||
roles[1].max_choice = 2
|
||||
roles[1].save()
|
||||
cls.candidatures = baker.make(
|
||||
Candidature,
|
||||
election_list=itertools.chain(
|
||||
itertools.repeat(lists[0], len(roles)),
|
||||
itertools.repeat(lists[1], len(roles)),
|
||||
),
|
||||
role=itertools.cycle(roles),
|
||||
user=iter(
|
||||
baker.make(
|
||||
User, username=seq("user "), _quantity=len(lists) * len(roles)
|
||||
)
|
||||
),
|
||||
_quantity=len(lists) * len(roles),
|
||||
_bulk_create=True,
|
||||
)
|
||||
votes = iter(
|
||||
baker.make(
|
||||
Vote,
|
||||
role=itertools.cycle(roles),
|
||||
_quantity=6 * len(roles),
|
||||
_bulk_create=True,
|
||||
)
|
||||
)
|
||||
through = []
|
||||
for cand in cls.candidatures:
|
||||
nb_voices = 4 if cand.election_list_id == lists[0].id else 2
|
||||
through.extend(
|
||||
[
|
||||
Vote.candidature.through(candidature=cand, vote=v)
|
||||
for v in itertools.islice(votes, nb_voices)
|
||||
]
|
||||
)
|
||||
Vote.candidature.through.objects.bulk_create(through)
|
||||
cls.election.voters.set(baker.make(User, _quantity=8, _bulk_create=True))
|
||||
cls.url = reverse(
|
||||
"election:apply_result", kwargs={"election_id": cls.election.id}
|
||||
)
|
||||
|
||||
def test_election_result(self):
|
||||
# we have made a complex setup, so testing the results is
|
||||
# useful to be sure we didn't make mistake when generating data
|
||||
assert self.election.results == {
|
||||
"election role 1": {
|
||||
"blank vote": {"percent": 25.0, "vote": 2},
|
||||
"total vote": 8,
|
||||
"user 1": {"percent": 50.0, "vote": 4},
|
||||
"user 5": {"percent": 25.0, "vote": 2},
|
||||
},
|
||||
"election role 2": {
|
||||
"blank vote": {"percent": 62.5, "vote": 10},
|
||||
"total vote": 16,
|
||||
"user 2": {"percent": 25.0, "vote": 4},
|
||||
"user 6": {"percent": 12.5, "vote": 2},
|
||||
},
|
||||
"election role 3": {
|
||||
"blank vote": {"percent": 25.0, "vote": 2},
|
||||
"total vote": 8,
|
||||
"user 3": {"percent": 50.0, "vote": 4},
|
||||
"user 7": {"percent": 25.0, "vote": 2},
|
||||
},
|
||||
"election role 4": {
|
||||
"blank vote": {"percent": 25.0, "vote": 2},
|
||||
"total vote": 8,
|
||||
"user 4": {"percent": 50.0, "vote": 4},
|
||||
"user 8": {"percent": 25.0, "vote": 2},
|
||||
},
|
||||
}
|
||||
|
||||
def test_apply_result(self):
|
||||
user = baker.make(
|
||||
User, user_permissions=[Permission.objects.get(codename="add_membership")]
|
||||
)
|
||||
self.client.force_login(user)
|
||||
response = self.client.get(self.url)
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
inputs = soup.find_all("input", attrs={"type": "checkbox"})
|
||||
assert all("checked" in i.attrs for i in inputs)
|
||||
ids = {int(i.attrs["value"]) for i in inputs}
|
||||
assert ids == {
|
||||
self.candidatures[0].id,
|
||||
self.candidatures[1].id,
|
||||
self.candidatures[2].id,
|
||||
self.candidatures[5].id,
|
||||
}
|
||||
response = self.client.post(
|
||||
self.url, data={"candidates": ids.difference({self.candidatures[5].id})}
|
||||
)
|
||||
assertRedirects(response, self.url)
|
||||
for candidate in self.candidatures[0:3]:
|
||||
assert Membership.objects.filter(
|
||||
start_date=localdate(),
|
||||
end_date=None,
|
||||
user=candidate.user,
|
||||
role=candidate.role.club_role,
|
||||
).exists()
|
||||
assert self.club.members_group.users.contains(candidate.user)
|
||||
assert self.club.board_group.users.contains(candidate.user)
|
||||
# candidatures[5] was unchecked, so it shouldn't receive a club role
|
||||
assert not self.candidatures[5].user.memberships.exists()
|
||||
|
||||
# now that results are applied, it shouldn't be possible to replay the request
|
||||
response = self.client.get(self.url)
|
||||
assert "Les résultats de cette élection ont été appliqués" in response.text
|
||||
response = self.client.post(self.url, data={"candidates": ids})
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_membership_description(self):
|
||||
"""Test that if club role name and election role name are different,
|
||||
then the election role name is used as membership description.
|
||||
"""
|
||||
form = ApplyElectionResultForm(
|
||||
election=self.election, data={"candidates": [self.candidatures[0].id]}
|
||||
)
|
||||
assert form.is_valid()
|
||||
memberships = form.save()
|
||||
assert len(memberships) == 1
|
||||
assert memberships[0].role == self.club_roles[0]
|
||||
assert memberships[0].description == "election role 1"
|
||||
|
||||
def test_no_result_to_apply(self):
|
||||
self.election.roles.update(club_role=None)
|
||||
user = baker.make(
|
||||
User, user_permissions=[Permission.objects.get(codename="add_membership")]
|
||||
)
|
||||
self.client.force_login(user)
|
||||
response = self.client.get(self.url)
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
assert not soup.find("input", attrs={"type": "checkbox"})
|
||||
assert "Pas de résultats à appliquer" in response.text
|
||||
|
||||
def test_access_denied(self):
|
||||
user = subscriber_user.make()
|
||||
self.client.force_login(user)
|
||||
response = self.client.get(self.url)
|
||||
assert response.status_code == 403
|
||||
response = self.client.post(
|
||||
self.url, data={"candidates": [self.candidatures[0].id]}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_election_not_finished(self):
|
||||
user = baker.make(
|
||||
User, user_permissions=[Permission.objects.get(codename="add_membership")]
|
||||
)
|
||||
self.election.end_date = now() + timedelta(minutes=1)
|
||||
self.election.save()
|
||||
self.client.force_login(user)
|
||||
response = self.client.get(self.url)
|
||||
assert response.status_code == 403
|
||||
response = self.client.post(
|
||||
self.url, data={"candidates": [self.candidatures[0].id]}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
@@ -1,110 +0,0 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.models import Club, ClubRole
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import Group, User
|
||||
from election.models import Election, Role
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCreateRole(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.club = baker.make(Club)
|
||||
cls.edit_group = baker.make(Group)
|
||||
cls.election = baker.make(
|
||||
Election,
|
||||
clubs=[cls.club],
|
||||
edit_groups=[cls.edit_group],
|
||||
view_groups=[Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)],
|
||||
end_candidature=now() + timedelta(days=1),
|
||||
)
|
||||
cls.url = reverse(
|
||||
"election:create_role", kwargs={"election_id": cls.election.id}
|
||||
)
|
||||
cls.election_url = reverse(
|
||||
"election:detail", kwargs={"election_id": cls.election.id}
|
||||
)
|
||||
cls.permission = Permission.objects.get(codename="add_role")
|
||||
|
||||
def assert_role_creation_ok(self):
|
||||
response = self.client.get(self.url)
|
||||
assert response.status_code == 200
|
||||
response = self.client.post(self.url, data={"title": "foo", "max_choice": 1})
|
||||
assertRedirects(response, self.election_url)
|
||||
roles = list(self.election.roles.all())
|
||||
assert len(roles) == 1
|
||||
assert roles[0].title == "foo"
|
||||
|
||||
def assert_role_creation_denied(self):
|
||||
initial_role_count = self.election.roles.count()
|
||||
response = self.client.get(self.url)
|
||||
assert response.status_code == 403
|
||||
response = self.client.post(self.url, data={"title": "foo", "max_choice": 1})
|
||||
assert response.status_code == 403
|
||||
assert self.election.roles.count() == initial_role_count
|
||||
|
||||
def test_admin(self):
|
||||
user = baker.make(User, user_permissions=[self.permission])
|
||||
self.client.force_login(user)
|
||||
self.assert_role_creation_ok()
|
||||
|
||||
def test_edit_group(self):
|
||||
user = baker.make(User, groups=[self.edit_group])
|
||||
self.client.force_login(user)
|
||||
self.assert_role_creation_ok()
|
||||
|
||||
def test_role_linked_to_club_role(self):
|
||||
user = baker.make(User, user_permissions=[self.permission])
|
||||
self.client.force_login(user)
|
||||
club_role = baker.make(ClubRole, is_board=True, club=self.club)
|
||||
response = self.client.post(
|
||||
self.url, data={"title": "foo", "max_choice": 1, "club_role": club_role.id}
|
||||
)
|
||||
assertRedirects(response, self.election_url)
|
||||
roles = list(self.election.roles.all())
|
||||
assert len(roles) == 1
|
||||
assert roles[0].title == "foo"
|
||||
assert roles[0].club_role == club_role
|
||||
|
||||
def test_permission_denied(self):
|
||||
user = subscriber_user.make()
|
||||
self.client.force_login(user)
|
||||
self.assert_role_creation_denied()
|
||||
|
||||
def test_election_not_editable(self):
|
||||
user = baker.make(User, user_permissions=[self.permission])
|
||||
self.election.end_candidature = now() - timedelta(minutes=1)
|
||||
self.election.save()
|
||||
self.client.force_login(user)
|
||||
self.assert_role_creation_denied()
|
||||
|
||||
|
||||
class TestUpdateRole(TestCreateRole):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
# TestUpdateRole is just TestCreateRole, but with different parameters
|
||||
cls.club = baker.make(Club)
|
||||
cls.edit_group = baker.make(Group)
|
||||
cls.election = baker.make(
|
||||
Election,
|
||||
clubs=[cls.club],
|
||||
edit_groups=[cls.edit_group],
|
||||
view_groups=[Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)],
|
||||
end_candidature=now() + timedelta(days=1),
|
||||
)
|
||||
cls.role = baker.make(Role, election=cls.election)
|
||||
cls.url = reverse("election:update_role", kwargs={"role_id": cls.role.id})
|
||||
cls.election_url = reverse(
|
||||
"election:detail", kwargs={"election_id": cls.election.id}
|
||||
)
|
||||
cls.permission = Permission.objects.get(codename="change_role")
|
||||
@@ -1,7 +1,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from election.views import (
|
||||
ApplyResultFragment,
|
||||
CandidatureCreateView,
|
||||
CandidatureDeleteView,
|
||||
CandidatureUpdateView,
|
||||
@@ -57,9 +56,4 @@ urlpatterns = [
|
||||
),
|
||||
path("<int:election_id>/vote/", VoteFormView.as_view(), name="vote"),
|
||||
path("<int:election_id>/detail/", ElectionDetailView.as_view(), name="detail"),
|
||||
path(
|
||||
"fragment/<int:election_id>/apply/",
|
||||
ApplyResultFragment.as_view(),
|
||||
name="apply_result",
|
||||
),
|
||||
]
|
||||
|
||||
+65
-65
@@ -18,9 +18,7 @@ from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateVi
|
||||
|
||||
from core.auth.mixins import CanEditMixin, CanViewMixin
|
||||
from election.forms import (
|
||||
ApplyElectionResultForm,
|
||||
CandidateForm,
|
||||
ElectionCreateForm,
|
||||
ElectionForm,
|
||||
ElectionListForm,
|
||||
RoleForm,
|
||||
@@ -210,7 +208,7 @@ class CandidatureCreateView(LoginRequiredMixin, CreateView):
|
||||
|
||||
class ElectionCreateView(PermissionRequiredMixin, CreateView):
|
||||
model = Election
|
||||
form_class = ElectionCreateForm
|
||||
form_class = ElectionForm
|
||||
template_name = "core/create.jinja"
|
||||
permission_required = "election.add_election"
|
||||
|
||||
@@ -221,7 +219,7 @@ class ElectionCreateView(PermissionRequiredMixin, CreateView):
|
||||
class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
|
||||
model = Role
|
||||
form_class = RoleForm
|
||||
template_name = "election/role_form.jinja"
|
||||
template_name = "core/create.jinja"
|
||||
|
||||
@cached_property
|
||||
def election(self):
|
||||
@@ -230,17 +228,22 @@ class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
|
||||
def test_func(self):
|
||||
if not self.election.is_vote_editable:
|
||||
return False
|
||||
user = self.request.user
|
||||
return user.has_perm("election.add_role") or user.can_edit(self.election)
|
||||
if self.request.user.has_perm("election.add_role"):
|
||||
return True
|
||||
return self.election.edit_groups.filter(
|
||||
id__in=self.request.user.all_groups
|
||||
).exists()
|
||||
|
||||
def get_initial(self):
|
||||
return {"election": self.election}
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {"election": self.election}
|
||||
return super().get_form_kwargs() | {"election_id": self.election.id}
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return reverse("election:detail", kwargs={"election_id": self.election.id})
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(**kwargs) | {"election": self.election}
|
||||
return reverse(
|
||||
"election:detail", kwargs={"election_id": self.object.election_id}
|
||||
)
|
||||
|
||||
|
||||
class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
|
||||
@@ -264,11 +267,16 @@ class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView
|
||||
)
|
||||
return not groups.isdisjoint(self.request.user.all_groups.keys())
|
||||
|
||||
def get_initial(self):
|
||||
return {"election": self.election}
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {"election": self.election}
|
||||
return super().get_form_kwargs() | {"election_id": self.election.id}
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return reverse("election:detail", kwargs={"election_id": self.election.id})
|
||||
return reverse(
|
||||
"election:detail", kwargs={"election_id": self.object.election_id}
|
||||
)
|
||||
|
||||
|
||||
# Update view
|
||||
@@ -280,6 +288,18 @@ class ElectionUpdateView(CanEditMixin, UpdateView):
|
||||
template_name = "core/edit.jinja"
|
||||
pk_url_kwarg = "election_id"
|
||||
|
||||
def get_initial(self):
|
||||
return {
|
||||
"start_date": self.object.start_date.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"end_date": self.object.end_date.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"start_candidature": self.object.start_candidature.strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
),
|
||||
"end_candidature": self.object.end_candidature.strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
),
|
||||
}
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return reverse_lazy("election:detail", kwargs={"election_id": self.object.id})
|
||||
|
||||
@@ -304,30 +324,48 @@ class CandidatureUpdateView(LoginRequiredMixin, CanEditMixin, UpdateView):
|
||||
)
|
||||
|
||||
|
||||
class RoleUpdateView(UserPassesTestMixin, UpdateView):
|
||||
class RoleUpdateView(CanEditMixin, UpdateView):
|
||||
model = Role
|
||||
form_class = RoleForm
|
||||
template_name = "election/role_form.jinja"
|
||||
template_name = "core/edit.jinja"
|
||||
pk_url_kwarg = "role_id"
|
||||
|
||||
@cached_property
|
||||
def election(self):
|
||||
return self.get_object().election
|
||||
def dispatch(self, request, *arg, **kwargs):
|
||||
self.object = self.get_object()
|
||||
if not self.object.election.is_vote_editable:
|
||||
raise PermissionDenied
|
||||
return super().dispatch(request, *arg, **kwargs)
|
||||
|
||||
def test_func(self):
|
||||
if not self.election.is_vote_editable:
|
||||
return False
|
||||
user = self.request.user
|
||||
return user.has_perm("election.change_role") or user.can_edit(self.election)
|
||||
def remove_fields(self):
|
||||
self.form.fields.pop("election", None)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(**kwargs) | {"election": self.election}
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
self.form = self.get_form()
|
||||
self.remove_fields()
|
||||
return self.render_to_response(self.get_context_data(form=self.form))
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
self.form = self.get_form()
|
||||
self.remove_fields()
|
||||
if (
|
||||
request.user.is_authenticated
|
||||
and request.user.can_edit(self.object)
|
||||
and self.form.is_valid()
|
||||
):
|
||||
return super().form_valid(self.form)
|
||||
return self.form_invalid(self.form)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {"election": self.election}
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["election_id"] = self.object.election.id
|
||||
return kwargs
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return reverse("election:detail", kwargs={"election_id": self.election.id})
|
||||
return reverse_lazy(
|
||||
"election:detail", kwargs={"election_id": self.object.election.id}
|
||||
)
|
||||
|
||||
|
||||
# Delete Views
|
||||
@@ -387,41 +425,3 @@ class ElectionListDeleteView(CanEditMixin, DeleteView):
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return reverse("election:detail", kwargs={"election_id": self.election.id})
|
||||
|
||||
|
||||
class ApplyResultFragment(LoginRequiredMixin, UserPassesTestMixin, FormView):
|
||||
template_name = "election/fragments/apply_result.jinja"
|
||||
form_class = ApplyElectionResultForm
|
||||
|
||||
@cached_property
|
||||
def election(self):
|
||||
return get_object_or_404(Election, pk=self.kwargs["election_id"])
|
||||
|
||||
def test_func(self):
|
||||
if not self.election.is_vote_finished:
|
||||
return False
|
||||
if self.request.user.has_perm("club.add_membership"):
|
||||
return True
|
||||
return self.election.edit_groups.filter(
|
||||
id__in=self.request.user.all_groups
|
||||
).exists()
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if self.election.results_applied:
|
||||
raise PermissionDenied
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {"election": self.election}
|
||||
|
||||
def form_valid(self, form: ApplyElectionResultForm):
|
||||
form.save()
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(**kwargs) | {"clubs": self.election.clubs.all()}
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return reverse(
|
||||
"election:apply_result", kwargs={"election_id": self.election.id}
|
||||
)
|
||||
|
||||
+196
-186
@@ -6,7 +6,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-06-10 20:18+0200\n"
|
||||
"POT-Creation-Date: 2026-05-12 11:12+0200\n"
|
||||
"PO-Revision-Date: 2016-07-18\n"
|
||||
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||
@@ -141,7 +141,8 @@ msgstr "vous devez spécifier au moins un utilisateur ou une adresse email"
|
||||
msgid "Begin date"
|
||||
msgstr "Date de début"
|
||||
|
||||
#: club/forms.py com/forms.py counter/forms.py subscription/forms.py
|
||||
#: club/forms.py com/forms.py counter/forms.py election/forms.py
|
||||
#: subscription/forms.py
|
||||
msgid "End date"
|
||||
msgstr "Date de fin"
|
||||
|
||||
@@ -260,7 +261,7 @@ msgstr ""
|
||||
"Si ce rôle est inactif, il ne pourra pas être attribué aux gens qui "
|
||||
"rejoignent le club."
|
||||
|
||||
#: club/models.py election/models.py
|
||||
#: club/models.py
|
||||
msgid "club role"
|
||||
msgstr "rôle de club"
|
||||
|
||||
@@ -303,6 +304,11 @@ msgstr "date de fin"
|
||||
msgid "role"
|
||||
msgstr "rôle"
|
||||
|
||||
#: club/models.py core/models.py counter/models.py election/models.py
|
||||
#: forum/models.py reservation/models.py
|
||||
msgid "description"
|
||||
msgstr "description"
|
||||
|
||||
#: club/models.py
|
||||
msgid "past member"
|
||||
msgstr "ancien membre"
|
||||
@@ -362,8 +368,11 @@ msgid "Unregistered user"
|
||||
msgstr "Utilisateur non enregistré"
|
||||
|
||||
#: club/models.py
|
||||
msgid "The base url that links with this type must respect"
|
||||
msgstr "L'url de base que tous les liens de ce type doivent respecter"
|
||||
#, python-format
|
||||
msgid "The base url that links with this type must respect (e.g. `%(url)s`)"
|
||||
msgstr ""
|
||||
"L'url de base que tous les liens de ce type doivent respecter (par exemple "
|
||||
"`%(url)s`)"
|
||||
|
||||
#: club/models.py counter/models.py
|
||||
msgid "icon"
|
||||
@@ -591,7 +600,6 @@ msgstr ""
|
||||
#: counter/templates/counter/cash_register_summary.jinja
|
||||
#: counter/templates/counter/invoices_call.jinja
|
||||
#: counter/templates/counter/product_form.jinja
|
||||
#: election/templates/election/role_form.jinja
|
||||
#: forum/templates/forum/reply.jinja
|
||||
#: subscription/templates/subscription/fragments/creation_form_existing_user.jinja
|
||||
#: subscription/templates/subscription/fragments/creation_form_new_user.jinja
|
||||
@@ -680,7 +688,6 @@ msgstr "Étiquette"
|
||||
#: core/templates/core/user_account_detail.jinja
|
||||
#: core/templates/core/user_stats.jinja
|
||||
#: counter/templates/counter/last_ops.jinja
|
||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
msgid "Quantity"
|
||||
msgstr "Quantité"
|
||||
|
||||
@@ -753,6 +760,18 @@ msgstr "Nouveau Trombi"
|
||||
msgid "Posters"
|
||||
msgstr "Affiches"
|
||||
|
||||
#: club/templates/club/club_tools.jinja
|
||||
msgid "Reservable rooms"
|
||||
msgstr "Salles réservables"
|
||||
|
||||
#: club/templates/club/club_tools.jinja
|
||||
msgid "Add a room"
|
||||
msgstr "Ajouter une salle"
|
||||
|
||||
#: club/templates/club/club_tools.jinja
|
||||
msgid "This club manages no reservable room"
|
||||
msgstr "Ce club ne gère pas de salle réservable"
|
||||
|
||||
#: club/templates/club/club_tools.jinja
|
||||
msgid "Counters:"
|
||||
msgstr "Comptoirs : "
|
||||
@@ -964,7 +983,7 @@ msgstr "rôle de club – membre"
|
||||
msgid "Benefit"
|
||||
msgstr "Bénéfice"
|
||||
|
||||
#: club/views.py eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
#: club/views.py
|
||||
msgid "Unit price"
|
||||
msgstr "Prix unitaire"
|
||||
|
||||
@@ -976,7 +995,7 @@ msgstr "Prix d'achat"
|
||||
msgid "Format: 16:9 | Resolution: 1920x1080"
|
||||
msgstr "Format : 16:9 | Résolution : 1920x1080"
|
||||
|
||||
#: com/forms.py subscription/forms.py
|
||||
#: com/forms.py election/forms.py subscription/forms.py
|
||||
msgid "Start date"
|
||||
msgstr "Date de début"
|
||||
|
||||
@@ -1052,7 +1071,7 @@ msgstr "Une description plus détaillée et exhaustive de l'évènement."
|
||||
msgid "The club which organizes the event."
|
||||
msgstr "Le club qui organise l'évènement."
|
||||
|
||||
#: com/models.py pedagogy/models.py trombi/models.py
|
||||
#: com/models.py pedagogy/models.py reservation/models.py trombi/models.py
|
||||
msgid "author"
|
||||
msgstr "auteur"
|
||||
|
||||
@@ -1345,6 +1364,11 @@ msgstr "Emploi du temps"
|
||||
msgid "Matmatronch"
|
||||
msgstr "Matmatronch"
|
||||
|
||||
#: com/templates/com/news_list.jinja
|
||||
#: reservation/templates/reservation/schedule.jinja
|
||||
msgid "Room reservation"
|
||||
msgstr "Réservation de salle"
|
||||
|
||||
#: com/templates/com/news_list.jinja core/templates/core/base/navbar.jinja
|
||||
#: core/templates/core/user_tools.jinja
|
||||
msgid "Elections"
|
||||
@@ -2201,7 +2225,6 @@ msgstr "Êtes-vous sûr de vouloir supprimer \"%(name)s\" ?"
|
||||
#: core/templates/core/delete_confirm.jinja
|
||||
#: core/templates/core/file_delete_confirm.jinja
|
||||
#: counter/templates/counter/fragments/delete_student_card.jinja
|
||||
#: counter/templates/counter/fragments/login.jinja
|
||||
msgid "Confirm"
|
||||
msgstr "Confirmation"
|
||||
|
||||
@@ -2209,6 +2232,7 @@ msgstr "Confirmation"
|
||||
#: core/templates/core/file_delete_confirm.jinja
|
||||
#: counter/templates/counter/counter_click.jinja
|
||||
#: counter/templates/counter/fragments/delete_student_card.jinja
|
||||
#: reservation/templates/reservation/fragments/create_reservation.jinja
|
||||
#: sas/templates/sas/ask_picture_removal.jinja
|
||||
msgid "Cancel"
|
||||
msgstr "Annuler"
|
||||
@@ -3205,22 +3229,6 @@ msgstr "Cet UID est invalide"
|
||||
msgid "User not found"
|
||||
msgstr "Utilisateur non trouvé"
|
||||
|
||||
#: counter/forms.py
|
||||
msgid "You are not a barman of this counter."
|
||||
msgstr "Vous n'êtes pas barman sur ce comptoir."
|
||||
|
||||
#: counter/forms.py
|
||||
msgid "You are already logged in this counter."
|
||||
msgstr "Vous êtes déjà connecté à ce comptoir."
|
||||
|
||||
#: counter/forms.py
|
||||
msgid "You are already logged in another counter."
|
||||
msgstr "Vous êtes déjà connecté à un autre comptoir."
|
||||
|
||||
#: counter/forms.py
|
||||
msgid "You are already logged on another device"
|
||||
msgstr "Vous êtes déjà connecté sur un autre appareil"
|
||||
|
||||
#: counter/forms.py
|
||||
msgid "Regular barmen"
|
||||
msgstr "Barmen réguliers"
|
||||
@@ -3310,11 +3318,6 @@ msgstr "Saisie de produit dupliquée"
|
||||
msgid "Not enough money"
|
||||
msgstr "Solde insuffisant"
|
||||
|
||||
#: counter/forms.py counter/models.py
|
||||
#, python-format
|
||||
msgid "There cannot be more than %(money)d€ on an AE account"
|
||||
msgstr "Il ne peut pas y avoir plus de %(money)d€ sur un compte AE"
|
||||
|
||||
#: counter/forms.py
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -3391,7 +3394,7 @@ msgstr "Mettre à True si le mail a reçu une erreur"
|
||||
msgid "The operation that emptied the account."
|
||||
msgstr "L'opération qui a vidé le compte."
|
||||
|
||||
#: counter/models.py pedagogy/models.py
|
||||
#: counter/models.py pedagogy/models.py reservation/models.py
|
||||
msgid "comment"
|
||||
msgstr "commentaire"
|
||||
|
||||
@@ -3428,16 +3431,8 @@ msgid "Buy five, get the sixth free"
|
||||
msgstr "Pour cinq achetés, le sixième offert"
|
||||
|
||||
#: counter/models.py
|
||||
msgid "clic limit"
|
||||
msgstr "limite de clic"
|
||||
|
||||
#: counter/models.py
|
||||
msgid ""
|
||||
"If a limit is set, the product won't be purchasable anymore on the eboutic "
|
||||
"once the latter is reached."
|
||||
msgstr ""
|
||||
"Si une limite est donnée, le produit ne sera plus achetable sur l'eboutic "
|
||||
"une fois celle-ci atteinte."
|
||||
msgid "buying groups"
|
||||
msgstr "groupe d'achat"
|
||||
|
||||
#: counter/models.py election/models.py
|
||||
msgid "archived"
|
||||
@@ -3504,6 +3499,10 @@ msgstr "Bureau"
|
||||
msgid "sellers"
|
||||
msgstr "vendeurs"
|
||||
|
||||
#: counter/models.py
|
||||
msgid "token"
|
||||
msgstr "jeton"
|
||||
|
||||
#: counter/models.py
|
||||
msgid "regular barman"
|
||||
msgstr "barman régulier"
|
||||
@@ -3789,6 +3788,15 @@ msgstr "Confirmer (FIN)"
|
||||
msgid "Cancel (ANN)"
|
||||
msgstr "Annuler (ANN)"
|
||||
|
||||
#: counter/templates/counter/counter_click.jinja
|
||||
#: counter/templates/counter/fragments/create_refill.jinja
|
||||
#: counter/templates/counter/fragments/create_student_card.jinja
|
||||
#: counter/templates/counter/invoices_call.jinja
|
||||
#: sas/templates/sas/picture.jinja
|
||||
#: subscription/templates/subscription/stats.jinja
|
||||
msgid "Go"
|
||||
msgstr "Valider"
|
||||
|
||||
#: counter/templates/counter/counter_click.jinja
|
||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
msgid "Basket: "
|
||||
@@ -3819,7 +3827,7 @@ msgstr ""
|
||||
|
||||
#: counter/templates/counter/counter_click.jinja
|
||||
msgid "No products available on this counter for this user"
|
||||
msgstr "Pas de produits disponibles dans ce comptoir pour cet utilisateur"
|
||||
msgstr "Pas de produits disponnibles dans ce comptoir pour cet utilisateur"
|
||||
|
||||
#: counter/templates/counter/counter_list.jinja
|
||||
msgid "Counter admin list"
|
||||
@@ -3880,20 +3888,12 @@ msgid "Please, login"
|
||||
msgstr "Merci de vous identifier"
|
||||
|
||||
#: counter/templates/counter/counter_main.jinja
|
||||
msgid "Barmen:"
|
||||
msgstr "Barmen :"
|
||||
msgid "Barman: "
|
||||
msgstr "Barman : "
|
||||
|
||||
#: counter/templates/counter/counter_main.jinja
|
||||
msgid "On this device"
|
||||
msgstr "Sur cet appareil"
|
||||
|
||||
#: counter/templates/counter/counter_main.jinja
|
||||
msgid "Elsewhere"
|
||||
msgstr "Ailleurs"
|
||||
|
||||
#: counter/templates/counter/counter_main.jinja
|
||||
msgid "No barman logged elsewhere"
|
||||
msgstr "Pas de barman connecté ailleurs"
|
||||
msgid "login"
|
||||
msgstr "login"
|
||||
|
||||
#: counter/templates/counter/eticket_list.jinja
|
||||
msgid "Eticket list"
|
||||
@@ -3945,14 +3945,6 @@ msgstr ""
|
||||
msgid "New formula"
|
||||
msgstr "Nouvelle formule"
|
||||
|
||||
#: counter/templates/counter/fragments/create_refill.jinja
|
||||
#: counter/templates/counter/fragments/create_student_card.jinja
|
||||
#: counter/templates/counter/invoices_call.jinja
|
||||
#: sas/templates/sas/picture.jinja
|
||||
#: subscription/templates/subscription/stats.jinja
|
||||
msgid "Go"
|
||||
msgstr "Valider"
|
||||
|
||||
#: counter/templates/counter/fragments/create_student_card.jinja
|
||||
msgid "No student card registered."
|
||||
msgstr "Aucune carte étudiante enregistrée."
|
||||
@@ -4306,14 +4298,22 @@ msgstr "Montant du chèque"
|
||||
msgid "Check quantity"
|
||||
msgstr "Nombre de chèque"
|
||||
|
||||
#: counter/views/click.py
|
||||
msgid "You cannot click users on this counter"
|
||||
msgstr "Vous ne pouvez pas cliquer des gens sur ce comptoir"
|
||||
|
||||
#: counter/views/eticket.py
|
||||
msgid "people(s)"
|
||||
msgstr "personne(s)"
|
||||
|
||||
#: counter/views/home.py
|
||||
msgid "Bad credentials"
|
||||
msgstr "Mauvais identifiants"
|
||||
|
||||
#: counter/views/home.py
|
||||
msgid "User is not barman"
|
||||
msgstr "L'utilisateur n'est pas barman."
|
||||
|
||||
#: counter/views/home.py
|
||||
msgid "Bad location, someone is already logged in somewhere else"
|
||||
msgstr "Mauvais comptoir, quelqu'un est déjà connecté ailleurs"
|
||||
|
||||
#: counter/views/invoice.py
|
||||
msgid "Invoice calls status has been updated."
|
||||
msgstr "Le statut des appels à facture a été mis à jour."
|
||||
@@ -4390,18 +4390,6 @@ msgstr "Solde actuel : "
|
||||
msgid "Remaining account amount: "
|
||||
msgstr "Solde restant : "
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
msgid "I have read and I accept"
|
||||
msgstr "J'ai lu et j'accepte"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
msgid "the general terms and conditions"
|
||||
msgstr "les conditions générales de vente"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
msgid "of the student association of the UTBM"
|
||||
msgstr "de l'Association des étudiants de l'UTBM"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
msgid "Pay with credit card"
|
||||
msgstr "Payer avec une carte bancaire"
|
||||
@@ -4437,15 +4425,6 @@ msgstr "Payer avec un compte AE"
|
||||
msgid "The online shop of the association."
|
||||
msgstr "La boutique en ligne de l'association."
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
#, python-format
|
||||
msgid ""
|
||||
"You cannot purchase the current basket, because it would put your AE account "
|
||||
"balance above the %(limit)s€ limit"
|
||||
msgstr ""
|
||||
"Vous ne pouvez pas finaliser le panier actuel, parce que le solde de votre "
|
||||
"compte AE passerait au-dessus de la limite de %(limit)s€."
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
msgid "Clear"
|
||||
msgstr "Vider"
|
||||
@@ -4466,8 +4445,45 @@ msgid "this page"
|
||||
msgstr "cette page"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
msgid "Product sold out"
|
||||
msgstr "Produit épuisé"
|
||||
msgid "Eurockéennes 2025 partnership"
|
||||
msgstr "Partenariat Eurockéennes 2025"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
msgid ""
|
||||
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
|
||||
"according to its own privacy policy. By clicking the accept button you "
|
||||
"consent to their terms of services."
|
||||
msgstr ""
|
||||
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
|
||||
"collecter des informations utilisateur conformément à sa propre politique de "
|
||||
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
|
||||
"leurs termes de service."
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
msgid "Privacy policy"
|
||||
msgstr "Politique de confidentialité"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
#: trombi/templates/trombi/comment_moderation.jinja
|
||||
msgid "Accept"
|
||||
msgstr "Accepter"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
msgid ""
|
||||
"You must be subscribed to benefit from the partnership with the Eurockéennes."
|
||||
msgstr ""
|
||||
"Vous devez être cotisant pour bénéficier du partenariat avec les "
|
||||
"Eurockéennes."
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
#, python-format
|
||||
msgid ""
|
||||
"This partnership offers a discount of up to 33%% on tickets for Friday, "
|
||||
"Saturday and Sunday, as well as the 3-day package from Friday to Sunday."
|
||||
msgstr ""
|
||||
"Ce partenariat permet de profiter d'une réduction jusqu'à 33%% sur les "
|
||||
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
|
||||
"du vendredi au dimanche."
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
msgid "There are no items available for sale"
|
||||
@@ -4501,13 +4517,16 @@ msgstr ""
|
||||
"par carte bancaire"
|
||||
|
||||
#: eboutic/views.py
|
||||
msgid "Basket expired"
|
||||
msgstr "Panier expiré"
|
||||
|
||||
#: eboutic/views.py
|
||||
#, python-format
|
||||
msgid "Basket available until %(until)s"
|
||||
msgstr "Panier disponible jusqu'à %(until)s"
|
||||
msgid ""
|
||||
"The Crédit Agricole changed its policy related to the billing information "
|
||||
"that must be provided in order to pay with a credit card. If you want to pay "
|
||||
"with your credit card, you must add a phone number to the data you already "
|
||||
"provided."
|
||||
msgstr ""
|
||||
"Le Crédit Agricole a changé sa politique relative aux informations à "
|
||||
"fournir pour effectuer un paiement par carte bancaire. De ce fait, si vous "
|
||||
"souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux "
|
||||
"données que vous aviez déjà fourni."
|
||||
|
||||
#: eboutic/views.py
|
||||
msgid "You can't buy a refilling with sith money"
|
||||
@@ -4525,13 +4544,17 @@ msgstr "Utilisateur se présentant"
|
||||
msgid "Blank vote"
|
||||
msgstr "Vote blanc"
|
||||
|
||||
#: election/models.py
|
||||
msgid "clubs"
|
||||
msgstr "clubs"
|
||||
#: election/forms.py
|
||||
msgid "This role already exists for this election"
|
||||
msgstr "Ce rôle existe déjà pour cette élection"
|
||||
|
||||
#: election/models.py
|
||||
msgid "The club(s) this election is held for."
|
||||
msgstr "Le(s) club(s) pour lequel cette élection est tenue."
|
||||
#: election/forms.py
|
||||
msgid "Start candidature"
|
||||
msgstr "Début des candidatures"
|
||||
|
||||
#: election/forms.py
|
||||
msgid "End candidature"
|
||||
msgstr "Fin des candidatures"
|
||||
|
||||
#: election/models.py
|
||||
msgid "start candidature"
|
||||
@@ -4569,18 +4592,6 @@ msgstr "élection"
|
||||
msgid "max choice"
|
||||
msgstr "nombre de choix maxi"
|
||||
|
||||
#: election/models.py
|
||||
msgid ""
|
||||
"A club role. Filling this will allow automatic completion of title and "
|
||||
"description, and automatic assignation after the elections."
|
||||
msgstr ""
|
||||
"Un rôle de club. Remplir ce champ permet l'autocomplétion du titre et de la "
|
||||
"description, et l'attribution automatique des rôles après les élections."
|
||||
|
||||
#: election/models.py
|
||||
msgid "This role already exists for this election"
|
||||
msgstr "Ce rôle existe déjà pour cette élection"
|
||||
|
||||
#: election/models.py
|
||||
msgid "election list"
|
||||
msgstr "liste électorale"
|
||||
@@ -4617,6 +4628,8 @@ msgid "Polls will open "
|
||||
msgstr "Les votes ouvriront "
|
||||
|
||||
#: election/templates/election/election_detail.jinja
|
||||
#: election/templates/election/election_list.jinja
|
||||
#: forum/templates/forum/macros.jinja
|
||||
msgid " at "
|
||||
msgstr " à "
|
||||
|
||||
@@ -4624,10 +4637,6 @@ msgstr "à"
|
||||
msgid "and will close "
|
||||
msgstr "et fermeront"
|
||||
|
||||
#: election/templates/election/election_detail.jinja
|
||||
msgid "Apply election result"
|
||||
msgstr "Appliquer les résultats de l'élection"
|
||||
|
||||
#: election/templates/election/election_detail.jinja
|
||||
msgid "You already have submitted your vote."
|
||||
msgstr "Vous avez déjà soumis votre vote."
|
||||
@@ -4669,19 +4678,10 @@ msgstr "Liste des élections"
|
||||
msgid "Current elections"
|
||||
msgstr "Élections actuelles"
|
||||
|
||||
#: election/templates/election/election_list.jinja
|
||||
msgid "New election"
|
||||
msgstr "Nouvelle élection"
|
||||
|
||||
#: election/templates/election/election_list.jinja
|
||||
msgid "Applications open from"
|
||||
msgstr "Candidatures ouvertes à partir du"
|
||||
|
||||
#: election/templates/election/election_list.jinja
|
||||
#: forum/templates/forum/macros.jinja
|
||||
msgid " at "
|
||||
msgstr " à "
|
||||
|
||||
#: election/templates/election/election_list.jinja
|
||||
msgid "to"
|
||||
msgstr "au"
|
||||
@@ -4690,59 +4690,6 @@ msgstr "au"
|
||||
msgid "Polls open from"
|
||||
msgstr "Votes ouverts du"
|
||||
|
||||
#: election/templates/election/fragments/apply_result.jinja
|
||||
msgid "No result to apply"
|
||||
msgstr "Pas de résultats à appliquer"
|
||||
|
||||
#: election/templates/election/fragments/apply_result.jinja
|
||||
msgid "This may be because no role of this election was linked to a club role."
|
||||
msgstr ""
|
||||
"Ceci s'explique peut-être parce qu'aucun poste de cette élection n'était lié "
|
||||
"à un rôle de club."
|
||||
|
||||
#: election/templates/election/fragments/apply_result.jinja
|
||||
msgid "The results of this election have been applied"
|
||||
msgstr "Les résultats de cette élection ont été appliqués"
|
||||
|
||||
#: election/templates/election/fragments/apply_result.jinja
|
||||
#, python-format
|
||||
msgid "%(club)s members"
|
||||
msgstr "Membres %(club)s"
|
||||
|
||||
#: election/templates/election/fragments/apply_result.jinja
|
||||
msgid "Warning"
|
||||
msgstr "Attention"
|
||||
|
||||
#: election/templates/election/fragments/apply_result.jinja
|
||||
msgid ""
|
||||
"Only election roles linked to a club role will be automatically applied."
|
||||
msgstr ""
|
||||
"Seuls les postes de cette élection qui sont liés à un rôle de club seront "
|
||||
"automatiquement appliqués."
|
||||
|
||||
#: election/templates/election/fragments/apply_result.jinja
|
||||
msgid "Don't forget to manually apply the eventual remaining roles afterward."
|
||||
msgstr ""
|
||||
"N'oubliez pas après d'attribuer manuellement les éventuels postes restants."
|
||||
|
||||
#: election/templates/election/role_form.jinja
|
||||
msgid "Election role"
|
||||
msgstr "Rôle d'élection"
|
||||
|
||||
#: election/templates/election/role_form.jinja
|
||||
#, python-format
|
||||
msgid "Create role for election \"%(election)s\""
|
||||
msgstr "Création d'un rôle pour l'élection « %(election)s »"
|
||||
|
||||
#: election/templates/election/role_form.jinja
|
||||
#, python-format
|
||||
msgid "Edit role for election \"%(election)s\""
|
||||
msgstr "Modification d'un rôle pour l'élection « %(election)s »"
|
||||
|
||||
#: election/templates/election/role_form.jinja
|
||||
msgid "autofill form"
|
||||
msgstr "compléter le formulaire"
|
||||
|
||||
#: election/views.py
|
||||
msgid "Form is invalid"
|
||||
msgstr "Formulaire invalide"
|
||||
@@ -5226,6 +5173,73 @@ msgstr "Signaler ce commentaire"
|
||||
msgid "Edit UE"
|
||||
msgstr "Éditer l'UE"
|
||||
|
||||
#: reservation/forms.py
|
||||
msgid "The start must be set before the end"
|
||||
msgstr "Le début doit être placé avant la fin"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "room name"
|
||||
msgstr "Nom de la salle"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "room owner"
|
||||
msgstr "propriétaire de la salle"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "The club which manages this room"
|
||||
msgstr "Le club qui gère cette salle"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "site"
|
||||
msgstr "site"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "reservable room"
|
||||
msgstr "salle réservable"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "reservable rooms"
|
||||
msgstr "salles réservables"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "reserved room"
|
||||
msgstr "salle réservée"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "slot start"
|
||||
msgstr "début du créneau"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "slot end"
|
||||
msgstr "fin du créneau"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "reservation slot"
|
||||
msgstr "créneau de réservation"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "reservation slots"
|
||||
msgstr "créneaux de réservation"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "There is already a reservation on this slot."
|
||||
msgstr "Il y a déjà une réservation sur ce créneau."
|
||||
|
||||
#: reservation/templates/reservation/fragments/create_reservation.jinja
|
||||
msgid "Book a room"
|
||||
msgstr "Réserver une salle"
|
||||
|
||||
#: reservation/templates/reservation/schedule.jinja
|
||||
msgid "You can book a room by selecting a free slot in the calendar."
|
||||
msgstr ""
|
||||
"Vous pouvez réserver une salle en sélectionnant un emplacement libre dans le "
|
||||
"calendrier."
|
||||
|
||||
#: reservation/views.py
|
||||
#, python-format
|
||||
msgid "%(name)s was updated successfully"
|
||||
msgstr "%(name)s a été mis à jour avec succès"
|
||||
|
||||
#: rootplace/forms.py
|
||||
msgid "User that will be kept"
|
||||
msgstr "Utilisateur qui sera conservé"
|
||||
@@ -5908,10 +5922,6 @@ msgstr "fin"
|
||||
msgid "Moderate Trombi comments"
|
||||
msgstr "Modérer les commentaires du Trombi"
|
||||
|
||||
#: trombi/templates/trombi/comment_moderation.jinja
|
||||
msgid "Accept"
|
||||
msgstr "Accepter"
|
||||
|
||||
#: trombi/templates/trombi/comment_moderation.jinja
|
||||
msgid "Reject"
|
||||
msgstr "Refuser"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-05-17 10:03+0200\n"
|
||||
"POT-Creation-Date: 2026-04-17 22:42+0200\n"
|
||||
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
|
||||
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||
@@ -263,9 +263,13 @@ msgstr "Types de produits réordonnés !"
|
||||
msgid "Product type reorganisation failed with status code : %d"
|
||||
msgstr "La réorganisation des types de produit a échoué avec le code : %d"
|
||||
|
||||
#: eboutic/static/bundled/eboutic/checkout-index.ts
|
||||
msgid "Basket expired"
|
||||
msgstr "Panier expiré"
|
||||
#: reservation/static/bundled/reservation/components/room-scheduler-index.ts
|
||||
msgid "Rooms"
|
||||
msgstr "Salles"
|
||||
|
||||
#: reservation/static/bundled/reservation/slot-reservation-index.ts
|
||||
msgid "This slot has been successfully moved"
|
||||
msgstr "Ce créneau a été bougé avec succès"
|
||||
|
||||
#: sas/static/bundled/sas/pictures-download-index.ts
|
||||
msgid "pictures.%(extension)s"
|
||||
|
||||
Generated
+1390
-1081
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user