mirror of
https://github.com/ae-utbm/sith.git
synced 2026-08-04 04:32:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d9b915176 | ||
|
|
b8c8768479 | ||
|
|
3be4c583f1 | ||
|
|
2d75a3e6c2 | ||
|
|
8cdb74e90f | ||
|
|
847511d27f | ||
|
|
a75d5e06b6 | ||
|
|
1c277365ed | ||
|
|
e7ef3d0020 | ||
|
|
5c87bcf4b5 | ||
|
|
92b83071ad | ||
|
|
7ad469ff24 | ||
|
|
c076b80e40 | ||
|
|
dcdfb086ed | ||
|
|
5625268d6b | ||
|
|
648b6b6af0 | ||
|
|
73ae7666c1 | ||
|
|
defeef62ea | ||
|
|
859d724c34 | ||
|
|
307f89ce90 | ||
|
|
44f8655b70 | ||
|
|
8f15facd8f | ||
|
|
1af1712efc | ||
|
|
0ba001ccda | ||
|
|
feca466dbe | ||
|
|
519a7758c5 | ||
|
|
caa2bf66be | ||
|
|
998efc7c6b | ||
|
|
867362fb51 | ||
|
|
d41a3a524a | ||
|
|
39bbbc8878 | ||
|
|
5e553d91a8 | ||
|
|
f6f31af975 | ||
|
|
1957aa1fdb | ||
|
|
d73b7de903 | ||
|
|
4c5149aec9 | ||
|
|
89afdba055 | ||
|
|
e9a55c6f3a | ||
|
|
922c940fd5 | ||
|
|
29c0d0be28 | ||
|
|
b1ab21aff7 | ||
|
|
8ee385e1ae | ||
|
|
d0a80a3ec8 | ||
|
|
bcc44727df | ||
|
|
d25bcec9ba | ||
|
|
f3e78f229a | ||
|
|
00fb8a719f | ||
|
|
c4af372973 | ||
|
|
d119bfa0c6 | ||
|
|
dc2e7ff468 | ||
|
|
960657404b | ||
|
|
cd9cd81e8b | ||
|
|
733bd49a42 | ||
|
|
eb7f5def6e | ||
|
|
ba618aa3cd | ||
|
|
1456bc8044 | ||
|
|
177002b8b8 | ||
|
|
e629b36465 | ||
|
|
30a3911fa1 | ||
|
|
7c9ba29db1 | ||
|
|
cf31182429 | ||
|
|
29cacf8efc | ||
|
|
1e592e657f | ||
|
|
fb1790020b | ||
|
|
3cf142f3f1 | ||
|
|
222b0d16a7 | ||
|
|
074ebcb011 | ||
|
|
a26e06216e | ||
|
|
78c541dd36 | ||
|
|
b4d76c4f85 | ||
|
|
b5a2ec78df | ||
|
|
8022589902 | ||
|
|
6ae73a28b4 | ||
|
|
7f415c6a6c | ||
|
|
dd4887ead4 | ||
|
|
a8b6a2e43b | ||
|
|
f90cb5b91c | ||
|
|
d604147a93 | ||
|
|
3f2908eb8d | ||
|
|
e811aeaecd | ||
|
|
549a778be0 | ||
|
|
5c42da273b | ||
|
|
b8e0294df6 | ||
|
|
78b24dc1e7 | ||
|
|
ebf0196bef | ||
|
|
362b9eea06 | ||
|
|
3b3e33ed80 | ||
|
|
649190debe | ||
|
|
50c880719a |
@@ -1,7 +1,7 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
# Ruff version.
|
# Ruff version.
|
||||||
rev: v0.15.13
|
rev: v0.16.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check # just check the code, and print the errors
|
- id: ruff-check # just check the code, and print the errors
|
||||||
- id: ruff-check # actually fix the fixable errors, but print nothing
|
- id: ruff-check # actually fix the fixable errors, but print nothing
|
||||||
@@ -12,7 +12,7 @@ repos:
|
|||||||
rev: v0.6.1
|
rev: v0.6.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: biome-check
|
- id: biome-check
|
||||||
additional_dependencies: ["@biomejs/biome@2.4.6"]
|
additional_dependencies: ["@biomejs/biome@2.5.1"]
|
||||||
- repo: https://github.com/rtts/djhtml
|
- repo: https://github.com/rtts/djhtml
|
||||||
rev: 3.0.11
|
rev: 3.0.11
|
||||||
hooks:
|
hooks:
|
||||||
|
|||||||
+2
-7
@@ -46,7 +46,7 @@ from django.http import HttpRequest
|
|||||||
from ninja_extra import ControllerBase
|
from ninja_extra import ControllerBase
|
||||||
from ninja_extra.permissions import BasePermission
|
from ninja_extra.permissions import BasePermission
|
||||||
|
|
||||||
from counter.models import Counter
|
from counter.utils import is_logged_in_counter
|
||||||
|
|
||||||
|
|
||||||
class IsInGroup(BasePermission):
|
class IsInGroup(BasePermission):
|
||||||
@@ -186,12 +186,7 @@ class IsLoggedInCounter(BasePermission):
|
|||||||
"""Check that a user is logged in a counter."""
|
"""Check that a user is logged in a counter."""
|
||||||
|
|
||||||
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
|
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
|
||||||
if "/counter/" not in request.META.get("HTTP_REFERER", ""):
|
return is_logged_in_counter(request)
|
||||||
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")
|
CanAccessLookup = IsLoggedInCounter | HasPerm("core.access_lookup")
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@
|
|||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"preset": "recommended",
|
||||||
"style": {
|
"style": {
|
||||||
"useNamingConvention": "error"
|
"useNamingConvention": "error"
|
||||||
},
|
},
|
||||||
|
|||||||
+41
@@ -11,6 +11,7 @@ from club.models import Club, Membership
|
|||||||
from club.schemas import (
|
from club.schemas import (
|
||||||
ClubSchema,
|
ClubSchema,
|
||||||
ClubSearchFilterSchema,
|
ClubSearchFilterSchema,
|
||||||
|
MembershipFilterSchema,
|
||||||
SimpleClubSchema,
|
SimpleClubSchema,
|
||||||
UserMembershipSchema,
|
UserMembershipSchema,
|
||||||
)
|
)
|
||||||
@@ -62,3 +63,43 @@ class UserClubController(ControllerBase):
|
|||||||
.filter(user=user)
|
.filter(user=user)
|
||||||
.select_related("club", "user", "role")
|
.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,10 +21,13 @@
|
|||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
import itertools
|
||||||
|
from operator import attrgetter
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.db.models import Exists, OuterRef, Q, QuerySet
|
from django.db.models import Exists, OuterRef, Q, QuerySet
|
||||||
from django.db.models.functions import Lower
|
from django.db.models.functions import Lower
|
||||||
|
from django.forms.models import ModelChoiceField, ModelChoiceIterator
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@@ -46,6 +49,37 @@ from counter.models import Counter, Selling
|
|||||||
from counter.schemas import SaleFilterSchema
|
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):
|
class ClubLinkForm(forms.ModelForm):
|
||||||
error_css_class = "error"
|
error_css_class = "error"
|
||||||
required_css_class = "required"
|
required_css_class = "required"
|
||||||
@@ -392,6 +426,30 @@ class ClubRoleForm(forms.ModelForm):
|
|||||||
self.instance.order = cleaned_data["ORDER"] - 1
|
self.instance.order = cleaned_data["ORDER"] - 1
|
||||||
return cleaned_data
|
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):
|
class ClubRoleCreateForm(forms.ModelForm):
|
||||||
"""Form to create a club role.
|
"""Form to create a club role.
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ class Migration(migrations.Migration):
|
|||||||
"url_base",
|
"url_base",
|
||||||
models.URLField(
|
models.URLField(
|
||||||
help_text=(
|
help_text=(
|
||||||
"The base url that links with this type "
|
"The base url that links with this type must respect"
|
||||||
"must respect (e.g. `https://www.instagram.com`)"
|
|
||||||
),
|
),
|
||||||
unique=True,
|
unique=True,
|
||||||
verbose_name="url base",
|
verbose_name="url base",
|
||||||
|
|||||||
+1
-4
@@ -793,10 +793,7 @@ class LinkType(models.Model):
|
|||||||
url_base = models.URLField(
|
url_base = models.URLField(
|
||||||
"url base",
|
"url base",
|
||||||
unique=True,
|
unique=True,
|
||||||
help_text=_(
|
help_text=_("The base url that links with this type must respect"),
|
||||||
"The base url that links with this type must respect (e.g. `%(url)s`)"
|
|
||||||
)
|
|
||||||
% {"url": "https://www.instagram.com"},
|
|
||||||
)
|
)
|
||||||
icon = models.CharField(
|
icon = models.CharField(
|
||||||
_("icon"),
|
_("icon"),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from datetime import date
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
@@ -79,3 +80,9 @@ class UserMembershipSchema(ModelSchema):
|
|||||||
|
|
||||||
club: SimpleClubSchema
|
club: SimpleClubSchema
|
||||||
role: ClubRoleSchema
|
role: ClubRoleSchema
|
||||||
|
user: SimpleUserSchema
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipFilterSchema(FilterSchema):
|
||||||
|
since_date: Annotated[date, FilterLookup("date__lte")]
|
||||||
|
clubs_id: set[int] | None = None
|
||||||
|
|||||||
@@ -45,3 +45,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 575px){
|
||||||
|
#club-list{
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
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,6 +4,7 @@ import pytest
|
|||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.timezone import now
|
||||||
from model_bakery import baker, seq
|
from model_bakery import baker, seq
|
||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
from pytest_django.asserts import assertRedirects
|
from pytest_django.asserts import assertRedirects
|
||||||
@@ -239,7 +240,7 @@ class TestClubRoleUpdate(TestCase):
|
|||||||
|
|
||||||
def test_president_moves_itself_out_of_the_presidency(self):
|
def test_president_moves_itself_out_of_the_presidency(self):
|
||||||
"""Test that if the user moves its own role out of the presidency,
|
"""Test that if the user moves its own role out of the presidency,
|
||||||
then it's redirected to another page and loses access to the update page."""
|
then it loses access to the update page."""
|
||||||
self.payload["roles-0-is_presidency"] = False
|
self.payload["roles-0-is_presidency"] = False
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
res = self.client.post(self.url, data=self.payload)
|
res = self.client.post(self.url, data=self.payload)
|
||||||
@@ -251,3 +252,29 @@ class TestClubRoleUpdate(TestCase):
|
|||||||
|
|
||||||
res = self.client.get(self.url)
|
res = self.client.get(self.url)
|
||||||
assert res.status_code == 403
|
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,4 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from aemark import markdown
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from django.test import Client
|
from django.test import Client
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -7,7 +8,6 @@ from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
|||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, ClubRole, Membership
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.markdown import markdown
|
|
||||||
from core.models import PageRev, User
|
from core.models import PageRev, User
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,13 +1,13 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
|
from aemark import markdown
|
||||||
from ninja import FilterLookup, FilterSchema, ModelSchema
|
from ninja import FilterLookup, FilterSchema, ModelSchema
|
||||||
from ninja_extra import service_resolver
|
from ninja_extra import service_resolver
|
||||||
from ninja_extra.context import RouteContext
|
from ninja_extra.context import RouteContext
|
||||||
|
|
||||||
from club.schemas import ClubProfileSchema
|
from club.schemas import ClubProfileSchema
|
||||||
from com.models import News, NewsDate
|
from com.models import News, NewsDate
|
||||||
from core.markdown import markdown
|
|
||||||
|
|
||||||
|
|
||||||
class NewsDateFilterSchema(FilterSchema):
|
class NewsDateFilterSchema(FilterSchema):
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from datetime import timedelta
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from aemark import markdown
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
@@ -13,7 +14,6 @@ from pytest_django.asserts import assertNumQueries
|
|||||||
|
|
||||||
from com.ics_calendar import IcsCalendar
|
from com.ics_calendar import IcsCalendar
|
||||||
from com.models import News, NewsDate
|
from com.models import News, NewsDate
|
||||||
from core.markdown import markdown
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -170,7 +170,7 @@ class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
|
|||||||
form_class = NewsForm
|
form_class = NewsForm
|
||||||
template_name = "com/news_edit.jinja"
|
template_name = "com/news_edit.jinja"
|
||||||
pk_url_kwarg = "news_id"
|
pk_url_kwarg = "news_id"
|
||||||
permission_required = "com.edit_news"
|
permission_required = "com.change_news"
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
response = super().form_valid(form) # Does the saving part
|
response = super().form_valid(form) # Does the saving part
|
||||||
|
|||||||
+2
-2
@@ -99,9 +99,9 @@ class PageAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(SithFile)
|
@admin.register(SithFile)
|
||||||
class SithFileAdmin(admin.ModelAdmin):
|
class SithFileAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "owner", "size", "date")
|
list_display = ("name", "owner", "size", "date", "is_in_sas")
|
||||||
autocomplete_fields = ("parent", "owner", "moderator")
|
autocomplete_fields = ("parent", "owner", "moderator")
|
||||||
search_fields = ("name",)
|
search_fields = ("name", "parent__name")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(OperationLog)
|
@admin.register(OperationLog)
|
||||||
|
|||||||
+1
-1
@@ -110,7 +110,7 @@ class SithFileController(ControllerBase):
|
|||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
def search_files(self, search: Annotated[str, MinLen(1)]):
|
def search_files(self, search: Annotated[str, MinLen(1)]):
|
||||||
return SithFile.objects.filter(name__icontains=search)
|
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
|
||||||
|
|
||||||
|
|
||||||
@api_controller("/group")
|
@api_controller("/group")
|
||||||
|
|||||||
+35
-36
@@ -2,12 +2,9 @@
|
|||||||
<h1>Markdown-AE Documentation</h1>
|
<h1>Markdown-AE Documentation</h1>
|
||||||
<p>Le Markdown le plus standard se trouve documenté ici:
|
<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 />
|
<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>
|
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/lepture/mistune">mistune</a>.<br />
|
<p>Le réel parseur du site AE est une version tunée de <a href="https://github.com/kivikakk/comrak">comrak</a>.</p>
|
||||||
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>
|
<h2>Basique</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Mettre le texte en <strong>gras</strong> : <code>**texte**</code></li>
|
<li>Mettre le texte en <strong>gras</strong> : <code>**texte**</code></li>
|
||||||
@@ -15,8 +12,8 @@ En pratique, cette page devrait déjà résumer une bonne partie.</p>
|
|||||||
<li><u>Souligner</u> le texte : <code>__texte__</code></li>
|
<li><u>Souligner</u> le texte : <code>__texte__</code></li>
|
||||||
<li><del>Barrer du texte</del> : <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>On peut bien sûr tout <del><em><strong><u>combiner</u></strong></em></del> : <code>~~***__texte__***~~</code></li>
|
||||||
<li>Mettre du texte^en exposant^ : <code><sup>texte</sup></code></li>
|
<li>Mettre du texte<sup>en exposant</sup> : <code><sup>texte</sup></code></li>
|
||||||
<li>Mettre du texte~en indice~ : <code><sub>texte</sub></code></li>
|
<li>Mettre du texte<sub>en indice</sub> : <code><sub>texte</sub></code></li>
|
||||||
</ul>
|
</ul>
|
||||||
<h2>Liens</h2>
|
<h2>Liens</h2>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -28,10 +25,10 @@ En pratique, cette page devrait déjà résumer une bonne partie.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
<p><a href="http://www.site.com">nom du lien</a></p>
|
<p><a href="http://www.site.com">nom du lien</a></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Les liens peuvent être internes au site de l'AE, on peut dès lors éviter d'entrer
|
<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>
|
l’adresse complète d’une page : <code>[nom du lien](page://nomDeLaPage)</code></li>
|
||||||
</ul>
|
</ul>
|
||||||
<p><a href="/page/nomDeLaPage/">nom du lien</a></p>
|
<p><a href="/page/nomDeLaPage">nom du lien</a></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>On peut également utiliser une image pour les liens :
|
<li>On peut également utiliser une image pour les liens :
|
||||||
<code>[nom du lien](options)</code></li>
|
<code>[nom du lien](options)</code></li>
|
||||||
@@ -94,25 +91,25 @@ etc...
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Titre</th>
|
<th>Titre</th>
|
||||||
<th>Titre2</th>
|
<th>Titre2</th>
|
||||||
<th>Titre3</th>
|
<th>Titre3</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>test</td>
|
<td>test</td>
|
||||||
<td>test</td>
|
<td>test</td>
|
||||||
<td>test</td>
|
<td>test</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>test</td>
|
<td>test</td>
|
||||||
<td>test</td>
|
<td>test</td>
|
||||||
<td>test</td>
|
<td>test</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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 |
|
<pre><code>| Titre | Titre2 | Titre3 |
|
||||||
|:-------|:------:|-------:|
|
|:-------|:------:|-------:|
|
||||||
| gauche | centre | droite |
|
| gauche | centre | droite |
|
||||||
@@ -120,16 +117,16 @@ etc...
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="text-align:left">Titre</th>
|
<th align="left">Titre</th>
|
||||||
<th style="text-align:center">Titre2</th>
|
<th align="center">Titre2</th>
|
||||||
<th style="text-align:right">Titre3</th>
|
<th align="right">Titre3</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="text-align:left">gauche</td>
|
<td align="left">gauche</td>
|
||||||
<td style="text-align:center">centre</td>
|
<td align="center">centre</td>
|
||||||
<td style="text-align:right">droite</td>
|
<td align="right">droite</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -141,11 +138,11 @@ etc...
|
|||||||

|

|
||||||

|

|
||||||
</code></pre>
|
</code></pre>
|
||||||
<p><img src="/static/core/img/logo.png" alt="image à 50%" title="Image à 50%" style="width:50%;" /><br />
|
<p><img src="/static/core/img/logo.png" style="width:50%" alt="image à 50%" title="Image à 50%" /><br />
|
||||||
Image à 50% de la largeur de la page.</p>
|
Image à 50% de la largeur de la page.</p>
|
||||||
<p><img src="/static/core/img/logo.png" alt="image de 350 pixels de large" title="Image de 350 pixels" style="width:350px;" /><br />
|
<p><img src="/static/core/img/logo.png" style="width:350px" alt="image de 350 pixels de large" title="Image de 350 pixels" /><br />
|
||||||
Image de 350 pixels de large.</p>
|
Image de 350 pixels de large.</p>
|
||||||
<p><img src="/static/core/img/logo.png" alt="image de 350x100 pixels" title="Image de 350x100 pixels" style="width:350px;height:100px;" /><br />
|
<p><img src="/static/core/img/logo.png" style="width:350px;height:100px" alt="image de 350x100 pixels" title="Image de 350x100 pixels" /><br />
|
||||||
Image de 350x100 pixels.</p>
|
Image de 350x100 pixels.</p>
|
||||||
<p>(devrait pouvoir détecter si vidéo ou non)</p>
|
<p>(devrait pouvoir détecter si vidéo ou non)</p>
|
||||||
<h2>Blocs de citations</h2>
|
<h2>Blocs de citations</h2>
|
||||||
@@ -159,9 +156,9 @@ Image de 350x100 pixels.</p>
|
|||||||
un bloc de
|
un bloc de
|
||||||
citation</p>
|
citation</p>
|
||||||
</blockquote>
|
</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>
|
<h2>Note de bas de page</h2>
|
||||||
<p>On les crée comme ça<sup class="footnote-ref" id="fnref-1"><a href="#fn-1">1</a></sup>:</p>
|
<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>
|
||||||
<pre><code>Je fais une note[^clef].
|
<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
|
[^clef]: je note ensuite où je veux le contenu de ma clef qui apparaîtra quand même en bas
|
||||||
@@ -175,13 +172,15 @@ citation</p>
|
|||||||
</code></pre>
|
</code></pre>
|
||||||
<h2>Échapper des caractères</h2>
|
<h2>Échapper des caractères</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Il est possible d'ignorer un caractère spécial en l'échappant à l'aide d'un \</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>
|
<li>L’échappement de blocs de codes complet se fera à l’aide de balises <nosyntax></nosyntax></li>
|
||||||
</ul>
|
</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>
|
<p>Une ligne peut être créée avec une ligne contenant 4 tirets (<code>----</code>).</p>
|
||||||
<section class="footnotes">
|
<section class="footnotes" data-footnotes>
|
||||||
<ol>
|
<ol>
|
||||||
<li id="fn-1"><p>ceci est le contenu de ma clef<a href="#fnref-1" class="footnote">↩</a></p></li>
|
<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>
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ https://www.markdownguide.org/basic-syntax.
|
|||||||
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.
|
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 [mistune](https://github.com/lepture/mistune).
|
Le réel parseur du site AE est une version tunée de [comrak](https://github.com/kivikakk/comrak).
|
||||||
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
|
## Basique
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,10 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
|
|
||||||
|
from aemark import markdown
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from core.markdown import markdown
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Output the fully rendered SYNTAX.md file"
|
help = "Output the fully rendered SYNTAX.md file"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
from datetime import date, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import ClassVar, NamedTuple
|
from typing import ClassVar, NamedTuple
|
||||||
@@ -33,7 +33,8 @@ from django.core.management.base import BaseCommand
|
|||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.timezone import localdate
|
from django.utils.lorem_ipsum import paragraphs
|
||||||
|
from django.utils.timezone import localdate, now
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from club.models import Club, ClubLink, ClubRole, LinkType, Membership
|
from club.models import Club, ClubLink, ClubRole, LinkType, Membership
|
||||||
@@ -43,13 +44,14 @@ from core.models import BanGroup, Group, Page, PageRev, SithFile, User
|
|||||||
from core.utils import resize_image
|
from core.utils import resize_image
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
Counter,
|
Counter,
|
||||||
|
CounterSellers,
|
||||||
Price,
|
Price,
|
||||||
Product,
|
Product,
|
||||||
ProductType,
|
ProductType,
|
||||||
ReturnableProduct,
|
ReturnableProduct,
|
||||||
StudentCard,
|
StudentCard,
|
||||||
)
|
)
|
||||||
from election.models import Candidature, Election, ElectionList, Role
|
from election.models import Candidature, Election, ElectionList, Role, Vote
|
||||||
from forum.models import Forum
|
from forum.models import Forum
|
||||||
from pedagogy.models import UE
|
from pedagogy.models import UE
|
||||||
from sas.models import Album, PeoplePictureRelation, Picture
|
from sas.models import Album, PeoplePictureRelation, Picture
|
||||||
@@ -124,9 +126,8 @@ class Command(BaseCommand):
|
|||||||
p.save(force_lock=True)
|
p.save(force_lock=True)
|
||||||
|
|
||||||
club_root = SithFile.objects.create(name="clubs", owner=root)
|
club_root = SithFile.objects.create(name="clubs", owner=root)
|
||||||
sas = SithFile.objects.create(name="SAS", owner=root)
|
sas = SithFile.objects.create(
|
||||||
main_club = Club.objects.create(
|
name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID
|
||||||
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
|
|
||||||
)
|
)
|
||||||
clubs = self._create_clubs()
|
clubs = self._create_clubs()
|
||||||
|
|
||||||
@@ -365,62 +366,15 @@ class Command(BaseCommand):
|
|||||||
Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE")
|
Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE")
|
||||||
|
|
||||||
# Add barman to counter
|
# Add barman to counter
|
||||||
Counter.sellers.through.objects.bulk_create(
|
CounterSellers.objects.bulk_create(
|
||||||
[
|
[
|
||||||
Counter.sellers.through(counter_id=1, user=skia), # MDE
|
CounterSellers(counter_id=1, user=skia, is_regular=True), # MDE
|
||||||
Counter.sellers.through(counter_id=2, user=krophil), # Foyer
|
CounterSellers(counter_id=2, user=krophil, is_regular=True), # Foyer
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create an election
|
# Create an election
|
||||||
el = Election.objects.create(
|
self._create_elections(groups, clubs, skia, sli, krophil)
|
||||||
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
|
# Forum
|
||||||
room = Forum.objects.create(
|
room = Forum.objects.create(
|
||||||
@@ -576,20 +530,32 @@ class Command(BaseCommand):
|
|||||||
# SAS
|
# SAS
|
||||||
for f in self.SAS_FIXTURE_PATH.glob("*"):
|
for f in self.SAS_FIXTURE_PATH.glob("*"):
|
||||||
if f.is_dir():
|
if f.is_dir():
|
||||||
album = Album.objects.create(name=f.name, is_moderated=True)
|
album = Album(
|
||||||
|
parent=sas,
|
||||||
|
name=f.name,
|
||||||
|
owner=root,
|
||||||
|
is_folder=True,
|
||||||
|
is_in_sas=True,
|
||||||
|
is_moderated=True,
|
||||||
|
)
|
||||||
|
album.clean()
|
||||||
|
album.save()
|
||||||
for p in f.iterdir():
|
for p in f.iterdir():
|
||||||
file = resize_image(Image.open(p), 1000, "WEBP")
|
file = resize_image(Image.open(p), 1000, "WEBP")
|
||||||
pict = Picture(
|
pict = Picture(
|
||||||
parent=album,
|
parent=album,
|
||||||
name=p.name,
|
name=p.name,
|
||||||
original=file,
|
file=file,
|
||||||
owner=root,
|
owner=root,
|
||||||
|
is_folder=False,
|
||||||
|
is_in_sas=True,
|
||||||
is_moderated=True,
|
is_moderated=True,
|
||||||
|
mime_type="image/webp",
|
||||||
|
size=file.size,
|
||||||
)
|
)
|
||||||
pict.original.name = pict.name
|
pict.file.name = p.name
|
||||||
pict.generate_thumbnails()
|
pict.full_clean()
|
||||||
pict.full_clean(save=True)
|
pict.generate_thumbnails(save=True)
|
||||||
album.generate_thumbnail()
|
|
||||||
|
|
||||||
img_skia = Picture.objects.get(name="skia.jpg")
|
img_skia = Picture.objects.get(name="skia.jpg")
|
||||||
img_sli = Picture.objects.get(name="sli.jpg")
|
img_sli = Picture.objects.get(name="sli.jpg")
|
||||||
@@ -999,3 +965,132 @@ class Command(BaseCommand):
|
|||||||
BanGroup.objects.create(name="Banned from buying alcohol", description="")
|
BanGroup.objects.create(name="Banned from buying alcohol", description="")
|
||||||
BanGroup.objects.create(name="Banned from counters", description="")
|
BanGroup.objects.create(name="Banned from counters", description="")
|
||||||
BanGroup.objects.create(name="Banned to subscribe", 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,132 +0,0 @@
|
|||||||
#
|
|
||||||
# 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",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Generated by Django 4.2.17 on 2025-01-26 15:01
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
from django.db.migrations.state import StateApps
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
import core.models
|
|
||||||
|
|
||||||
|
|
||||||
def remove_sas_sithfiles(apps: StateApps, schema_editor):
|
|
||||||
SithFile: type[core.models.SithFile] = apps.get_model("core", "SithFile")
|
|
||||||
SithFile.objects.filter(is_in_sas=True).delete()
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("core", "0048_alter_user_options"),
|
|
||||||
("sas", "0007_alter_peoplepicturerelation_picture_and_more"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RunPython(
|
|
||||||
remove_sas_sithfiles, reverse_code=migrations.RunPython.noop, elidable=True
|
|
||||||
)
|
|
||||||
]
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# Generated by Django 4.2.17 on 2025-02-14 11:58
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [("core", "0049_remove_sithfiles")]
|
|
||||||
|
|
||||||
operations = [migrations.RemoveField(model_name="sithfile", name="is_in_sas")]
|
|
||||||
@@ -876,6 +876,9 @@ class SithFile(models.Model):
|
|||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
)
|
)
|
||||||
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
|
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
|
||||||
|
is_in_sas = models.BooleanField(
|
||||||
|
_("is in the SAS"), default=False, db_index=True
|
||||||
|
) # Allows to query this flag, updated at each call to save()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("file")
|
verbose_name = _("file")
|
||||||
@@ -884,10 +887,24 @@ class SithFile(models.Model):
|
|||||||
return self.get_parent_path() + "/" + self.name
|
return self.get_parent_path() + "/" + self.name
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
sas_id = settings.SITH_SAS_ROOT_DIR_ID
|
||||||
|
self.is_in_sas = self.id == sas_id or any(
|
||||||
|
p.id == sas_id for p in self.get_parent_list()
|
||||||
|
)
|
||||||
adding = self._state.adding
|
adding = self._state.adding
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
if adding:
|
if adding:
|
||||||
self.copy_rights()
|
self.copy_rights()
|
||||||
|
if self.is_in_sas:
|
||||||
|
for user in User.objects.filter(
|
||||||
|
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
|
||||||
|
):
|
||||||
|
Notification(
|
||||||
|
user=user,
|
||||||
|
url=reverse("sas:moderation"),
|
||||||
|
type="SAS_MODERATION",
|
||||||
|
param="1",
|
||||||
|
).save()
|
||||||
|
|
||||||
def is_owned_by(self, user: User) -> bool:
|
def is_owned_by(self, user: User) -> bool:
|
||||||
if user.is_anonymous:
|
if user.is_anonymous:
|
||||||
@@ -900,6 +917,8 @@ class SithFile(models.Model):
|
|||||||
return user.is_board_member
|
return user.is_board_member
|
||||||
if user.is_com_admin:
|
if user.is_com_admin:
|
||||||
return True
|
return True
|
||||||
|
if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
|
||||||
|
return True
|
||||||
return user.id == self.owner_id
|
return user.id == self.owner_id
|
||||||
|
|
||||||
def can_be_viewed_by(self, user: User) -> bool:
|
def can_be_viewed_by(self, user: User) -> bool:
|
||||||
@@ -926,6 +945,8 @@ class SithFile(models.Model):
|
|||||||
super().clean()
|
super().clean()
|
||||||
if "/" in self.name:
|
if "/" in self.name:
|
||||||
raise ValidationError(_("Character '/' not authorized in name"))
|
raise ValidationError(_("Character '/' not authorized in name"))
|
||||||
|
if self == self.parent:
|
||||||
|
raise ValidationError(_("Loop in folder tree"), code="loop")
|
||||||
if self == self.parent or (
|
if self == self.parent or (
|
||||||
self.parent is not None and self in self.get_parent_list()
|
self.parent is not None and self in self.get_parent_list()
|
||||||
):
|
):
|
||||||
@@ -1006,6 +1027,18 @@ class SithFile(models.Model):
|
|||||||
def is_file(self):
|
def is_file(self):
|
||||||
return not self.is_folder
|
return not self.is_folder
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def as_picture(self):
|
||||||
|
from sas.models import Picture
|
||||||
|
|
||||||
|
return Picture.objects.filter(id=self.id).first()
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def as_album(self):
|
||||||
|
from sas.models import Album
|
||||||
|
|
||||||
|
return Album.objects.filter(id=self.id).first()
|
||||||
|
|
||||||
def get_parent_list(self):
|
def get_parent_list(self):
|
||||||
parents = []
|
parents = []
|
||||||
current = self.parent
|
current = self.parent
|
||||||
|
|||||||
-1
@@ -55,7 +55,6 @@ declare interface NDEFRecordInit {
|
|||||||
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
declare type NDEFMessageSource = string | BufferSource | NDEFMessageInit;
|
declare type NDEFMessageSource = string | BufferSource | NDEFMessageInit;
|
||||||
|
|
||||||
// biome-ignore lint/correctness/noUnusedVariables: this is the official definition
|
|
||||||
interface Window {
|
interface Window {
|
||||||
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
NDEFReader: NDEFReader;
|
NDEFReader: NDEFReader;
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ details.accordion>.accordion-content {
|
|||||||
border-bottom-right-radius: 3px;
|
border-bottom-right-radius: 3px;
|
||||||
border-bottom-left-radius: 3px;
|
border-bottom-left-radius: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
@media screen and (max-width: 600px) {
|
||||||
|
padding: .75em 1.5em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin animation($selector) {
|
@mixin animation($selector) {
|
||||||
|
|||||||
@@ -29,7 +29,12 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
|
||||||
&.clickable:hover {
|
&:disabled {
|
||||||
|
background-color: darken($primary-neutral-light-color, 5%);
|
||||||
|
opacity: 65%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.clickable:not(:disabled):hover {
|
||||||
background-color: darken($primary-neutral-light-color, 5%);
|
background-color: darken($primary-neutral-light-color, 5%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
color: black;
|
color: black;
|
||||||
|
|
||||||
&:hover {
|
&:not(.link-like):not(:disabled):hover {
|
||||||
background: hsl(0, 0%, 83%);
|
background: hsl(0, 0%, 83%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,7 +141,6 @@ form {
|
|||||||
display: block;
|
display: block;
|
||||||
margin: calc(var(--nf-input-size) * 1.5) auto 10px;
|
margin: calc(var(--nf-input-size) * 1.5) auto 10px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
.fields-centered {
|
.fields-centered {
|
||||||
padding: 10px 10px 0;
|
padding: 10px 10px 0;
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ $background-color-hovered: #283747;
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
>.button {
|
a.button {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
height: 35px;
|
height: 35px;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
@@ -139,7 +139,7 @@ $background-color-hovered: #283747;
|
|||||||
font-size: .9em;
|
font-size: .9em;
|
||||||
width: 120px;
|
width: 120px;
|
||||||
|
|
||||||
&:hover {
|
&:not(.link-like):not(:disabled):hover {
|
||||||
background-color: $background-color-hovered;
|
background-color: $background-color-hovered;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,9 @@
|
|||||||
</form>
|
</form>
|
||||||
<ul class="bars">
|
<ul class="bars">
|
||||||
{% cache 100 "counters_activity" %}
|
{% cache 100 "counters_activity" %}
|
||||||
{# The sith has no periodic tasks manager
|
{# It would be cleaner to handle the timeout with django-celery-beat,
|
||||||
and using cron jobs would be way too overkill here.
|
but doing it here is simpler and less error-prone #}
|
||||||
Thus the barmen timeout is handled in the only place that
|
{% do Counter.objects.filter(type="BAR").handle_timeout() %}
|
||||||
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 %}
|
{% endcache %}
|
||||||
{% for bar in Counter.objects.annotate_has_barman(user).annotate_is_open().filter(type="BAR") %}
|
{% for bar in Counter.objects.annotate_has_barman(user).annotate_is_open().filter(type="BAR") %}
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<template x-for="(message, index) in $notifications.getAll()">
|
<template x-for="(message, index) in $notifications.getAll()">
|
||||||
<div class="alert" :class="`alert-${message.tag}`" x-transition>
|
<div class="alert" :class="`alert-${message.tag}`" x-transition>
|
||||||
<span class="alert-main" x-text="message.text"></span>
|
<span class="alert-main" x-text="message.text"></span>
|
||||||
<span class="clickable" @click="messages = messages.filter((item, i) => i !== index)">
|
<span class="clickable" @click="$store.notifications = $store.notifications.filter((item, i) => i !== index)">
|
||||||
<i class="fa fa-close"></i>
|
<i class="fa fa-close"></i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,14 +25,13 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
import phonenumbers
|
import phonenumbers
|
||||||
|
from aemark import markdown as md
|
||||||
from django import template
|
from django import template
|
||||||
from django.forms import BoundField
|
from django.forms import BoundField
|
||||||
from django.template.defaultfilters import stringfilter
|
from django.template.defaultfilters import stringfilter
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import ngettext
|
from django.utils.translation import ngettext
|
||||||
|
|
||||||
from core.markdown import markdown as md
|
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+9
-12
@@ -18,6 +18,7 @@ from smtplib import SMTPException
|
|||||||
|
|
||||||
import freezegun
|
import freezegun
|
||||||
import pytest
|
import pytest
|
||||||
|
from aemark import markdown
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from django.contrib.auth.hashers import make_password
|
from django.contrib.auth.hashers import make_password
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
@@ -34,7 +35,6 @@ from pytest_django.asserts import assertInHTML, assertRedirects
|
|||||||
from antispam.models import ToxicDomain
|
from antispam.models import ToxicDomain
|
||||||
from club.models import Club
|
from club.models import Club
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.markdown import markdown
|
|
||||||
from core.models import AnonymousUser, Group, Page, User, validate_promo
|
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.utils import get_last_promo, get_semester_code, get_start_of_semester
|
||||||
from core.views import AllowFragment
|
from core.views import AllowFragment
|
||||||
@@ -200,31 +200,28 @@ class TestUserLogin:
|
|||||||
[
|
[
|
||||||
(
|
(
|
||||||
"[nom du lien](page://nomDeLaPage)",
|
"[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__", "<u>texte</u>"),
|
||||||
("~~***__texte__***~~", "<del><em><strong><u>texte</u></strong></em></del>"),
|
("~~***__texte__***~~", "<del><em><strong><u>texte</u></strong></em></del>"),
|
||||||
(
|
(
|
||||||
'',
|
'',
|
||||||
'<img src="/img.png" alt="tst_alt" title="tst_title" style="width:50%;" />',
|
'<img src="/img.png" style="width:50%" alt="tst_alt" title="tst_title" />',
|
||||||
),
|
|
||||||
(
|
|
||||||
"[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" alt="" style="width:50px;height:450px;" />',
|
'<img src="/img.png" style="width:50px;height:450px" alt="" />',
|
||||||
),
|
),
|
||||||
("", '<img src="/img.png" alt="" />'),
|
("", '<img src="/img.png" alt="" />'),
|
||||||
(
|
(
|
||||||
"",
|
"",
|
||||||
'<img src="/img.png" alt="" style="width:50%;height:120%;" />',
|
'<img src="/img.png" style="width:50%;height:120%" alt="" />',
|
||||||
),
|
),
|
||||||
("", '<img src="/img.png" alt="" style="width:50px;" />'),
|
("", '<img src="/img.png" style="width:50px" alt="" />'),
|
||||||
(
|
(
|
||||||
"",
|
"",
|
||||||
'<img src="/img.png" alt="" style="width:50px;height:120%;" />',
|
'<img src="/img.png" style="width:50px;height:120%" alt="" />',
|
||||||
),
|
),
|
||||||
# when the image dimension has a wrong format, don't touch the url
|
# when the image dimension has a wrong format, don't touch the url
|
||||||
("", '<img src="/img.png?50pxxxxxxxx" alt="" />'),
|
("", '<img src="/img.png?50pxxxxxxxx" alt="" />'),
|
||||||
@@ -350,7 +347,7 @@ http://git.an
|
|||||||
<p><a href="http://git.an">http://git.an</a></p>
|
<p><a href="http://git.an">http://git.an</a></p>
|
||||||
<h1>Swag</h1>
|
<h1>Swag</h1>
|
||||||
<p><guy>Bibou</guy></p>
|
<p><guy>Bibou</guy></p>
|
||||||
<p><script>alert('Guy');</script></p>
|
<script>alert('Guy');</script>
|
||||||
"""
|
"""
|
||||||
assertInHTML(expected, response.text)
|
assertInHTML(expected, response.text)
|
||||||
|
|
||||||
|
|||||||
+11
-41
@@ -5,7 +5,6 @@ from typing import Callable
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.conf import settings
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
@@ -18,8 +17,8 @@ from pytest_django.asserts import assertNumQueries
|
|||||||
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
|
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
|
||||||
from core.models import Group, QuickUploadImage, SithFile, User
|
from core.models import Group, QuickUploadImage, SithFile, User
|
||||||
from core.utils import RED_PIXEL_PNG
|
from core.utils import RED_PIXEL_PNG
|
||||||
from sas.baker_recipes import picture_recipe
|
|
||||||
from sas.models import Picture
|
from sas.models import Picture
|
||||||
|
from sith import settings
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -31,19 +30,24 @@ class TestImageAccess:
|
|||||||
lambda: baker.make(
|
lambda: baker.make(
|
||||||
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
|
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
|
||||||
),
|
),
|
||||||
|
lambda: baker.make(
|
||||||
|
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_sas_image_access(self, user_factory: Callable[[], User]):
|
def test_sas_image_access(self, user_factory: Callable[[], User]):
|
||||||
"""Test that only authorized users can access the sas image."""
|
"""Test that only authorized users can access the sas image."""
|
||||||
user = user_factory()
|
user = user_factory()
|
||||||
picture = picture_recipe.make()
|
picture: SithFile = baker.make(
|
||||||
assert user.can_edit(picture)
|
Picture, parent=SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID)
|
||||||
|
)
|
||||||
|
assert picture.is_owned_by(user)
|
||||||
|
|
||||||
def test_sas_image_access_owner(self):
|
def test_sas_image_access_owner(self):
|
||||||
"""Test that the owner of the image can access it."""
|
"""Test that the owner of the image can access it."""
|
||||||
user = baker.make(User)
|
user = baker.make(User)
|
||||||
picture = picture_recipe.make(owner=user)
|
picture: Picture = baker.make(Picture, owner=user)
|
||||||
assert user.can_edit(picture)
|
assert picture.is_owned_by(user)
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"user_factory",
|
"user_factory",
|
||||||
@@ -59,41 +63,7 @@ class TestImageAccess:
|
|||||||
user = user_factory()
|
user = user_factory()
|
||||||
owner = baker.make(User)
|
owner = baker.make(User)
|
||||||
picture: Picture = baker.make(Picture, owner=owner)
|
picture: Picture = baker.make(Picture, owner=owner)
|
||||||
assert not user.can_edit(picture)
|
assert not picture.is_owned_by(user)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestUserPicture:
|
|
||||||
def test_anonymous_user_unauthorized(self, client):
|
|
||||||
"""An anonymous user shouldn't have access to an user's photo page."""
|
|
||||||
response = client.get(
|
|
||||||
reverse(
|
|
||||||
"sas:user_pictures",
|
|
||||||
kwargs={"user_id": User.objects.get(username="sli").pk},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("username", "status"),
|
|
||||||
[
|
|
||||||
("guy", 403),
|
|
||||||
("root", 200),
|
|
||||||
("skia", 200),
|
|
||||||
("sli", 200),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_page_is_working(self, client, username, status):
|
|
||||||
"""Only user that subscribed (or admins) should be able to see the page."""
|
|
||||||
# Test for simple user
|
|
||||||
client.force_login(User.objects.get(username=username))
|
|
||||||
response = client.get(
|
|
||||||
reverse(
|
|
||||||
"sas:user_pictures",
|
|
||||||
kwargs={"user_id": User.objects.get(username="sli").pk},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert response.status_code == status
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: many tests on the pages:
|
# TODO: many tests on the pages:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from datetime import timedelta
|
|||||||
|
|
||||||
import freezegun
|
import freezegun
|
||||||
import pytest
|
import pytest
|
||||||
|
from aemark import markdown
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
@@ -13,7 +14,6 @@ from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
|||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, Membership
|
||||||
from core.baker_recipes import board_user, subscriber_user
|
from core.baker_recipes import board_user, subscriber_user
|
||||||
from core.markdown import markdown
|
|
||||||
from core.models import AnonymousUser, Page, PageRev, User
|
from core.models import AnonymousUser, Page, PageRev, User
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ from counter.baker_recipes import sale_recipe
|
|||||||
from counter.models import Counter, Customer, Permanency, Refilling, Selling
|
from counter.models import Counter, Customer, Permanency, Refilling, Selling
|
||||||
from counter.utils import is_logged_in_counter
|
from counter.utils import is_logged_in_counter
|
||||||
from eboutic.models import Invoice, InvoiceItem
|
from eboutic.models import Invoice, InvoiceItem
|
||||||
from sas.models import Picture
|
|
||||||
|
|
||||||
|
|
||||||
class TestSearchUsers(TestCase):
|
class TestSearchUsers(TestCase):
|
||||||
@@ -35,7 +34,7 @@ class TestSearchUsers(TestCase):
|
|||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
# News.author has on_delete=PROTECT, so news must be deleted beforehand
|
# News.author has on_delete=PROTECT, so news must be deleted beforehand
|
||||||
News.objects.all().delete()
|
News.objects.all().delete()
|
||||||
Picture.objects.all().delete() # same for pictures
|
SithFile.objects.all().delete()
|
||||||
User.objects.all().delete()
|
User.objects.all().delete()
|
||||||
user_recipe = Recipe(
|
user_recipe = Recipe(
|
||||||
User,
|
User,
|
||||||
@@ -201,7 +200,11 @@ class TestFilterInactive(TestCase):
|
|||||||
]
|
]
|
||||||
sale_recipe.make(customer=cls.users[3].customer, date=time_active)
|
sale_recipe.make(customer=cls.users[3].customer, date=time_active)
|
||||||
baker.make(
|
baker.make(
|
||||||
Refilling, customer=cls.users[4].customer, date=time_active, counter=counter
|
Refilling,
|
||||||
|
customer=cls.users[4].customer,
|
||||||
|
date=time_active,
|
||||||
|
counter=counter,
|
||||||
|
amount=1,
|
||||||
)
|
)
|
||||||
sale_recipe.make(customer=cls.users[5].customer, date=time_inactive)
|
sale_recipe.make(customer=cls.users[5].customer, date=time_inactive)
|
||||||
|
|
||||||
@@ -456,7 +459,9 @@ def test_user_preferences(client: Client):
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_user_stats(client: Client):
|
def test_user_stats(client: Client):
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
baker.make(Refilling, customer=user.customer, amount=99999)
|
baker.make(
|
||||||
|
Refilling, customer=user.customer, amount=settings.SITH_ACCOUNT_MAX_MONEY
|
||||||
|
)
|
||||||
bars = [b[0] for b in settings.SITH_COUNTER_BARS]
|
bars = [b[0] for b in settings.SITH_COUNTER_BARS]
|
||||||
baker.make(
|
baker.make(
|
||||||
Permanency,
|
Permanency,
|
||||||
|
|||||||
+3
-76
@@ -12,23 +12,18 @@
|
|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
# Image utils
|
# Image utils
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Any, Final, Unpack
|
from typing import Final
|
||||||
|
|
||||||
import PIL
|
import PIL
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.files.uploadedfile import UploadedFile
|
from django.core.files.uploadedfile import UploadedFile
|
||||||
from django.db import models
|
from django.http import HttpRequest
|
||||||
from django.forms import BaseForm
|
|
||||||
from django.http import Http404, HttpRequest
|
|
||||||
from django.shortcuts import get_list_or_404
|
|
||||||
from django.template.loader import render_to_string
|
|
||||||
from django.utils.safestring import SafeString
|
|
||||||
from django.utils.timezone import localdate
|
from django.utils.timezone import localdate
|
||||||
from PIL.Image import Image, Resampling
|
from PIL.Image import Image, Resampling
|
||||||
|
|
||||||
@@ -46,21 +41,6 @@ to generate a dummy image that is considered valid nonetheless
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FormFragmentTemplateData[T: BaseForm]:
|
|
||||||
"""Dataclass used to pre-render form fragments"""
|
|
||||||
|
|
||||||
form: T
|
|
||||||
template: str
|
|
||||||
context: dict[str, Any]
|
|
||||||
|
|
||||||
def render(self, request: HttpRequest) -> SafeString:
|
|
||||||
# Request is needed for csrf_tokens
|
|
||||||
return render_to_string(
|
|
||||||
self.template, context={"form": self.form, **self.context}, request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_start_of_semester(today: date | None = None) -> date:
|
def get_start_of_semester(today: date | None = None) -> date:
|
||||||
"""Return the date of the start of the semester of the given date.
|
"""Return the date of the start of the semester of the given date.
|
||||||
If no date is given, return the start date of the current semester.
|
If no date is given, return the start date of the current semester.
|
||||||
@@ -208,56 +188,3 @@ def get_client_ip(request: HttpRequest) -> str | None:
|
|||||||
return ip
|
return ip
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
Filterable = type[models.Model] | models.QuerySet | models.Manager
|
|
||||||
ListFilter = dict[str, list | tuple | set]
|
|
||||||
|
|
||||||
|
|
||||||
def get_list_exact_or_404(klass: Filterable, **kwargs: Unpack[ListFilter]) -> list:
|
|
||||||
"""Use filter() to return a list of objects from a list of unique keys (like ids)
|
|
||||||
or raises Http404 if the list has not the same length as the given one.
|
|
||||||
|
|
||||||
Work like `get_object_or_404()` but for lists of objects, with some caveats :
|
|
||||||
|
|
||||||
- The filter must be a list, a tuple or a set.
|
|
||||||
- There can't be more than exactly one filter.
|
|
||||||
- There must be no duplicate in the filter.
|
|
||||||
- The filter should consist in unique keys (like ids), or it could fail randomly.
|
|
||||||
|
|
||||||
klass may be a Model, Manager, or QuerySet object. All other passed
|
|
||||||
arguments and keyword arguments are used in the filter() query.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
Http404: If the list is empty or doesn't have as many elements as the keys list.
|
|
||||||
ValueError: If the first argument is not a Model, Manager, or QuerySet object.
|
|
||||||
ValueError: If more than one filter is passed.
|
|
||||||
TypeError: If the given filter is not a list, a tuple or a set.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
Get all the products with ids 1, 2, 3: ::
|
|
||||||
|
|
||||||
products = get_list_exact_or_404(Product, id__in=[1, 2, 3])
|
|
||||||
|
|
||||||
Don't work with duplicate ids: ::
|
|
||||||
|
|
||||||
products = get_list_exact_or_404(Product, id__in=[1, 2, 3, 3])
|
|
||||||
# Raises Http404: "The list of keys must contain no duplicates."
|
|
||||||
"""
|
|
||||||
if len(kwargs) > 1:
|
|
||||||
raise ValueError("get_list_exact_or_404() only accepts one filter.")
|
|
||||||
key, list_filter = next(iter(kwargs.items()))
|
|
||||||
if not isinstance(list_filter, (list, tuple, set)):
|
|
||||||
raise TypeError(
|
|
||||||
f"The given filter must be a list, a tuple or a set, not {type(list_filter)}"
|
|
||||||
)
|
|
||||||
if len(list_filter) != len(set(list_filter)):
|
|
||||||
raise ValueError("The list of keys must contain no duplicates.")
|
|
||||||
kwargs = {key: list_filter}
|
|
||||||
obj_list = get_list_or_404(klass, **kwargs)
|
|
||||||
if len(obj_list) != len(list_filter):
|
|
||||||
raise Http404(
|
|
||||||
"The given list of keys doesn't match the number of objects found."
|
|
||||||
f"Expected {len(list_filter)} items, got {len(obj_list)}."
|
|
||||||
)
|
|
||||||
return obj_list
|
|
||||||
|
|||||||
+1
-1
@@ -374,7 +374,7 @@ class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
|
|||||||
class FileModerationView(AllowFragment, ListView):
|
class FileModerationView(AllowFragment, ListView):
|
||||||
model = SithFile
|
model = SithFile
|
||||||
template_name = "core/file_moderation.jinja"
|
template_name = "core/file_moderation.jinja"
|
||||||
queryset = SithFile.objects.filter(is_moderated=False)
|
queryset = SithFile.objects.filter(is_moderated=False, is_in_sas=False)
|
||||||
ordering = "id"
|
ordering = "id"
|
||||||
paginate_by = 100
|
paginate_by = 100
|
||||||
|
|
||||||
|
|||||||
+50
-4
@@ -1,22 +1,68 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core import checks
|
||||||
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.utils.functional import cached_property
|
||||||
|
|
||||||
|
|
||||||
class CurrencyField(models.DecimalField):
|
class CurrencyField(models.DecimalField):
|
||||||
"""Custom database field used for currency."""
|
"""Custom database field used for currency."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(
|
||||||
kwargs["max_digits"] = 12
|
self, verbose_name=None, name=None, min_value=None, max_value=None, **kwargs
|
||||||
kwargs["decimal_places"] = 2
|
):
|
||||||
super().__init__(*args, **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 to_python(self, value):
|
def to_python(self, value):
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
return super().to_python(value).quantize(Decimal("0.01"))
|
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:
|
if settings.TESTING:
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
|
|||||||
+91
-39
@@ -3,12 +3,16 @@ import math
|
|||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db.models import Exists, OuterRef, Q
|
from django.db.models import Exists, OuterRef, Q
|
||||||
from django.forms import BaseModelFormSet
|
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.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_celery_beat.models import ClockedSchedule
|
from django_celery_beat.models import ClockedSchedule
|
||||||
@@ -17,6 +21,7 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
|||||||
from club.models import Club
|
from club.models import Club
|
||||||
from club.widgets.ajax_select import AutoCompleteSelectClub
|
from club.widgets.ajax_select import AutoCompleteSelectClub
|
||||||
from core.models import User, UserQuerySet
|
from core.models import User, UserQuerySet
|
||||||
|
from core.views import LoginForm
|
||||||
from core.views.forms import (
|
from core.views.forms import (
|
||||||
FutureDateTimeField,
|
FutureDateTimeField,
|
||||||
NFCTextInput,
|
NFCTextInput,
|
||||||
@@ -37,6 +42,7 @@ from counter.models import (
|
|||||||
Customer,
|
Customer,
|
||||||
Eticket,
|
Eticket,
|
||||||
InvoiceCall,
|
InvoiceCall,
|
||||||
|
Permanency,
|
||||||
Price,
|
Price,
|
||||||
Product,
|
Product,
|
||||||
ProductFormula,
|
ProductFormula,
|
||||||
@@ -91,30 +97,18 @@ class StudentCardForm(forms.ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class GetUserForm(forms.Form):
|
class GetUserForm(forms.Form):
|
||||||
"""The Form class aims at providing a valid user_id field in its cleaned data, in order to pass it to some view,
|
"""Find a user to show its click page."""
|
||||||
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(
|
code = forms.CharField(
|
||||||
label="Code",
|
label="Code",
|
||||||
max_length=StudentCard.UID_SIZE,
|
max_length=StudentCard.UID_SIZE,
|
||||||
required=False,
|
required=False,
|
||||||
widget=NFCTextInput,
|
widget=NFCTextInput(attrs={"autofocus": True}),
|
||||||
)
|
)
|
||||||
id = forms.CharField(
|
id = forms.CharField(
|
||||||
label=_("Select user"),
|
label=_("Select user"), widget=AutoCompleteSelectUser, required=False
|
||||||
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):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
customer = None
|
customer = None
|
||||||
@@ -136,11 +130,41 @@ class GetUserForm(forms.Form):
|
|||||||
|
|
||||||
if customer is None or not customer.can_buy:
|
if customer is None or not customer.can_buy:
|
||||||
raise forms.ValidationError(_("User not found"))
|
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
|
cleaned_data["user"] = customer.user
|
||||||
return cleaned_data
|
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):
|
class RefillForm(forms.ModelForm):
|
||||||
allowed_refilling_methods = [
|
allowed_refilling_methods = [
|
||||||
Refilling.PaymentMethod.CASH,
|
Refilling.PaymentMethod.CASH,
|
||||||
@@ -149,18 +173,19 @@ class RefillForm(forms.ModelForm):
|
|||||||
|
|
||||||
error_css_class = "error"
|
error_css_class = "error"
|
||||||
required_css_class = "required"
|
required_css_class = "required"
|
||||||
amount = forms.FloatField(
|
|
||||||
min_value=0, widget=forms.NumberInput(attrs={"class": "focus"})
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Refilling
|
model = Refilling
|
||||||
fields = ["amount", "payment_method"]
|
fields = ["amount", "payment_method"]
|
||||||
widgets = {"payment_method": forms.RadioSelect}
|
widgets = {"payment_method": forms.RadioSelect}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(
|
||||||
|
self, *args, counter: Counter, operator: User, customer: Customer, **kwargs
|
||||||
|
):
|
||||||
super().__init__(*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 = (
|
self.fields["payment_method"].choices = (
|
||||||
method
|
method
|
||||||
for method in self.fields["payment_method"].choices
|
for method in self.fields["payment_method"].choices
|
||||||
@@ -168,6 +193,9 @@ class RefillForm(forms.ModelForm):
|
|||||||
)
|
)
|
||||||
if self.fields["payment_method"].initial not in self.allowed_refilling_methods:
|
if self.fields["payment_method"].initial not in self.allowed_refilling_methods:
|
||||||
self.fields["payment_method"].initial = self.allowed_refilling_methods[0]
|
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):
|
class CounterEditForm(forms.ModelForm):
|
||||||
@@ -409,6 +437,7 @@ class ProductForm(forms.ModelForm):
|
|||||||
"club",
|
"club",
|
||||||
"limit_age",
|
"limit_age",
|
||||||
"tray",
|
"tray",
|
||||||
|
"clic_limit",
|
||||||
"archived",
|
"archived",
|
||||||
]
|
]
|
||||||
help_texts = {
|
help_texts = {
|
||||||
@@ -540,16 +569,7 @@ class BasketItemForm(forms.Form):
|
|||||||
quantity = forms.IntegerField(min_value=1, required=True)
|
quantity = forms.IntegerField(min_value=1, required=True)
|
||||||
price_id = forms.IntegerField(min_value=0, required=True)
|
price_id = forms.IntegerField(min_value=0, required=True)
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, allowed_prices: dict[int, Price], *args, **kwargs):
|
||||||
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
|
self.allowed_prices = allowed_prices
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
@@ -584,6 +604,15 @@ class BasketItemForm(forms.Form):
|
|||||||
|
|
||||||
|
|
||||||
class BaseBasketForm(forms.BaseFormSet):
|
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):
|
def clean(self):
|
||||||
self.forms = [form for form in self.forms if form.cleaned_data != {}]
|
self.forms = [form for form in self.forms if form.cleaned_data != {}]
|
||||||
|
|
||||||
@@ -592,8 +621,8 @@ class BaseBasketForm(forms.BaseFormSet):
|
|||||||
|
|
||||||
self._check_forms_have_errors()
|
self._check_forms_have_errors()
|
||||||
self._check_product_are_unique()
|
self._check_product_are_unique()
|
||||||
self._check_recorded_products(self[0].customer)
|
self._check_recorded_products()
|
||||||
self._check_enough_money(self[0].counter, self[0].customer)
|
self._check_account_balance()
|
||||||
|
|
||||||
def _check_forms_have_errors(self):
|
def _check_forms_have_errors(self):
|
||||||
if any(len(form.errors) > 0 for form in self):
|
if any(len(form.errors) > 0 for form in self):
|
||||||
@@ -604,12 +633,35 @@ class BaseBasketForm(forms.BaseFormSet):
|
|||||||
if len(price_ids) != len(self.forms):
|
if len(price_ids) != len(self.forms):
|
||||||
raise forms.ValidationError(_("Duplicated product entries."))
|
raise forms.ValidationError(_("Duplicated product entries."))
|
||||||
|
|
||||||
def _check_enough_money(self, counter: Counter, customer: Customer):
|
@cached_property
|
||||||
self.total_price = sum([data["total_price"] for data in self.cleaned_data])
|
def total_price(self):
|
||||||
if self.total_price > customer.amount:
|
refill = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
||||||
raise forms.ValidationError(_("Not enough money"))
|
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_recorded_products(self, customer: Customer):
|
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
|
||||||
|
):
|
||||||
|
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):
|
||||||
"""Check for, among other things, ecocups and pitchers"""
|
"""Check for, among other things, ecocups and pitchers"""
|
||||||
items = defaultdict(int)
|
items = defaultdict(int)
|
||||||
for form in self.forms:
|
for form in self.forms:
|
||||||
@@ -618,7 +670,7 @@ class BaseBasketForm(forms.BaseFormSet):
|
|||||||
returnables = list(
|
returnables = list(
|
||||||
ReturnableProduct.objects.filter(
|
ReturnableProduct.objects.filter(
|
||||||
Q(product_id__in=ids) | Q(returned_product_id__in=ids)
|
Q(product_id__in=ids) | Q(returned_product_id__in=ids)
|
||||||
).annotate_balance_for(customer)
|
).annotate_balance_for(self.customer)
|
||||||
)
|
)
|
||||||
limit_reached = []
|
limit_reached = []
|
||||||
for returnable in returnables:
|
for returnable in returnables:
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 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"),
|
||||||
|
]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
+71
-33
@@ -22,18 +22,19 @@ import string
|
|||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from datetime import timezone as tz
|
from datetime import timezone as tz
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import TYPE_CHECKING, Literal, Self
|
from typing import Literal, Self
|
||||||
|
|
||||||
from dict2xml import dict2xml
|
from dict2xml import dict2xml
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.validators import MinLengthValidator
|
from django.core.validators import MinLengthValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Exists, F, OuterRef, Q, QuerySet, Subquery, Sum, Value
|
from django.db.models import Exists, F, Max, OuterRef, Q, QuerySet, Subquery, Sum, Value
|
||||||
from django.db.models.functions import Coalesce, Concat, Length
|
from django.db.models.functions import Coalesce, Concat, Length
|
||||||
from django.forms import ValidationError
|
from django.forms import ValidationError
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_celery_beat.models import PeriodicTask
|
from django_celery_beat.models import PeriodicTask
|
||||||
from django_countries.fields import CountryField
|
from django_countries.fields import CountryField
|
||||||
@@ -47,9 +48,6 @@ from core.utils import get_start_of_semester
|
|||||||
from counter.fields import CurrencyField
|
from counter.fields import CurrencyField
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
|
|
||||||
def get_eboutic() -> Counter:
|
def get_eboutic() -> Counter:
|
||||||
return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
|
return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
|
||||||
@@ -101,7 +99,9 @@ class Customer(models.Model):
|
|||||||
|
|
||||||
user = models.OneToOneField(User, primary_key=True, on_delete=models.CASCADE)
|
user = models.OneToOneField(User, primary_key=True, on_delete=models.CASCADE)
|
||||||
account_id = models.CharField(_("account id"), max_length=10, unique=True)
|
account_id = models.CharField(_("account id"), max_length=10, unique=True)
|
||||||
amount = CurrencyField(_("amount"), default=0)
|
amount: CurrencyField = CurrencyField(
|
||||||
|
_("amount"), max_value=settings.SITH_ACCOUNT_MAX_MONEY, default=0
|
||||||
|
)
|
||||||
|
|
||||||
objects = CustomerQuerySet.as_manager()
|
objects = CustomerQuerySet.as_manager()
|
||||||
|
|
||||||
@@ -158,13 +158,15 @@ class Customer(models.Model):
|
|||||||
unique_fields=["customer", "returnable"],
|
unique_fields=["customer", "returnable"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@cached_property
|
||||||
def can_buy(self) -> bool:
|
def can_buy(self) -> bool:
|
||||||
"""Check if whether this customer has the right to purchase any item."""
|
"""Check if whether this customer has the right to purchase any item."""
|
||||||
subscription = self.user.subscriptions.order_by("subscription_end").last()
|
subscription_end = self.user.subscriptions.aggregate(
|
||||||
if subscription is None:
|
res=Max("subscription_end")
|
||||||
|
).get("res")
|
||||||
|
if subscription_end is None:
|
||||||
return False
|
return False
|
||||||
return (date.today() - subscription.subscription_end) < timedelta(days=90)
|
return (date.today() - subscription_end) < timedelta(days=90)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_or_create(cls, user: User) -> tuple[Customer, bool]:
|
def get_or_create(cls, user: User) -> tuple[Customer, bool]:
|
||||||
@@ -230,15 +232,8 @@ class BillingInfo(models.Model):
|
|||||||
address_2 = models.CharField(_("Address 2"), max_length=50, blank=True, null=True)
|
address_2 = models.CharField(_("Address 2"), max_length=50, blank=True, null=True)
|
||||||
zip_code = models.CharField(_("Zip code"), max_length=16) # code postal
|
zip_code = models.CharField(_("Zip code"), max_length=16) # code postal
|
||||||
city = models.CharField(_("City"), max_length=50)
|
city = models.CharField(_("City"), max_length=50)
|
||||||
country = CountryField(blank_label=_("Country"))
|
country = CountryField(_("Country"))
|
||||||
|
phone_number = PhoneNumberField(_("Phone number"))
|
||||||
# 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):
|
def __str__(self):
|
||||||
return f"{self.first_name} {self.last_name}"
|
return f"{self.first_name} {self.last_name}"
|
||||||
@@ -353,6 +348,40 @@ class ProductType(OrderedModel):
|
|||||||
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
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):
|
class Product(models.Model):
|
||||||
"""A product, with all its related information."""
|
"""A product, with all its related information."""
|
||||||
|
|
||||||
@@ -370,8 +399,7 @@ class Product(models.Model):
|
|||||||
)
|
)
|
||||||
code = models.CharField(_("code"), max_length=16, blank=True)
|
code = models.CharField(_("code"), max_length=16, blank=True)
|
||||||
purchase_price = CurrencyField(
|
purchase_price = CurrencyField(
|
||||||
_("purchase price"),
|
_("purchase price"), help_text=_("Initial cost of purchasing the product")
|
||||||
help_text=_("Initial cost of purchasing the product"),
|
|
||||||
)
|
)
|
||||||
icon = ResizedImageField(
|
icon = ResizedImageField(
|
||||||
height=70,
|
height=70,
|
||||||
@@ -388,13 +416,21 @@ class Product(models.Model):
|
|||||||
tray = models.BooleanField(
|
tray = models.BooleanField(
|
||||||
_("tray price"), help_text=_("Buy five, get the sixth free"), default=False
|
_("tray price"), help_text=_("Buy five, get the sixth free"), default=False
|
||||||
)
|
)
|
||||||
buying_groups = models.ManyToManyField(
|
clic_limit = models.PositiveSmallIntegerField(
|
||||||
Group, related_name="products", verbose_name=_("buying groups"), blank=True
|
_("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,
|
||||||
)
|
)
|
||||||
archived = models.BooleanField(_("archived"), default=False)
|
archived = models.BooleanField(_("archived"), default=False)
|
||||||
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
|
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
|
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
|
||||||
|
|
||||||
|
objects = ProductQuerySet.as_manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("product")
|
verbose_name = _("product")
|
||||||
|
|
||||||
@@ -580,7 +616,6 @@ class Counter(models.Model):
|
|||||||
view_groups = models.ManyToManyField(
|
view_groups = models.ManyToManyField(
|
||||||
Group, related_name="viewable_counters", blank=True
|
Group, related_name="viewable_counters", blank=True
|
||||||
)
|
)
|
||||||
token = models.CharField(_("token"), max_length=30, null=True, blank=True)
|
|
||||||
|
|
||||||
objects = CounterQuerySet.as_manager()
|
objects = CounterQuerySet.as_manager()
|
||||||
|
|
||||||
@@ -733,10 +768,8 @@ class Counter(models.Model):
|
|||||||
# but they share the same primary key
|
# but they share the same primary key
|
||||||
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
|
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
|
||||||
|
|
||||||
def get_prices_for(
|
def get_prices_for(self, customer: Customer) -> PriceQuerySet:
|
||||||
self, customer: Customer, *, order_by: Sequence[str] | None = None
|
return (
|
||||||
) -> list[Price]:
|
|
||||||
qs = (
|
|
||||||
Price.objects.filter(
|
Price.objects.filter(
|
||||||
product__counters=self, product__product_type__isnull=False
|
product__counters=self, product__product_type__isnull=False
|
||||||
)
|
)
|
||||||
@@ -744,9 +777,6 @@ class Counter(models.Model):
|
|||||||
.select_related("product", "product__product_type")
|
.select_related("product", "product__product_type")
|
||||||
.prefetch_related("groups")
|
.prefetch_related("groups")
|
||||||
)
|
)
|
||||||
if order_by:
|
|
||||||
qs = qs.order_by(*order_by)
|
|
||||||
return list(qs)
|
|
||||||
|
|
||||||
|
|
||||||
class CounterSellers(models.Model):
|
class CounterSellers(models.Model):
|
||||||
@@ -797,7 +827,7 @@ class Refilling(models.Model):
|
|||||||
counter = models.ForeignKey(
|
counter = models.ForeignKey(
|
||||||
Counter, related_name="refillings", blank=False, on_delete=models.CASCADE
|
Counter, related_name="refillings", blank=False, on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
amount = CurrencyField(_("amount"))
|
amount: CurrencyField = CurrencyField(_("amount"), min_value=0.01)
|
||||||
operator = models.ForeignKey(
|
operator = models.ForeignKey(
|
||||||
User,
|
User,
|
||||||
related_name="refillings_as_operator",
|
related_name="refillings_as_operator",
|
||||||
@@ -851,6 +881,14 @@ class Refilling(models.Model):
|
|||||||
return False
|
return False
|
||||||
return user.is_owner(self.counter) and self.payment_method != "CARD"
|
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):
|
def delete(self, *args, **kwargs):
|
||||||
self.customer.amount -= self.amount
|
self.customer.amount -= self.amount
|
||||||
self.customer.save()
|
self.customer.save()
|
||||||
@@ -1079,7 +1117,7 @@ class Permanency(models.Model):
|
|||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
)
|
)
|
||||||
start = models.DateTimeField(_("start date"))
|
start = models.DateTimeField(_("start date"))
|
||||||
end = models.DateTimeField(_("end date"), null=True, db_index=True)
|
end = models.DateTimeField(_("end date"), null=True, blank=True, db_index=True)
|
||||||
activity = models.DateTimeField(_("last activity date"), auto_now=True)
|
activity = models.DateTimeField(_("last activity date"), auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
+7
-14
@@ -20,41 +20,34 @@
|
|||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
import random
|
||||||
|
|
||||||
from django.db.models.signals import pre_delete
|
from django.db.models.signals import pre_delete
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
from core.middleware import get_signal_request
|
from core.middleware import get_signal_request
|
||||||
from core.models import OperationLog
|
from core.models import OperationLog
|
||||||
from counter.models import Counter, Refilling, Selling
|
from counter.models import Refilling, Selling
|
||||||
|
|
||||||
|
|
||||||
def write_log(instance, operation_type):
|
def write_log(instance: Selling | Refilling, operation_type):
|
||||||
def get_user():
|
def get_user():
|
||||||
request = get_signal_request()
|
request = get_signal_request()
|
||||||
|
|
||||||
if not request:
|
if not request:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Get a random barmen if deletion is from a counter
|
if request.barmen:
|
||||||
session = getattr(request, "session", {})
|
return random.choice(list(request.barmen))
|
||||||
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
|
# Get the current logged user if not from a counter
|
||||||
if request.user and not request.user.is_anonymous:
|
if request.user.is_authenticated:
|
||||||
return request.user
|
return request.user
|
||||||
|
|
||||||
# Return None by default
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
OperationLog(
|
OperationLog(
|
||||||
label=str(instance),
|
label=str(instance), operator=get_user(), operation_type=operation_type
|
||||||
operator=get_user(),
|
|
||||||
operation_type=operation_type,
|
|
||||||
).save()
|
).save()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { RecursivePartial, TomSettings } from "tom-select/dist/types/types";
|
import type { RecursivePartial, TomSettings } from "tom-select/src/types";
|
||||||
import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base.ts";
|
import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base";
|
||||||
import { registerComponent } from "#core:utils/web-components.ts";
|
import { registerComponent } from "#core:utils/web-components";
|
||||||
|
|
||||||
const productParsingRegex = /^(\d+x)?(.*)/i;
|
const productParsingRegex = /^(\d+x)?(.*)/i;
|
||||||
const codeParsingRegex = / \((\w+)\)$/;
|
const codeParsingRegex = / \((\w+)\)$/;
|
||||||
|
|
||||||
function parseProduct(query: string): [number, string] {
|
function parseProduct(query: string): [number, string] {
|
||||||
const parsed = productParsingRegex.exec(query);
|
const parsed = productParsingRegex.exec(query) as RegExpExecArray;
|
||||||
return [Number.parseInt(parsed[1] || "1", 10), parsed[2]];
|
return [Number.parseInt(parsed[1] || "1", 10), parsed[2]];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,13 +63,6 @@ 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> {
|
protected tomSelectSettings(): RecursivePartial<TomSettings> {
|
||||||
/* We disable the dropdown on focus because we're going to always autofocus the widget */
|
/* We disable the dropdown on focus because we're going to always autofocus the widget */
|
||||||
@@ -80,9 +73,7 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
|
|||||||
// We need to manually set weights or it results on an inconsistent
|
// We need to manually set weights or it results on an inconsistent
|
||||||
// behavior between production and development environment
|
// behavior between production and development environment
|
||||||
searchField: [
|
searchField: [
|
||||||
// @ts-expect-error documentation says it's fine, specified type is wrong
|
|
||||||
{ field: "code", weight: 2 },
|
{ field: "code", weight: 2 },
|
||||||
// @ts-expect-error documentation says it's fine, specified type is wrong
|
|
||||||
{ field: "text", weight: 0.5 },
|
{ field: "text", weight: 0.5 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { BasketItem } from "#counter:counter/basket";
|
|||||||
import type {
|
import type {
|
||||||
CounterConfig,
|
CounterConfig,
|
||||||
CounterItem,
|
CounterItem,
|
||||||
ErrorMessage,
|
|
||||||
ProductFormula,
|
ProductFormula,
|
||||||
} from "#counter:counter/types";
|
} from "#counter:counter/types";
|
||||||
import type { CounterProductSelect } from "./components/counter-product-select-index";
|
import type { CounterProductSelect } from "./components/counter-product-select-index";
|
||||||
@@ -24,21 +23,24 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.codeField = this.$refs.codeField;
|
this.codeField = this.$refs.codeField as CounterProductSelect;
|
||||||
|
this.codeField.widget.hook("after", "onOptionSelect", () => {
|
||||||
|
this.handleCode();
|
||||||
|
});
|
||||||
this.codeField.widget.focus();
|
this.codeField.widget.focus();
|
||||||
|
|
||||||
// It's quite tricky to manually apply attributes to the management part
|
// It's quite tricky to manually apply attributes to the management part
|
||||||
// of a formset so we dynamically apply it here
|
// of a formset so we dynamically apply it here
|
||||||
this.$refs.basketManagementForm
|
this.$refs.basketManagementForm
|
||||||
.querySelector("#id_form-TOTAL_FORMS")
|
.querySelector("#id_form-TOTAL_FORMS")
|
||||||
.setAttribute(":value", "getBasketSize()");
|
?.setAttribute(":value", "getBasketSize()");
|
||||||
},
|
},
|
||||||
|
|
||||||
removeFromBasket(id: string) {
|
removeFromBasket(id: string) {
|
||||||
delete this.basket[id];
|
delete this.basket[id];
|
||||||
},
|
},
|
||||||
|
|
||||||
addToBasket(id: string, quantity: number): ErrorMessage {
|
addToBasket(id: string, quantity: number) {
|
||||||
const item: BasketItem =
|
const item: BasketItem =
|
||||||
this.basket[id] || new BasketItem(config.products[id], 0);
|
this.basket[id] || new BasketItem(config.products[id], 0);
|
||||||
|
|
||||||
@@ -47,7 +49,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
|
|
||||||
if (item.quantity <= 0) {
|
if (item.quantity <= 0) {
|
||||||
delete this.basket[id];
|
delete this.basket[id];
|
||||||
return "";
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.basket[id] = item;
|
this.basket[id] = item;
|
||||||
@@ -69,7 +71,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
const products = new Set(
|
const products = new Set(
|
||||||
Object.values(this.basket).map((item: BasketItem) => item.product.productId),
|
Object.values(this.basket).map((item: BasketItem) => item.product.productId),
|
||||||
);
|
);
|
||||||
const formula: ProductFormula = config.formulas.find((f: ProductFormula) => {
|
const formula = config.formulas.find((f: ProductFormula) => {
|
||||||
return f.products.every((p: number) => products.has(p));
|
return f.products.every((p: number) => products.has(p));
|
||||||
});
|
});
|
||||||
if (formula === undefined) {
|
if (formula === undefined) {
|
||||||
@@ -77,9 +79,13 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
// Now that the formula is found, remove the items composing it from the basket
|
// Now that the formula is found, remove the items composing it from the basket
|
||||||
for (const product of formula.products) {
|
for (const product of formula.products) {
|
||||||
const key = Object.entries(this.basket).find(
|
const item = Object.entries(this.basket).find(
|
||||||
([_, i]: [string, BasketItem]) => i.product.productId === product,
|
([_, i]: [string, BasketItem]) => i.product.productId === product,
|
||||||
)[0];
|
);
|
||||||
|
if (item === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = item[0];
|
||||||
this.basket[key].quantity -= 1;
|
this.basket[key].quantity -= 1;
|
||||||
if (this.basket[key].quantity <= 0) {
|
if (this.basket[key].quantity <= 0) {
|
||||||
this.removeFromBasket(key);
|
this.removeFromBasket(key);
|
||||||
@@ -89,7 +95,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
const result = Object.values(config.products)
|
const result = Object.values(config.products)
|
||||||
.filter((item: CounterItem) => item.productId === formula.result)
|
.filter((item: CounterItem) => item.productId === formula.result)
|
||||||
.reduce((acc, curr) => (acc.price.amount < curr.price.amount ? acc : curr));
|
.reduce((acc, curr) => (acc.price.amount < curr.price.amount ? acc : curr));
|
||||||
this.addToBasket(result.price.id, 1);
|
this.addToBasket(result.price.id.toString(), 1);
|
||||||
this.alertMessage.display(
|
this.alertMessage.display(
|
||||||
interpolate(
|
interpolate(
|
||||||
gettext("Formula %(formula)s applied"),
|
gettext("Formula %(formula)s applied"),
|
||||||
@@ -116,14 +122,18 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
onRefillingSuccess(event: CustomEvent) {
|
onRefillingSuccess(event: CustomEvent) {
|
||||||
if (event.type !== "htmx:after-request" || event.detail.failed) {
|
if (
|
||||||
|
event.type !== "htmx:after-swap" ||
|
||||||
|
event.detail.failed ||
|
||||||
|
event.detail.elt.querySelector(".errorlist")
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.customerBalance += Number.parseFloat(
|
this.customerBalance += Number.parseFloat(
|
||||||
(event.detail.target.querySelector("#id_amount") as HTMLInputElement).value,
|
(event.detail.target.querySelector("#id_amount") as HTMLInputElement).value,
|
||||||
);
|
);
|
||||||
document.getElementById("selling-accordion").setAttribute("open", "");
|
document.getElementById("selling-accordion")?.setAttribute("open", "");
|
||||||
this.codeField.widget.focus();
|
this.codeField?.widget.focus();
|
||||||
},
|
},
|
||||||
|
|
||||||
finish() {
|
finish() {
|
||||||
@@ -133,7 +143,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.$refs.basketForm.submit();
|
(this.$refs.basketForm as HTMLFormElement).submit();
|
||||||
},
|
},
|
||||||
|
|
||||||
cancel() {
|
cancel() {
|
||||||
@@ -141,6 +151,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
handleCode() {
|
handleCode() {
|
||||||
|
if (!this.codeField) throw Error("Unexpected null codeField.");
|
||||||
|
|
||||||
const [quantity, code] = this.codeField.getSelectedProduct() as [number, string];
|
const [quantity, code] = this.codeField.getSelectedProduct() as [number, string];
|
||||||
|
|
||||||
if (this.codeField.getOperationCodes().includes(code.toUpperCase())) {
|
if (this.codeField.getOperationCodes().includes(code.toUpperCase())) {
|
||||||
@@ -154,6 +166,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.addToBasket(code, quantity);
|
this.addToBasket(code, quantity);
|
||||||
}
|
}
|
||||||
this.codeField.widget.clear();
|
this.codeField.widget.clear();
|
||||||
|
this.codeField.widget.setTextboxValue("");
|
||||||
this.codeField.widget.focus();
|
this.codeField.widget.focus();
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -42,7 +42,28 @@
|
|||||||
min-width: 350px;
|
min-width: 350px;
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
list-style-type: none;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,10 +56,15 @@
|
|||||||
<div class="accordion-content">
|
<div class="accordion-content">
|
||||||
{% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %}
|
{% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %}
|
||||||
|
|
||||||
<form method="post" action=""
|
<form method="post" action="" @submit.prevent="handleCode">
|
||||||
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>
|
<option value=""></option>
|
||||||
<optgroup label="{% trans %}Operations{% endtrans %}">
|
<optgroup label="{% trans %}Operations{% endtrans %}">
|
||||||
<option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option>
|
<option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option>
|
||||||
@@ -68,13 +73,11 @@
|
|||||||
{%- for category, prices in categories.items() -%}
|
{%- for category, prices in categories.items() -%}
|
||||||
<optgroup label="{{ category }}">
|
<optgroup label="{{ category }}">
|
||||||
{%- for price in prices -%}
|
{%- for price in prices -%}
|
||||||
<option value="{{ price.id }}">{{ price.full_label }}</option>
|
<option value="{{ price.id }}">{{ price.full_label }} ({{ price.product.code }})</option>
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
</optgroup>
|
</optgroup>
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
</counter-product-select>
|
</counter-product-select>
|
||||||
|
|
||||||
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% for error in form.non_form_errors() %}
|
{% for error in form.non_form_errors() %}
|
||||||
@@ -102,7 +105,9 @@
|
|||||||
{{ form.management_form }}
|
{{ form.management_form }}
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
|
<li x-show="getBasketSize() === 0">
|
||||||
|
<em>{% trans %}This basket is empty{% endtrans %}</em>
|
||||||
|
</li>
|
||||||
<template x-for="(item, index) in Object.values(basket)" :key="item.product.price.id">
|
<template x-for="(item, index) in Object.values(basket)" :key="item.product.price.id">
|
||||||
<li>
|
<li>
|
||||||
<template x-for="error in item.errors">
|
<template x-for="error in item.errors">
|
||||||
@@ -110,12 +115,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<div class="basket-row">
|
||||||
|
<div>
|
||||||
<button @click.prevent="addToBasket(item.product.price.id, -1)">-</button>
|
<button @click.prevent="addToBasket(item.product.price.id, -1)">-</button>
|
||||||
<span class="quantity" x-text="item.quantity"></span>
|
<span class="quantity" x-text="item.quantity"></span>
|
||||||
<button @click.prevent="addToBasket(item.product.price.id, 1)">+</button>
|
<button @click.prevent="addToBasket(item.product.price.id, 1)">+</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span x-text="item.product.name"></span> :
|
<span class="product-name" x-text="item.product.name"></span>
|
||||||
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span>
|
<span x-text="`${item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })} €`"></span>
|
||||||
<span x-show="item.getBonusQuantity() > 0"
|
<span x-show="item.getBonusQuantity() > 0"
|
||||||
x-text="`${item.getBonusQuantity()} x P`"></span>
|
x-text="`${item.getBonusQuantity()} x P`"></span>
|
||||||
|
|
||||||
@@ -123,6 +131,7 @@
|
|||||||
class="remove-item"
|
class="remove-item"
|
||||||
@click.prevent="removeFromBasket(item.product.price.id)"
|
@click.prevent="removeFromBasket(item.product.price.id)"
|
||||||
><i class="fa fa-trash-can delete-action"></i></button>
|
><i class="fa fa-trash-can delete-action"></i></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
@@ -167,13 +176,17 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<details class="accordion" name="selling">
|
<details
|
||||||
|
class="accordion"
|
||||||
|
name="selling"
|
||||||
|
@toggle="if ($event.newState === 'open') $el.querySelector('input[type=number]')?.focus()"
|
||||||
|
>
|
||||||
<summary>{% trans %}Refilling{% endtrans %}</summary>
|
<summary>{% trans %}Refilling{% endtrans %}</summary>
|
||||||
{% if object.type == "BAR" %}
|
{% if object.type == "BAR" %}
|
||||||
{% if refilling_fragment %}
|
{% if refilling_fragment %}
|
||||||
<div
|
<div
|
||||||
class="accordion-content"
|
class="accordion-content"
|
||||||
@htmx:after-request="onRefillingSuccess"
|
@htmx:after-swap="onRefillingSuccess"
|
||||||
>
|
>
|
||||||
{{ refilling_fragment }}
|
{{ refilling_fragment }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,12 +32,11 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<p><strong>{% trans %}Total: {% endtrans %}{{ last_total }} €</strong></p>
|
<p><strong>{% trans %}Total: {% endtrans %}{{ last_total }} €</strong></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if barmen %}
|
{% if can_click %}
|
||||||
<p>{% trans %}Enter client code:{% endtrans %}</p>
|
<p>{% trans %}Enter client code:{% endtrans %}</p>
|
||||||
<form method="post" action="">
|
<form method="post" action="" id="select-user-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="counter_token" value="{{ counter.token }}" />
|
{{ form }}
|
||||||
{{ form.as_p() }}
|
|
||||||
<p><input type="submit" value="{% trans %}validate{% endtrans %}" /></p>
|
<p><input type="submit" value="{% trans %}validate{% endtrans %}" /></p>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -45,17 +44,36 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if counter.type == 'BAR' %}
|
{% if counter.type == 'BAR' %}
|
||||||
|
<h3>{% trans %}Barmen:{% endtrans %}</h3>
|
||||||
|
|
||||||
|
{% if barmen_here %}
|
||||||
|
<div class="row gap-2x">
|
||||||
<div>
|
<div>
|
||||||
<h3>{% trans %}Barman: {% endtrans %}</h3>
|
<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 %}
|
||||||
{% for b in barmen %}
|
{% for b in barmen %}
|
||||||
<p>{{ barman_logout_link(b) }}</p>
|
<p>{{ barman_logout_link(b) }}</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<form method="post" action="{{ url('counter:login', counter_id=counter.id) }}">
|
{% endif %}
|
||||||
{% csrf_token %}
|
{{ login_fragment }}
|
||||||
{{ login_form.as_p() }}
|
|
||||||
<p><input type="submit" value="{% trans %}login{% endtrans %}" /></p>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -63,10 +81,10 @@
|
|||||||
{{ super() }}
|
{{ super() }}
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
// The login form annoyingly takes priority over the code form
|
{# The login form annoyingly takes priority over the code form
|
||||||
// This is due to the loading time of the web component
|
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
|
We can't rely on DOMContentLoaded to know if the component is there so we
|
||||||
// periodically run a script until the field is there
|
periodically run a script until the field is there #}
|
||||||
const autofocus = () => {
|
const autofocus = () => {
|
||||||
const field = document.querySelector("input[id='id_code']");
|
const field = document.querySelector("input[id='id_code']");
|
||||||
if (field === null){
|
if (field === null){
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<form hx-post="{{ action }}" hx-swap="outerHTML">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form }}
|
||||||
|
<input type="submit" value="{% trans %}Confirm{% endtrans %}"/>
|
||||||
|
</form>
|
||||||
@@ -118,6 +118,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<fieldset><div>{{ form.clic_limit.as_field_group() }}</div></fieldset>
|
||||||
<fieldset><div>{{ form.counters.as_field_group() }}</div></fieldset>
|
<fieldset><div>{{ form.counters.as_field_group() }}</div></fieldset>
|
||||||
|
|
||||||
<h3 class="margin-bottom">{% trans %}Prices{% endtrans %}</h3>
|
<h3 class="margin-bottom">{% trans %}Prices{% endtrans %}</h3>
|
||||||
|
|||||||
+194
-47
@@ -17,9 +17,11 @@ from datetime import timedelta
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Permission, make_password
|
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.http import HttpResponse
|
||||||
from django.shortcuts import resolve_url
|
from django.shortcuts import resolve_url
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
@@ -37,6 +39,7 @@ from core.models import BanGroup, Group, User
|
|||||||
from counter.baker_recipes import price_recipe, product_recipe, sale_recipe
|
from counter.baker_recipes import price_recipe, product_recipe, sale_recipe
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
Counter,
|
Counter,
|
||||||
|
CounterSellers,
|
||||||
Customer,
|
Customer,
|
||||||
Permanency,
|
Permanency,
|
||||||
ProductType,
|
ProductType,
|
||||||
@@ -66,10 +69,14 @@ class TestFullClickBase(TestCase):
|
|||||||
cls.subscriber = subscriber_user.make()
|
cls.subscriber = subscriber_user.make()
|
||||||
|
|
||||||
cls.counter = baker.make(Counter, type="BAR")
|
cls.counter = baker.make(Counter, type="BAR")
|
||||||
cls.counter.sellers.add(cls.barmen, cls.board_admin)
|
|
||||||
|
|
||||||
cls.other_counter = baker.make(Counter, type="BAR")
|
cls.other_counter = baker.make(Counter, type="BAR")
|
||||||
cls.other_counter.sellers.add(cls.barmen)
|
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.yet_another_counter = baker.make(Counter, type="BAR")
|
cls.yet_another_counter = baker.make(Counter, type="BAR")
|
||||||
|
|
||||||
@@ -114,7 +121,10 @@ class TestRefilling(TestFullClickBase):
|
|||||||
) -> HttpResponse:
|
) -> HttpResponse:
|
||||||
used_client = client if client is not None else self.client
|
used_client = client if client is not None else self.client
|
||||||
return used_client.post(
|
return used_client.post(
|
||||||
reverse("counter:refilling_create", kwargs={"customer_id": user.pk}),
|
reverse(
|
||||||
|
"counter:refilling_create",
|
||||||
|
kwargs={"customer_id": user.pk, "counter_id": self.counter.pk},
|
||||||
|
),
|
||||||
{"amount": str(amount), "payment_method": Refilling.PaymentMethod.CASH},
|
{"amount": str(amount), "payment_method": Refilling.PaymentMethod.CASH},
|
||||||
HTTP_REFERER=reverse(
|
HTTP_REFERER=reverse(
|
||||||
"counter:click", kwargs={"counter_id": counter.id, "user_id": user.pk}
|
"counter:click", kwargs={"counter_id": counter.id, "user_id": user.pk}
|
||||||
@@ -134,23 +144,28 @@ class TestRefilling(TestFullClickBase):
|
|||||||
assert self.updated_amount(self.customer) == 0
|
assert self.updated_amount(self.customer) == 0
|
||||||
|
|
||||||
def test_refilling_no_refer_fail(self):
|
def test_refilling_no_refer_fail(self):
|
||||||
|
"""Check that the refill fails is the HTTP_REFERER header is missing"""
|
||||||
|
|
||||||
def refill():
|
def refill():
|
||||||
return self.client.post(
|
return self.client.post(
|
||||||
reverse(
|
reverse(
|
||||||
"counter:refilling_create",
|
"counter:refilling_create",
|
||||||
kwargs={"customer_id": self.customer.pk},
|
kwargs={
|
||||||
|
"customer_id": self.customer.pk,
|
||||||
|
"counter_id": self.counter.pk,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
{"amount": "10", "payment_method": "CASH"},
|
{"amount": "10", "payment_method": "CASH"},
|
||||||
)
|
)
|
||||||
|
|
||||||
self.client.force_login(self.club_admin)
|
self.client.force_login(self.club_admin)
|
||||||
assert refill()
|
assert refill().status_code == 403
|
||||||
|
|
||||||
self.client.force_login(self.root)
|
self.client.force_login(self.root)
|
||||||
assert refill()
|
assert refill().status_code == 403
|
||||||
|
|
||||||
self.client.force_login(self.subscriber)
|
self.client.force_login(self.subscriber)
|
||||||
assert refill()
|
assert refill().status_code == 403
|
||||||
|
|
||||||
assert self.updated_amount(self.customer) == 0
|
assert self.updated_amount(self.customer) == 0
|
||||||
|
|
||||||
@@ -186,6 +201,17 @@ class TestRefilling(TestFullClickBase):
|
|||||||
== 404
|
== 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):
|
def test_refilling_counter_success(self):
|
||||||
self.login_in_bar()
|
self.login_in_bar()
|
||||||
|
|
||||||
@@ -442,9 +468,19 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
|
|
||||||
def test_click_not_connected(self):
|
def test_click_not_connected(self):
|
||||||
force_refill_user(self.customer, 10)
|
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)])
|
res = self.submit_basket(self.customer, [BasketItem(self.snack.id, 2)])
|
||||||
assertRedirects(res, self.counter.get_absolute_url())
|
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(
|
res = self.submit_basket(
|
||||||
self.customer, [BasketItem(self.snack.id, 2)], counter=self.club_counter
|
self.customer, [BasketItem(self.snack.id, 2)], counter=self.club_counter
|
||||||
)
|
)
|
||||||
@@ -499,6 +535,19 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
|
|
||||||
assert self.updated_amount(self.customer) == Decimal(10)
|
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):
|
def test_annotate_has_barman_queryset(self):
|
||||||
"""Test if the custom queryset method `annotate_has_barman` works as intended."""
|
"""Test if the custom queryset method `annotate_has_barman` works as intended."""
|
||||||
counters = Counter.objects.annotate_has_barman(self.barmen)
|
counters = Counter.objects.annotate_has_barman(self.barmen)
|
||||||
@@ -596,7 +645,7 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
product=iter(_product_recipe.make(archived=False, _quantity=2)),
|
product=iter(_product_recipe.make(archived=False, _quantity=2)),
|
||||||
groups=[group],
|
groups=[group],
|
||||||
)
|
)
|
||||||
customer_prices = counter.get_prices_for(customer)
|
customer_prices = list(counter.get_prices_for(customer))
|
||||||
assert unarchived_prices == customer_prices
|
assert unarchived_prices == customer_prices
|
||||||
|
|
||||||
|
|
||||||
@@ -718,59 +767,154 @@ class TestCounterStats(TestCase):
|
|||||||
class TestBarmanConnection(TestCase):
|
class TestBarmanConnection(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
cls.krophil = User.objects.get(username="krophil")
|
cls.barman = subscriber_user.make()
|
||||||
cls.skia = User.objects.get(username="skia")
|
cls.barman.set_password("plop")
|
||||||
cls.skia.customer.account = 800
|
cls.barman.save()
|
||||||
cls.krophil.customer.save()
|
cls.counter = baker.make(Counter, type="BAR", sellers=[cls.barman])
|
||||||
cls.skia.customer.save()
|
cls.login_url = reverse("counter:login", kwargs={"counter_id": cls.counter.id})
|
||||||
|
cls.detail_url = reverse(
|
||||||
cls.counter = Counter.objects.get(id=2)
|
"counter:details", kwargs={"counter_id": cls.counter.id}
|
||||||
|
)
|
||||||
|
|
||||||
def test_barman_granted(self):
|
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.client.post(
|
||||||
reverse("counter:login", args=[self.counter.id]),
|
self.login_url, {"username": self.barman.username, "password": "plop"}
|
||||||
{"username": "krophil", "password": "plop"},
|
|
||||||
)
|
)
|
||||||
response = self.client.get(reverse("counter:details", args=[self.counter.id]))
|
self.assert_counter_login_fails(self.barman)
|
||||||
|
|
||||||
assert "<p>Entrez un code client : </p>" in str(response.content)
|
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)
|
||||||
|
|
||||||
def test_counters_list_barmen(self):
|
def test_barman_login_elsewhere(self):
|
||||||
|
"""Test when the barman log himself out then log in on another device."""
|
||||||
self.client.post(
|
self.client.post(
|
||||||
reverse("counter:login", args=[self.counter.id]),
|
self.login_url, {"username": self.barman.username, "password": "plop"}
|
||||||
{"username": "krophil", "password": "plop"},
|
|
||||||
)
|
)
|
||||||
response = self.client.get(reverse("counter:activity", args=[self.counter.id]))
|
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
|
||||||
|
|
||||||
assert '<li><a href="/user/10/">Kro Phil'</a></li>' in str(response.content)
|
# ... 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
|
||||||
|
|
||||||
def test_barman_denied(self):
|
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)
|
||||||
self.client.post(
|
self.client.post(
|
||||||
reverse("counter:login", args=[self.counter.id]),
|
reverse("counter:login", kwargs={"counter_id": other_counter.id}),
|
||||||
{"username": "skia", "password": "plop"},
|
{"username": self.barman.username, "password": "plop"},
|
||||||
)
|
)
|
||||||
response_get = self.client.get(
|
self.assert_counter_login_fails(self.barman)
|
||||||
reverse("counter:details", args=[self.counter.id])
|
|
||||||
|
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):
|
This is a regression test for #1440
|
||||||
self.client.post(
|
"""
|
||||||
reverse("counter:login", args=[self.counter.id]),
|
self.client.post( # login
|
||||||
{"username": "krophil", "password": "plop"},
|
self.login_url, {"username": self.barman.username, "password": "plop"}
|
||||||
)
|
)
|
||||||
response = self.client.get(reverse("counter:activity", args=[self.counter.id]))
|
self.client.post( # logout
|
||||||
|
reverse("counter:logout", kwargs={"counter_id": self.counter.id}),
|
||||||
assert '<li><a href="/user/1/">S' Kia</a></li>' not in str(response.content)
|
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
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_barman_timeout():
|
def test_barman_timeout(client: Client):
|
||||||
"""Test that barmen timeout is well managed."""
|
"""Test that barmen timeout is well managed."""
|
||||||
bar = baker.make(Counter, type="BAR")
|
bar = baker.make(Counter, type="BAR")
|
||||||
user = baker.make(User)
|
user = baker.make(User)
|
||||||
bar.sellers.add(user)
|
CounterSellers.objects.create(counter=bar, user=user)
|
||||||
baker.make(Permanency, counter=bar, user=user, start=now())
|
baker.make(Permanency, counter=bar, user=user, start=now())
|
||||||
|
|
||||||
qs = Counter.objects.annotate_is_open().filter(pk=bar.pk)
|
qs = Counter.objects.annotate_is_open().filter(pk=bar.pk)
|
||||||
@@ -786,6 +930,8 @@ def test_barman_timeout():
|
|||||||
bar = qs[0]
|
bar = qs[0]
|
||||||
assert not bar.is_open
|
assert not bar.is_open
|
||||||
assert bar.barmen_list == []
|
assert bar.barmen_list == []
|
||||||
|
res = client.get("")
|
||||||
|
assert res.wsgi_request.barmen == set()
|
||||||
|
|
||||||
|
|
||||||
class TestClubCounterClickAccess(TestCase):
|
class TestClubCounterClickAccess(TestCase):
|
||||||
@@ -835,14 +981,14 @@ class TestClubCounterClickAccess(TestCase):
|
|||||||
|
|
||||||
def test_barman(self):
|
def test_barman(self):
|
||||||
"""Sellers should be able to click on office counters"""
|
"""Sellers should be able to click on office counters"""
|
||||||
self.counter.sellers.add(self.user)
|
CounterSellers.objects.create(counter=self.counter, user=self.user)
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
res = self.client.get(self.click_url)
|
res = self.client.get(self.click_url)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
|
|
||||||
def test_both_barman_and_board_member(self):
|
def test_both_barman_and_board_member(self):
|
||||||
"""If the user is barman and board member, he should be authorized as well."""
|
"""If the user is barman and board member, he should be authorized as well."""
|
||||||
self.counter.sellers.add(self.user)
|
CounterSellers.objects.create(counter=self.counter, user=self.user)
|
||||||
baker.make(
|
baker.make(
|
||||||
Membership, club=self.counter.club, user=self.user, role=self.board_role
|
Membership, club=self.counter.club, user=self.user, role=self.board_role
|
||||||
)
|
)
|
||||||
@@ -868,14 +1014,15 @@ class TestCounterLogout:
|
|||||||
)
|
)
|
||||||
assertRedirects(
|
assertRedirects(
|
||||||
res,
|
res,
|
||||||
reverse(
|
reverse("counter:details", kwargs={"counter_id": permanence.counter_id}),
|
||||||
"counter:details", kwargs={"counter_id": permanence.counter_id}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
permanence.refresh_from_db()
|
permanence.refresh_from_db()
|
||||||
assert permanence.end == now()
|
assert permanence.end == permanence.activity
|
||||||
|
assert permanence.user not in res.wsgi_request.barmen
|
||||||
|
|
||||||
def test_logout_doesnt_change_old_permanences(self, client: Client):
|
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")
|
perm_counter = baker.make(Counter, type="BAR")
|
||||||
permanence = baker.make(
|
permanence = baker.make(
|
||||||
Permanency,
|
Permanency,
|
||||||
@@ -896,6 +1043,6 @@ class TestCounterLogout:
|
|||||||
data={"user_id": permanence.user_id},
|
data={"user_id": permanence.user_id},
|
||||||
)
|
)
|
||||||
permanence.refresh_from_db()
|
permanence.refresh_from_db()
|
||||||
assert permanence.end == now()
|
assert permanence.end == permanence.activity
|
||||||
old_permanence.refresh_from_db()
|
old_permanence.refresh_from_db()
|
||||||
assert old_permanence.end == old_end
|
assert old_permanence.end == old_end
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from core.models import User
|
|||||||
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
|
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
Counter,
|
Counter,
|
||||||
|
CounterSellers,
|
||||||
Customer,
|
Customer,
|
||||||
Refilling,
|
Refilling,
|
||||||
ReturnableProduct,
|
ReturnableProduct,
|
||||||
@@ -38,7 +39,7 @@ class TestStudentCard(TestCase):
|
|||||||
cls.subscriber = subscriber_user.make()
|
cls.subscriber = subscriber_user.make()
|
||||||
|
|
||||||
cls.counter = baker.make(Counter, type="BAR")
|
cls.counter = baker.make(Counter, type="BAR")
|
||||||
cls.counter.sellers.add(cls.barmen)
|
CounterSellers.objects.create(counter=cls.counter, user=cls.barmen)
|
||||||
|
|
||||||
cls.club_counter = baker.make(Counter)
|
cls.club_counter = baker.make(Counter)
|
||||||
role = baker.make(ClubRole, club=cls.club_counter.club, is_board=True)
|
role = baker.make(ClubRole, club=cls.club_counter.club, is_board=True)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import itertools
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -8,6 +9,7 @@ from django.core.cache import cache
|
|||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.timezone import now
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -16,9 +18,10 @@ from pytest_django.asserts import assertNumQueries, assertRedirects
|
|||||||
from club.models import Club
|
from club.models import Club
|
||||||
from core.baker_recipes import board_user, subscriber_user
|
from core.baker_recipes import board_user, subscriber_user
|
||||||
from core.models import Group, User
|
from core.models import Group, User
|
||||||
from counter.baker_recipes import product_recipe
|
from counter.baker_recipes import product_recipe, sale_recipe
|
||||||
from counter.forms import ProductForm, ProductPriceFormSet
|
from counter.forms import ProductForm, ProductPriceFormSet
|
||||||
from counter.models import Price, Product, ProductType
|
from counter.models import Price, Product, ProductType, Selling
|
||||||
|
from eboutic.models import Basket, BasketItem
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -222,3 +225,59 @@ def test_price_for_user():
|
|||||||
assert list(qs.for_user(users[0])) == [prices[0], prices[1], prices[4]]
|
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[1])) == [prices[0], prices[4]]
|
||||||
assert list(qs.for_user(users[2])) == [prices[0], prices[3]]
|
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:]
|
||||||
|
|||||||
+4
-3
@@ -41,7 +41,6 @@ from counter.views.admin import (
|
|||||||
ReturnableProductUpdateView,
|
ReturnableProductUpdateView,
|
||||||
SellingDeleteView,
|
SellingDeleteView,
|
||||||
)
|
)
|
||||||
from counter.views.auth import counter_login, counter_logout
|
|
||||||
from counter.views.cash import (
|
from counter.views.cash import (
|
||||||
CashSummaryEditView,
|
CashSummaryEditView,
|
||||||
CashSummaryListView,
|
CashSummaryListView,
|
||||||
@@ -57,7 +56,9 @@ from counter.views.eticket import (
|
|||||||
from counter.views.home import (
|
from counter.views.home import (
|
||||||
CounterActivityView,
|
CounterActivityView,
|
||||||
CounterLastOperationsView,
|
CounterLastOperationsView,
|
||||||
|
CounterLoginFragment,
|
||||||
CounterMain,
|
CounterMain,
|
||||||
|
counter_logout,
|
||||||
)
|
)
|
||||||
from counter.views.invoice import InvoiceCallView
|
from counter.views.invoice import InvoiceCallView
|
||||||
from counter.views.student_card import StudentCardDeleteView, StudentCardFormFragment
|
from counter.views.student_card import StudentCardDeleteView, StudentCardFormFragment
|
||||||
@@ -66,7 +67,7 @@ urlpatterns = [
|
|||||||
path("<int:counter_id>/", CounterMain.as_view(), name="details"),
|
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>/click/<int:user_id>/", CounterClick.as_view(), name="click"),
|
||||||
path(
|
path(
|
||||||
"refill/<int:customer_id>/",
|
"<int:counter_id>/refill/<int:customer_id>/",
|
||||||
RefillingCreateView.as_view(),
|
RefillingCreateView.as_view(),
|
||||||
name="refilling_create",
|
name="refilling_create",
|
||||||
),
|
),
|
||||||
@@ -82,7 +83,7 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
path("<int:counter_id>/activity/", CounterActivityView.as_view(), name="activity"),
|
path("<int:counter_id>/activity/", CounterActivityView.as_view(), name="activity"),
|
||||||
path("<int:counter_id>/stats/", CounterStatView.as_view(), name="stats"),
|
path("<int:counter_id>/stats/", CounterStatView.as_view(), name="stats"),
|
||||||
path("<int:counter_id>/login/", counter_login, name="login"),
|
path("<int:counter_id>/login/", CounterLoginFragment.as_view(), name="login"),
|
||||||
path("<int:counter_id>/logout/", counter_logout, name="logout"),
|
path("<int:counter_id>/logout/", counter_logout, name="logout"),
|
||||||
path("eticket/<int:selling_id>/pdf/", EticketPDFView.as_view(), name="eticket_pdf"),
|
path("eticket/<int:selling_id>/pdf/", EticketPDFView.as_view(), name="eticket_pdf"),
|
||||||
path(
|
path(
|
||||||
|
|||||||
+3
-16
@@ -3,8 +3,6 @@ from urllib.parse import urlparse
|
|||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.urls import resolve
|
from django.urls import resolve
|
||||||
|
|
||||||
from counter.models import Counter
|
|
||||||
|
|
||||||
|
|
||||||
def is_logged_in_counter(request: HttpRequest) -> bool:
|
def is_logged_in_counter(request: HttpRequest) -> bool:
|
||||||
"""Check if the request is sent from a device logged to a counter.
|
"""Check if the request is sent from a device logged to a counter.
|
||||||
@@ -20,24 +18,13 @@ def is_logged_in_counter(request: HttpRequest) -> bool:
|
|||||||
or the request path belongs to the counter app
|
or the request path belongs to the counter app
|
||||||
(eg. the barman went back to the main by missclick and go back
|
(eg. the barman went back to the main by missclick and go back
|
||||||
to the counter)
|
to the counter)
|
||||||
- The current session has a counter token associated with it.
|
- There are barmen logged in the current session
|
||||||
- A counter with this token exists.
|
|
||||||
- The counter is open
|
|
||||||
"""
|
"""
|
||||||
referer_ok = (
|
referer_ok = (
|
||||||
"HTTP_REFERER" in request.META
|
"HTTP_REFERER" in request.META
|
||||||
and resolve(urlparse(request.META["HTTP_REFERER"]).path).app_name == "counter"
|
and resolve(urlparse(request.META["HTTP_REFERER"]).path).app_name == "counter"
|
||||||
)
|
)
|
||||||
has_token = (
|
if not referer_ok and request.resolver_match.app_name != "counter":
|
||||||
(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 False
|
||||||
|
|
||||||
return (
|
return bool(request.barmen)
|
||||||
Counter.objects.annotate_is_open()
|
|
||||||
.filter(token=request.session["counter_token"], is_open=True)
|
|
||||||
.exists()
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
#
|
|
||||||
# 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)
|
|
||||||
+34
-29
@@ -12,8 +12,10 @@
|
|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
import random
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
@@ -21,7 +23,8 @@ from django.http import Http404
|
|||||||
from django.shortcuts import get_object_or_404, redirect, resolve_url
|
from django.shortcuts import get_object_or_404, redirect, resolve_url
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.safestring import SafeString
|
from django.utils.safestring import SafeString
|
||||||
from django.views.generic import FormView
|
from django.utils.translation import gettext as _
|
||||||
|
from django.views.generic import CreateView, FormView
|
||||||
from django.views.generic.detail import SingleObjectMixin
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
from ninja.main import HttpRequest
|
from ninja.main import HttpRequest
|
||||||
|
|
||||||
@@ -33,6 +36,7 @@ from counter.models import (
|
|||||||
Counter,
|
Counter,
|
||||||
Customer,
|
Customer,
|
||||||
ProductFormula,
|
ProductFormula,
|
||||||
|
Refilling,
|
||||||
ReturnableProduct,
|
ReturnableProduct,
|
||||||
Selling,
|
Selling,
|
||||||
)
|
)
|
||||||
@@ -46,7 +50,7 @@ def get_operator(request: HttpRequest, counter: Counter, customer: Customer) ->
|
|||||||
return request.user
|
return request.user
|
||||||
if counter.customer_is_barman(customer):
|
if counter.customer_is_barman(customer):
|
||||||
return customer.user
|
return customer.user
|
||||||
return counter.get_random_barman()
|
return random.choice(list(request.barmen))
|
||||||
|
|
||||||
|
|
||||||
class CounterClick(
|
class CounterClick(
|
||||||
@@ -69,16 +73,16 @@ class CounterClick(
|
|||||||
current_tab = "counter"
|
current_tab = "counter"
|
||||||
|
|
||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
kwargs = super().get_form_kwargs()
|
return super().get_form_kwargs() | {
|
||||||
kwargs["form_kwargs"] = {
|
|
||||||
"customer": self.customer,
|
"customer": self.customer,
|
||||||
"counter": self.object,
|
"counter": self.object,
|
||||||
"allowed_prices": {price.id: price for price in self.prices},
|
"form_kwargs": {
|
||||||
|
"allowed_prices": {price.id: price for price in self.prices}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return kwargs
|
|
||||||
|
|
||||||
def dispatch(self, request, *args, **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()
|
obj: Counter = self.get_object()
|
||||||
|
|
||||||
if not self.customer.can_buy or self.customer.user.is_banned_counter:
|
if not self.customer.can_buy or self.customer.user.is_banned_counter:
|
||||||
@@ -96,14 +100,13 @@ class CounterClick(
|
|||||||
# or a seller of this counter.
|
# or a seller of this counter.
|
||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
|
|
||||||
if obj.type == "BAR" and (
|
if obj.type == "BAR" and not (
|
||||||
not obj.is_open
|
request.barmen and request.barmen.issubset(set(obj.barmen_list))
|
||||||
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
|
return redirect(obj) # Redirect to counter
|
||||||
|
|
||||||
self.prices = obj.get_prices_for(self.customer)
|
self.prices = list(obj.get_prices_for(self.customer))
|
||||||
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
@@ -199,7 +202,7 @@ class CounterClick(
|
|||||||
)
|
)
|
||||||
if self.object.can_refill():
|
if self.object.can_refill():
|
||||||
res["refilling_fragment"] = RefillingCreateView.as_fragment()(
|
res["refilling_fragment"] = RefillingCreateView.as_fragment()(
|
||||||
self.request, customer=self.customer
|
self.request, customer=self.customer, counter=self.object
|
||||||
)
|
)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
@@ -223,9 +226,10 @@ class CounterClick(
|
|||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
class RefillingCreateView(FragmentMixin, FormView):
|
class RefillingCreateView(FragmentMixin, CreateView):
|
||||||
"""This is a fragment only view which integrates with counter_click.jinja"""
|
"""This is a fragment only view which integrates with counter_click.jinja"""
|
||||||
|
|
||||||
|
model = Refilling
|
||||||
form_class = RefillForm
|
form_class = RefillForm
|
||||||
template_name = "counter/fragments/create_refill.jinja"
|
template_name = "counter/fragments/create_refill.jinja"
|
||||||
|
|
||||||
@@ -237,34 +241,35 @@ class RefillingCreateView(FragmentMixin, FormView):
|
|||||||
if not is_logged_in_counter(request):
|
if not is_logged_in_counter(request):
|
||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
|
|
||||||
self.counter: Counter = get_object_or_404(
|
self.counter: Counter = get_object_or_404(Counter, id=self.kwargs["counter_id"])
|
||||||
Counter, token=request.session["counter_token"]
|
|
||||||
)
|
|
||||||
|
|
||||||
if not self.counter.can_refill():
|
if not (
|
||||||
|
request.barmen
|
||||||
|
and request.barmen.issubset(self.counter.barmen_list)
|
||||||
|
and self.counter.can_refill()
|
||||||
|
):
|
||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
|
|
||||||
self.operator = get_operator(request, self.counter, self.customer)
|
|
||||||
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
def render_fragment(self, request, **kwargs) -> SafeString:
|
def render_fragment(self, request, **kwargs) -> SafeString:
|
||||||
self.customer = kwargs.pop("customer")
|
self.customer = kwargs.pop("customer")
|
||||||
|
self.counter = kwargs.pop("counter")
|
||||||
|
self.object = None
|
||||||
return super().render_fragment(request, **kwargs)
|
return super().render_fragment(request, **kwargs)
|
||||||
|
|
||||||
def form_valid(self, form):
|
def get_form_kwargs(self):
|
||||||
res = super().form_valid(form)
|
return super().get_form_kwargs() | {
|
||||||
form.clean()
|
"counter": self.counter,
|
||||||
form.instance.counter = self.counter
|
"operator": get_operator(self.request, self.counter, self.customer),
|
||||||
form.instance.operator = self.operator
|
"customer": self.customer,
|
||||||
form.instance.customer = self.customer
|
}
|
||||||
form.instance.save()
|
|
||||||
return res
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
kwargs = super().get_context_data(**kwargs)
|
kwargs = super().get_context_data(**kwargs)
|
||||||
kwargs["action"] = reverse(
|
kwargs["action"] = reverse(
|
||||||
"counter:refilling_create", kwargs={"customer_id": self.customer.pk}
|
"counter:refilling_create",
|
||||||
|
kwargs={"customer_id": self.customer.pk, "counter_id": self.counter.pk},
|
||||||
)
|
)
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|||||||
+99
-52
@@ -15,78 +15,122 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import HttpResponseRedirect
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.urls import reverse, reverse_lazy
|
from django.db.models import F
|
||||||
|
from django.http import HttpRequest, HttpResponseRedirect
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.safestring import SafeString
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
from django.views.generic import DetailView
|
from django.views.generic import DetailView
|
||||||
from django.views.generic.edit import FormMixin, ProcessFormView
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
|
from django.views.generic.edit import FormView
|
||||||
|
|
||||||
from core.auth.mixins import CanViewMixin
|
from core.auth.mixins import CanViewMixin
|
||||||
from core.views.forms import LoginForm
|
from core.views import FragmentMixin, UseFragmentsMixin
|
||||||
from counter.forms import GetUserForm
|
from counter.forms import CounterLoginForm, GetUserForm
|
||||||
from counter.models import Counter
|
from counter.middleware import SESSION_PERMANENCES_KEY
|
||||||
|
from counter.models import Counter, Permanency
|
||||||
from counter.utils import is_logged_in_counter
|
from counter.utils import is_logged_in_counter
|
||||||
from counter.views.mixins import CounterTabsMixin
|
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(
|
class CounterMain(
|
||||||
CounterTabsMixin, CanViewMixin, DetailView, ProcessFormView, FormMixin
|
CounterTabsMixin, UseFragmentsMixin, CanViewMixin, SingleObjectMixin, FormView
|
||||||
):
|
):
|
||||||
"""The public (barman) view."""
|
"""The public (barman) view."""
|
||||||
|
|
||||||
model = Counter
|
model = Counter
|
||||||
|
queryset = Counter.objects.exclude(type="EBOUTIC")
|
||||||
template_name = "counter/counter_main.jinja"
|
template_name = "counter/counter_main.jinja"
|
||||||
pk_url_kwarg = "counter_id"
|
pk_url_kwarg = "counter_id"
|
||||||
form_class = (
|
form_class = GetUserForm
|
||||||
GetUserForm # Form to enter a client code and get the corresponding user id
|
|
||||||
)
|
|
||||||
current_tab = "counter"
|
current_tab = "counter"
|
||||||
|
|
||||||
def get_queryset(self):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
return super().get_queryset().exclude(type="EBOUTIC")
|
self.object: Counter = self.get_object()
|
||||||
|
if self.object.type == "BAR":
|
||||||
|
self.object.update_activity()
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def get_fragment_context_data(self) -> dict[str, SafeString]:
|
||||||
self.object = self.get_object()
|
login_fragment = (
|
||||||
if self.object.type == "BAR" and not (
|
CounterLoginFragment.as_fragment()(self.request, counter=self.object)
|
||||||
"counter_token" in self.request.session
|
if self.object.type == "BAR"
|
||||||
and self.request.session["counter_token"] == self.object.token
|
else ""
|
||||||
): # 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},
|
|
||||||
)
|
)
|
||||||
+ "?bad_location"
|
return super().get_fragment_context_data() | {"login_fragment": login_fragment}
|
||||||
)
|
|
||||||
return super().post(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
"""We handle here the login form for the barman."""
|
"""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 = 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":
|
if self.object.type == "BAR":
|
||||||
kwargs["barmen"] = self.object.barmen_list
|
kwargs["barmen"] = self.object.barmen_list
|
||||||
elif self.request.user.is_authenticated:
|
kwargs["barmen_here"] = list(
|
||||||
kwargs["barmen"] = [self.request.user]
|
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)
|
||||||
|
)
|
||||||
|
)
|
||||||
if "last_basket" in self.request.session:
|
if "last_basket" in self.request.session:
|
||||||
kwargs["last_basket"] = self.request.session.pop("last_basket")
|
kwargs["last_basket"] = self.request.session.pop("last_basket")
|
||||||
kwargs["last_customer"] = self.request.session.pop("last_customer")
|
kwargs["last_customer"] = self.request.session.pop("last_customer")
|
||||||
@@ -96,14 +140,17 @@ class CounterMain(
|
|||||||
)
|
)
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form: GetUserForm):
|
||||||
"""We handle here the redirection, passing the user id of the asked customer."""
|
"""We handle here the redirection, passing the user id of the asked customer."""
|
||||||
self.kwargs["user_id"] = form.cleaned_data["user_id"]
|
self.success_url = reverse(
|
||||||
|
"counter:click",
|
||||||
|
kwargs={
|
||||||
|
"counter_id": self.kwargs["counter_id"],
|
||||||
|
"user_id": form.cleaned_data["user_id"],
|
||||||
|
},
|
||||||
|
)
|
||||||
return super().form_valid(form)
|
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):
|
class CounterLastOperationsView(CounterTabsMixin, CanViewMixin, DetailView):
|
||||||
"""Provide the last operations to allow barmen to delete them."""
|
"""Provide the last operations to allow barmen to delete them."""
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ from django.db import migrations
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
# liste des autres migrations à appliquer avant celle-ci
|
# liste des autres migrations à appliquer avant celle-ci
|
||||||
]
|
]
|
||||||
@@ -117,14 +116,16 @@ Par exemple :
|
|||||||
```python
|
```python
|
||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
def forwards_func(apps, schema_editor):
|
def forwards_func(apps, schema_editor):
|
||||||
print("Appplication de la migration")
|
print("Appplication de la migration")
|
||||||
|
|
||||||
|
|
||||||
def reverse_func(apps, schema_editor):
|
def reverse_func(apps, schema_editor):
|
||||||
print("Annulation de la migration")
|
print("Annulation de la migration")
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -164,6 +165,7 @@ On écrirait donc, dans l'application `pedagogy` :
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
|
||||||
|
|
||||||
class UserUe(models.Model):
|
class UserUe(models.Model):
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
ue = models.CharField(max_length=10)
|
ue = models.CharField(max_length=10)
|
||||||
@@ -174,6 +176,7 @@ Et nous aurions le fichier de migration suivant :
|
|||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
@@ -289,6 +292,7 @@ La commande vous donnera ceci :
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
replaces = [("pedagogy", "0004_userue"), ("pedagogy", "0005_alter_userue_ue")]
|
replaces = [("pedagogy", "0004_userue"), ("pedagogy", "0005_alter_userue_ue")]
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,6 @@ for user in richest.annotate(amount=F("customer__amount"))[:100]:
|
|||||||
|
|
||||||
On aurait même pu réorganiser ça :
|
On aurait même pu réorganiser ça :
|
||||||
```python
|
```python
|
||||||
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
|
|
||||||
@@ -239,10 +238,7 @@ nous écrivons donc instinctivement :
|
|||||||
from counter.models import Counter
|
from counter.models import Counter
|
||||||
|
|
||||||
foyer = Counter.objects.get(name="Foyer")
|
foyer = Counter.objects.get(name="Foyer")
|
||||||
total_amount = sum(
|
total_amount = sum(sale.amount * sale.unit_price for sale in foyer.sellings.all())
|
||||||
sale.amount * sale.unit_price
|
|
||||||
for sale in foyer.sellings.all()
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
On pourrait penser qu'il n'y a pas de problème.
|
On pourrait penser qu'il n'y a pas de problème.
|
||||||
|
|||||||
@@ -27,12 +27,11 @@ SITH_SUBSCRIPTIONS = {
|
|||||||
"cursus-alternant": {"name": _("Alternating cursus"), "price": 30, "duration": 6},
|
"cursus-alternant": {"name": _("Alternating cursus"), "price": 30, "duration": 6},
|
||||||
"membre-honoraire": {"name": _("Honorary member"), "price": 0, "duration": 666},
|
"membre-honoraire": {"name": _("Honorary member"), "price": 0, "duration": 666},
|
||||||
"un-jour": {"name": _("One day"), "price": 0, "duration": 0.00555333},
|
"un-jour": {"name": _("One day"), "price": 0, "duration": 0.00555333},
|
||||||
|
|
||||||
# On rajoute ici notre cotisation
|
# On rajoute ici notre cotisation
|
||||||
# Elle se nomme "Un mois"
|
# Elle se nomme "Un mois"
|
||||||
# Coûte 6€
|
# Coûte 6€
|
||||||
# Dure 1 mois (on raisonne en semestre, ici, c'est 1/6 de semestre)
|
# 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},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Si le mot est dans le code Python :
|
|||||||
```python
|
```python
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
help_text=_("Hello")
|
help_text = _("Hello")
|
||||||
```
|
```
|
||||||
|
|
||||||
Si le mot apparaît dans le template Jinja :
|
Si le mot apparaît dans le template Jinja :
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from django.templatetags.static import static
|
|||||||
|
|
||||||
# Sélectionnez le fichier de bannière pour le weekmail de l'automne 2018
|
# Sélectionnez le fichier de bannière pour le weekmail de l'automne 2018
|
||||||
|
|
||||||
|
|
||||||
def get_banner(self):
|
def get_banner(self):
|
||||||
return "http://" + settings.SITH_URL + static("com/img/weekmail_bannerA18.jpg")
|
return "http://" + settings.SITH_URL + static("com/img/weekmail_bannerA18.jpg")
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -147,14 +147,15 @@ Voici quelques exemples :
|
|||||||
env = Env()
|
env = Env()
|
||||||
env.read_env()
|
env.read_env()
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
async with aiohttp.ClientSession(
|
async with aiohttp.ClientSession(
|
||||||
base_url="https://ae.utbm.fr/api/",
|
base_url="https://ae.utbm.fr/api/", headers={"X-APIKey": env.str("API_KEY")}
|
||||||
headers={"X-APIKey": env.str("API_KEY")}
|
|
||||||
) as session:
|
) as session:
|
||||||
async with session.get("club/1") as res:
|
async with session.get("club/1") as res:
|
||||||
print(await res.json())
|
print(await res.json())
|
||||||
|
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
|
||||||
|
## Fonctionnement général
|
||||||
|
|
||||||
La boutique en ligne nécessite une interaction
|
La boutique en ligne nécessite une interaction
|
||||||
avec la banque pour son fonctionnement.
|
avec la banque pour son fonctionnement.
|
||||||
|
|
||||||
@@ -9,3 +11,32 @@ Nous ne pouvons donc que vous redirigez vers la doc du crédit
|
|||||||
agricole :
|
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/)
|
[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):
|
def get_context_data(**kwargs):
|
||||||
return super().get_context_data(**kwargs) | {
|
return super().get_context_data(**kwargs) | {
|
||||||
"create_fragment": FooCreateFragment.as_fragment()(),
|
"create_fragment": FooCreateFragment.as_fragment()(),
|
||||||
"update_fragment": FooUpdateFragment.as_fragment()(foo_id=1)
|
"update_fragment": FooUpdateFragment.as_fragment()(foo_id=1),
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -319,11 +319,9 @@ from core.views.mixins import UseFragmentsMixin
|
|||||||
class FooCompositeFormView(UseFragmentsMixin, TemplateView):
|
class FooCompositeFormView(UseFragmentsMixin, TemplateView):
|
||||||
fragments = {
|
fragments = {
|
||||||
"create_fragment": FooCreateFragment,
|
"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"
|
template_name = "app/foo.jinja"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -342,9 +340,7 @@ from core.views.mixins import UseFragmentsMixin
|
|||||||
|
|
||||||
|
|
||||||
class FooCompositeFormView(UseFragmentsMixin, TemplateView):
|
class FooCompositeFormView(UseFragmentsMixin, TemplateView):
|
||||||
fragments = {
|
fragments = {"create_fragment": FooCreateFragment}
|
||||||
"create_fragment": FooCreateFragment
|
|
||||||
}
|
|
||||||
template_name = "app/foo.jinja"
|
template_name = "app/foo.jinja"
|
||||||
|
|
||||||
def get_fragment_context_data(self):
|
def get_fragment_context_data(self):
|
||||||
|
|||||||
@@ -263,35 +263,3 @@ avec un unique champ permettant de sélectionner des groupes.
|
|||||||
Par défaut, seuls les utilisateurs avec la permission
|
Par défaut, seuls les utilisateurs avec la permission
|
||||||
`auth.change_permission` auront accès à ce formulaire
|
`auth.change_permission` auront accès à ce formulaire
|
||||||
(donc, normalement, uniquement les utilisateurs Root).
|
(donc, normalement, uniquement les utilisateurs Root).
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant A as Utilisateur
|
|
||||||
participant B as ReverseProxy
|
|
||||||
participant C as MarkdownImage
|
|
||||||
participant D as Model
|
|
||||||
|
|
||||||
A->>B: GET /page/foo
|
|
||||||
B->>C: GET /page/foo
|
|
||||||
C-->>B: La page, avec les urls
|
|
||||||
B-->>A: La page, avec les urls
|
|
||||||
alt image publique
|
|
||||||
A->>B: GET markdown/public/2025/img.webp
|
|
||||||
B-->>A: img.webp
|
|
||||||
end
|
|
||||||
alt image privée
|
|
||||||
A->>B: GET markdown_image/{id}
|
|
||||||
B->>C: GET markdown_image/{id}
|
|
||||||
C->>D: user.can_view(image)
|
|
||||||
alt l'utilisateur a le droit de voir l'image
|
|
||||||
D-->>C: True
|
|
||||||
C-->>B: 200 (avec le X-Accel-Redirect)
|
|
||||||
B-->>A: img.webp
|
|
||||||
end
|
|
||||||
alt l'utilisateur n'a pas le droit de l'image
|
|
||||||
D-->>C: False
|
|
||||||
C-->>B: 403
|
|
||||||
B-->>A: 403
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ Par exemple, prenons le modèle suivant :
|
|||||||
```python
|
```python
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
class News(models.Model):
|
class News(models.Model):
|
||||||
# ...
|
# ...
|
||||||
|
|
||||||
@@ -199,6 +200,7 @@ Pour les vues sous forme de fonction, il y a le décorateur
|
|||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.generic.detail import SingleObjectMixin
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
|
|
||||||
|
|
||||||
class NewsModerateView(PermissionRequiredMixin, SingleObjectMixin, View):
|
class NewsModerateView(PermissionRequiredMixin, SingleObjectMixin, View):
|
||||||
model = News
|
model = News
|
||||||
pk_url_kwarg = "news_id"
|
pk_url_kwarg = "news_id"
|
||||||
@@ -225,6 +227,7 @@ Pour les vues sous forme de fonction, il y a le décorateur
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
|
|
||||||
@permission_required("com.moderate_news")
|
@permission_required("com.moderate_news")
|
||||||
@require_POST
|
@require_POST
|
||||||
def moderate_news(request, news_id: int):
|
def moderate_news(request, news_id: int):
|
||||||
@@ -263,6 +266,7 @@ from core.auth.mixins import PermissionOrAuthorRequiredMixin
|
|||||||
|
|
||||||
from django.views.generic import UpdateView
|
from django.views.generic import UpdateView
|
||||||
|
|
||||||
|
|
||||||
class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
|
class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
|
||||||
model = News
|
model = News
|
||||||
pk_url_kwarg = "news_id"
|
pk_url_kwarg = "news_id"
|
||||||
@@ -324,8 +328,8 @@ Voici un exemple d'implémentation de ce système :
|
|||||||
|
|
||||||
from core.models import User, Group
|
from core.models import User, Group
|
||||||
|
|
||||||
class Article(models.Model):
|
|
||||||
|
|
||||||
|
class Article(models.Model):
|
||||||
title = models.CharField(_("title"), max_length=100)
|
title = models.CharField(_("title"), max_length=100)
|
||||||
content = models.TextField(_("content"))
|
content = models.TextField(_("content"))
|
||||||
|
|
||||||
@@ -370,6 +374,7 @@ Voici un exemple d'implémentation de ce système :
|
|||||||
|
|
||||||
from core.models import User, Group
|
from core.models import User, Group
|
||||||
|
|
||||||
|
|
||||||
class Article(models.Model):
|
class Article(models.Model):
|
||||||
title = models.CharField(_("title"), max_length=100)
|
title = models.CharField(_("title"), max_length=100)
|
||||||
content = models.TextField(_("content"))
|
content = models.TextField(_("content"))
|
||||||
@@ -529,10 +534,7 @@ class NewsQuerySet(models.QuerySet): # (1)!
|
|||||||
return self
|
return self
|
||||||
# sinon, on retourne les nouvelles modérées ou dont l'utilisateur
|
# sinon, on retourne les nouvelles modérées ou dont l'utilisateur
|
||||||
# est l'auteur
|
# est l'auteur
|
||||||
return self.filter(
|
return self.filter(models.Q(is_moderated=True) | models.Q(author=user))
|
||||||
models.Q(is_moderated=True)
|
|
||||||
| models.Q(author=user)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class News(models.Model):
|
class News(models.Model):
|
||||||
|
|||||||
+10
-1
@@ -1,3 +1,6 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ninja import Status
|
||||||
from ninja_extra import ControllerBase, api_controller, route
|
from ninja_extra import ControllerBase, api_controller, route
|
||||||
from ninja_extra.exceptions import NotFound
|
from ninja_extra.exceptions import NotFound
|
||||||
|
|
||||||
@@ -8,13 +11,19 @@ from eboutic.models import Basket
|
|||||||
|
|
||||||
@api_controller("/etransaction", permissions=[CanView])
|
@api_controller("/etransaction", permissions=[CanView])
|
||||||
class EtransactionInfoController(ControllerBase):
|
class EtransactionInfoController(ControllerBase):
|
||||||
@route.get("/data/{basket_id}", url_name="etransaction_data")
|
@route.get(
|
||||||
|
"/data/{basket_id}",
|
||||||
|
url_name="etransaction_data",
|
||||||
|
response={200: dict[str, Any], 410: str},
|
||||||
|
)
|
||||||
def fetch_etransaction_data(self, basket_id: int):
|
def fetch_etransaction_data(self, basket_id: int):
|
||||||
"""Generate the data to pay an eboutic command with paybox.
|
"""Generate the data to pay an eboutic command with paybox.
|
||||||
|
|
||||||
The data is generated with the basket that is used by the current session.
|
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)
|
basket: Basket = self.get_object_or_exception(Basket, pk=basket_id)
|
||||||
|
if basket.is_expired:
|
||||||
|
return Status(410, "This basket is expired.")
|
||||||
try:
|
try:
|
||||||
return dict(basket.get_e_transaction_data())
|
return dict(basket.get_e_transaction_data())
|
||||||
except BillingInfo.DoesNotExist as e:
|
except BillingInfo.DoesNotExist as e:
|
||||||
|
|||||||
+36
-37
@@ -16,7 +16,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hmac
|
import hmac
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
|
||||||
from typing import Self
|
from typing import Self
|
||||||
|
|
||||||
from dict2xml import dict2xml
|
from dict2xml import dict2xml
|
||||||
@@ -24,6 +23,7 @@ from django.conf import settings
|
|||||||
from django.db import DataError, models
|
from django.db import DataError, models
|
||||||
from django.db.models import F, OuterRef, Subquery, Sum
|
from django.db.models import F, OuterRef, Subquery, Sum
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
@@ -39,30 +39,6 @@ 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):
|
class Basket(models.Model):
|
||||||
"""Basket is built when the user connects to an eboutic page."""
|
"""Basket is built when the user connects to an eboutic page."""
|
||||||
|
|
||||||
@@ -95,6 +71,19 @@ 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(
|
def generate_sales(
|
||||||
self, counter, seller: User, payment_method: Selling.PaymentMethod
|
self, counter, seller: User, payment_method: Selling.PaymentMethod
|
||||||
):
|
):
|
||||||
@@ -133,15 +122,22 @@ class Basket(models.Model):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def get_e_transaction_data(self) -> list[tuple[str, str]]:
|
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
|
user = self.user
|
||||||
if not hasattr(user, "customer"):
|
if not hasattr(user, "customer"):
|
||||||
raise Customer.DoesNotExist
|
raise Customer.DoesNotExist
|
||||||
|
if self.is_expired:
|
||||||
|
raise ValueError("This method cannot be called on an expired basket.")
|
||||||
customer = user.customer
|
customer = user.customer
|
||||||
if (
|
if not hasattr(user.customer, "billing_infos"):
|
||||||
not hasattr(user.customer, "billing_infos")
|
|
||||||
or BillingInfoState.from_model(user.customer.billing_infos)
|
|
||||||
!= BillingInfoState.VALID
|
|
||||||
):
|
|
||||||
raise BillingInfo.DoesNotExist
|
raise BillingInfo.DoesNotExist
|
||||||
cart = {
|
cart = {
|
||||||
"shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}}
|
"shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}}
|
||||||
@@ -155,6 +151,10 @@ class Basket(models.Model):
|
|||||||
("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT),
|
("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT),
|
||||||
("PBX_TOTAL", str(int(self.total * 100))),
|
("PBX_TOTAL", str(int(self.total * 100))),
|
||||||
("PBX_DEVISE", "978"), # This is Euro
|
("PBX_DEVISE", "978"), # This is Euro
|
||||||
|
(
|
||||||
|
"PBX_DISPLAY",
|
||||||
|
str(int(settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT.total_seconds())),
|
||||||
|
),
|
||||||
("PBX_CMD", str(self.id)),
|
("PBX_CMD", str(self.id)),
|
||||||
("PBX_PORTEUR", user.email),
|
("PBX_PORTEUR", user.email),
|
||||||
("PBX_RETOUR", "Amount:M;BasketID:R;Auto:A;Error:E;Sig:K"),
|
("PBX_RETOUR", "Amount:M;BasketID:R;Auto:A;Error:E;Sig:K"),
|
||||||
@@ -219,16 +219,14 @@ class Invoice(models.Model):
|
|||||||
if self.validated:
|
if self.validated:
|
||||||
raise DataError(_("Invoice already validated"))
|
raise DataError(_("Invoice already validated"))
|
||||||
customer, _created = Customer.get_or_create(user=self.user)
|
customer, _created = Customer.get_or_create(user=self.user)
|
||||||
kwargs = {
|
kwargs = {"counter": get_eboutic(), "customer": customer, "date": self.date}
|
||||||
"counter": get_eboutic(),
|
|
||||||
"customer": customer,
|
|
||||||
"date": self.date,
|
|
||||||
"payment_method": Selling.PaymentMethod.CARD,
|
|
||||||
}
|
|
||||||
for i in self.items.select_related("product"):
|
for i in self.items.select_related("product"):
|
||||||
if i.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
|
if i.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
|
||||||
Refilling.objects.create(
|
Refilling.objects.create(
|
||||||
**kwargs, operator=self.user, amount=i.unit_price * i.quantity
|
**kwargs,
|
||||||
|
operator=self.user,
|
||||||
|
amount=i.unit_price * i.quantity,
|
||||||
|
payment_method=Refilling.PaymentMethod.CARD,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
Selling.objects.create(
|
Selling.objects.create(
|
||||||
@@ -239,6 +237,7 @@ class Invoice(models.Model):
|
|||||||
seller=self.user,
|
seller=self.user,
|
||||||
unit_price=i.unit_price,
|
unit_price=i.unit_price,
|
||||||
quantity=i.quantity,
|
quantity=i.quantity,
|
||||||
|
payment_method=Selling.PaymentMethod.CARD,
|
||||||
)
|
)
|
||||||
self.validated = True
|
self.validated = True
|
||||||
self.save()
|
self.save()
|
||||||
|
|||||||
@@ -1,22 +1,76 @@
|
|||||||
|
import { type Notification, NotificationLevel } from "#core:utils/notifications";
|
||||||
import { etransactioninfoFetchEtransactionData } from "#openapi";
|
import { etransactioninfoFetchEtransactionData } from "#openapi";
|
||||||
|
|
||||||
|
interface Basket {
|
||||||
|
id: number;
|
||||||
|
timeout: Date;
|
||||||
|
}
|
||||||
document.addEventListener("alpine:init", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
Alpine.data("etransaction", (initialData, basketId: number) => ({
|
Alpine.data(
|
||||||
|
"etransaction",
|
||||||
|
(initialData: Record<string, string>, basket: Basket) => ({
|
||||||
data: initialData,
|
data: initialData,
|
||||||
isCbAvailable: Object.keys(initialData).length > 0,
|
isCbAvailable: Object.keys(initialData).length > 0,
|
||||||
|
isSithAvailable: true,
|
||||||
|
|
||||||
async fill() {
|
init() {
|
||||||
this.isCbAvailable = false;
|
const now = new Date();
|
||||||
const res = await etransactioninfoFetchEtransactionData({
|
const timeout = basket.timeout.getTime() - now.getTime();
|
||||||
path: {
|
if (timeout > 0) {
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
// if not going inside this condition, it means that
|
||||||
basket_id: basketId,
|
// basket was already outdated at initial page load,
|
||||||
},
|
// in which case disabling buttons and displaying
|
||||||
});
|
// error message has been done at rendering time
|
||||||
if (res.response.ok) {
|
setTimeout(() => this.timeoutBasket(), timeout);
|
||||||
this.data = res.data;
|
|
||||||
this.isCbAvailable = true;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}));
|
|
||||||
|
/**
|
||||||
|
* 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({
|
||||||
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
|
path: { basket_id: basket.id },
|
||||||
|
});
|
||||||
|
if (res.response.ok) {
|
||||||
|
this.data = res.data as Record<string, string>;
|
||||||
|
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,13 +5,14 @@ interface BasketItem {
|
|||||||
name: string;
|
name: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
|
isRefill: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASKET_CACHE_KEY = "basket";
|
const BASKET_CACHE_KEY = "basket";
|
||||||
const BASKET_CACHE_VERSION = 1;
|
const BASKET_CACHE_VERSION = 2;
|
||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
Alpine.data("basket", (lastPurchaseTime?: number) => ({
|
Alpine.data("basket", (validPrices: number[], lastPurchaseTime?: number) => ({
|
||||||
basket: [] as BasketItem[],
|
basket: [] as BasketItem[],
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -19,25 +20,31 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.$watch("basket", () => {
|
this.$watch("basket", () => {
|
||||||
this.saveBasket();
|
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
|
document
|
||||||
.getElementById("id_form-TOTAL_FORMS")
|
.getElementById("id_form-TOTAL_FORMS")
|
||||||
.setAttribute(":value", "basket.length");
|
?.setAttribute(":value", "basket.length");
|
||||||
},
|
},
|
||||||
|
|
||||||
loadBasket(): BasketItem[] {
|
loadBasket(): BasketItem[] {
|
||||||
const cached = versionedLocalStorage.getItem<BasketItem[]>(BASKET_CACHE_KEY, {
|
const cached = versionedLocalStorage.getItem<BasketItem[]>(BASKET_CACHE_KEY, {
|
||||||
version: BASKET_CACHE_VERSION,
|
version: BASKET_CACHE_VERSION,
|
||||||
});
|
});
|
||||||
return cached ?? [];
|
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));
|
||||||
},
|
},
|
||||||
|
|
||||||
saveBasket() {
|
saveBasket() {
|
||||||
@@ -58,6 +65,19 @@ 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
|
* Add 1 to the quantity of an item in the basket
|
||||||
* @param {BasketItem} item
|
* @param {BasketItem} item
|
||||||
@@ -80,7 +100,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
|
|
||||||
if (this.basket[index].quantity === 0) {
|
if (this.basket[index].quantity === 0) {
|
||||||
this.basket = this.basket.filter(
|
this.basket = this.basket.filter(
|
||||||
(e: BasketItem) => e.priceId !== this.basket[index].id,
|
(e: BasketItem) => e.priceId !== this.basket[index].priceId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -97,14 +117,16 @@ document.addEventListener("alpine:init", () => {
|
|||||||
* @param id The id of the product to add
|
* @param id The id of the product to add
|
||||||
* @param name The name of the product
|
* @param name The name of the product
|
||||||
* @param price The unit price 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
|
* @returns The created item
|
||||||
*/
|
*/
|
||||||
createItem(id: number, name: string, price: number): BasketItem {
|
createItem(id: number, name: string, price: number, isRefill: boolean): BasketItem {
|
||||||
const newItem = {
|
const newItem = {
|
||||||
priceId: id,
|
priceId: id,
|
||||||
name,
|
name,
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
unitPrice: price,
|
unitPrice: price,
|
||||||
|
isRefill,
|
||||||
} as BasketItem;
|
} as BasketItem;
|
||||||
|
|
||||||
this.basket.push(newItem);
|
this.basket.push(newItem);
|
||||||
@@ -119,16 +141,17 @@ document.addEventListener("alpine:init", () => {
|
|||||||
* @param id The id of the product to add
|
* @param id The id of the product to add
|
||||||
* @param name The name of the product
|
* @param name The name of the product
|
||||||
* @param price The unit price 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) {
|
addFromCatalog(id: number, name: string, price: number, isRefill: boolean) {
|
||||||
let item = this.basket.find((e: BasketItem) => e.priceId === id);
|
const item = this.basket.find((e: BasketItem) => e.priceId === id);
|
||||||
|
|
||||||
// if the item is not in the basket, we create it
|
// if the item is not in the basket, we create it
|
||||||
// else we add + 1 to it
|
// else we add + 1 to it
|
||||||
if (item) {
|
if (item) {
|
||||||
this.add(item);
|
this.add(item);
|
||||||
} else {
|
} else {
|
||||||
item = this.createItem(id, name, price);
|
this.createItem(id, name, price, isRefill);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -21,9 +21,10 @@
|
|||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-target="#billing-infos-fragment"
|
hx-target="#billing-infos-fragment"
|
||||||
x-show="collapsed"
|
x-show="collapsed"
|
||||||
|
x-cloak
|
||||||
>
|
>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p() }}
|
{{ form }}
|
||||||
<br>
|
<br>
|
||||||
<input
|
<input
|
||||||
type="submit" class="btn btn-blue clickable"
|
type="submit" class="btn btn-blue clickable"
|
||||||
|
|||||||
@@ -16,17 +16,20 @@
|
|||||||
<h3>{% trans %}Eboutic{% endtrans %}</h3>
|
<h3>{% trans %}Eboutic{% endtrans %}</h3>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
let billingInfos = {{ billing_infos|safe }};
|
const billingInfos = {{ billing_infos|safe }};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div x-data="etransaction(billingInfos, {{ basket.id }})">
|
<div x-data='etransaction(
|
||||||
|
billingInfos,
|
||||||
|
{ id: {{ basket.id }}, timeout: new Date("{{ basket.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT }}") }
|
||||||
|
)'>
|
||||||
<p>{% trans %}Basket: {% endtrans %}</p>
|
<p>{% trans %}Basket: {% endtrans %}</p>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Article</td>
|
<td>Article</td>
|
||||||
<td>Quantity</td>
|
<td>{% trans %}Quantity{% endtrans %}</td>
|
||||||
<td>Unit price</td>
|
<td>{% trans %}Unit price{% endtrans %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -60,10 +63,41 @@
|
|||||||
<div @htmx:after-request="fill">
|
<div @htmx:after-request="fill">
|
||||||
{{ billing_infos_form }}
|
{{ billing_infos_form }}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% include "core/base/notifications.jinja" %}
|
{% 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
|
<form
|
||||||
method="post"
|
method="post"
|
||||||
|
id="bank-payment-form"
|
||||||
action="{{ settings.SITH_EBOUTIC_ET_URL }}"
|
action="{{ settings.SITH_EBOUTIC_ET_URL }}"
|
||||||
|
@submit="if (!$refs.cgvForm.reportValidity()) $event.preventDefault()"
|
||||||
>
|
>
|
||||||
<template x-for="[key, value] in Object.entries(data)" :key="key">
|
<template x-for="[key, value] in Object.entries(data)" :key="key">
|
||||||
<input type="hidden" :name="key" :value="value">
|
<input type="hidden" :name="key" :value="value">
|
||||||
@@ -72,7 +106,11 @@
|
|||||||
x-cloak
|
x-cloak
|
||||||
type="submit"
|
type="submit"
|
||||||
id="bank-submit-button"
|
id="bank-submit-button"
|
||||||
|
{% if basket.is_expired %}
|
||||||
|
disabled="disabled"
|
||||||
|
{% else %}
|
||||||
:disabled="!isCbAvailable"
|
:disabled="!isCbAvailable"
|
||||||
|
{% endif %}
|
||||||
class="btn btn-blue"
|
class="btn btn-blue"
|
||||||
value="{% trans %}Pay with credit card{% endtrans %}"
|
value="{% trans %}Pay with credit card{% endtrans %}"
|
||||||
/>
|
/>
|
||||||
@@ -91,9 +129,23 @@
|
|||||||
{% elif basket.total > user.account_balance %}
|
{% elif basket.total > user.account_balance %}
|
||||||
<p>{% trans %}AE account payment disabled because you do not have enough money remaining.{% endtrans %}</p>
|
<p>{% trans %}AE account payment disabled because you do not have enough money remaining.{% endtrans %}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form">
|
<form
|
||||||
|
method="post"
|
||||||
|
action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}"
|
||||||
|
id="sith-payment-form"
|
||||||
|
@submit="if (!$refs.cgvForm.reportValidity()) $event.preventDefault()"
|
||||||
|
>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input class="btn btn-blue" type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/>
|
<input
|
||||||
|
{% if basket.is_expired %}
|
||||||
|
disabled="disabled"
|
||||||
|
{% else %}
|
||||||
|
:disabled="!isSithAvailable"
|
||||||
|
{% endif %}
|
||||||
|
class="btn btn-blue"
|
||||||
|
type="submit"
|
||||||
|
value="{% trans %}Pay with Sith account{% endtrans %}"
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,17 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1>
|
<h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1>
|
||||||
|
|
||||||
<div id="eboutic" x-data="basket({{ last_purchase_time }})">
|
<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="basket">
|
<div id="basket">
|
||||||
<h3>Panier</h3>
|
<h3>Panier</h3>
|
||||||
<form method="post" action="">
|
<form method="post" action="">
|
||||||
@@ -48,6 +58,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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">
|
<ul class="item-list">
|
||||||
{# Starting money #}
|
{# Starting money #}
|
||||||
<li>
|
<li>
|
||||||
@@ -99,9 +120,12 @@
|
|||||||
<i class="fa fa-trash"></i>
|
<i class="fa fa-trash"></i>
|
||||||
{% trans %}Clear{% endtrans %}
|
{% trans %}Clear{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-blue">
|
<button
|
||||||
|
class="btn btn-blue"
|
||||||
|
:disabled="(getTotalAdded() + {{ customer_amount }}) > {{ settings.SITH_ACCOUNT_MAX_MONEY }}"
|
||||||
|
>
|
||||||
<i class="fa fa-check"></i>
|
<i class="fa fa-check"></i>
|
||||||
<input type="submit" value="{% trans %}Validate{% endtrans %}"/>
|
{% trans %}Validate{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -124,56 +148,6 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% for prices in categories %}
|
||||||
{% set category = prices[0].product.product_type %}
|
{% set category = prices[0].product.product_type %}
|
||||||
<section>
|
<section>
|
||||||
@@ -187,9 +161,15 @@
|
|||||||
{% for price in prices %}
|
{% for price in prices %}
|
||||||
<button
|
<button
|
||||||
id="{{ price.id }}"
|
id="{{ price.id }}"
|
||||||
class="card product-button clickable shadow"
|
class="card clickable shadow"
|
||||||
:class="{selected: basket.some((i) => i.priceId === {{ price.id }})}"
|
:class="{selected: basket.some((i) => i.priceId === {{ price.id }})}"
|
||||||
@click='addFromCatalog({{ price.id }}, {{ price.full_label|tojson }}, {{ price.amount }})'
|
@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 %}
|
||||||
>
|
>
|
||||||
{% if price.product.icon %}
|
{% if price.product.icon %}
|
||||||
<img
|
<img
|
||||||
@@ -202,6 +182,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<h4 class="card-title">{{ price.full_label }}</h4>
|
<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>
|
<p>{{ price.amount }} €</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<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,14 +1,19 @@
|
|||||||
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import freezegun
|
||||||
import pytest
|
import pytest
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from django.conf import settings
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.test.client import Client
|
from django.test.client import Client
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.timezone import localdate
|
from django.utils.timezone import localdate, now
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertRedirects
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
|
import eboutic.models
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import Group, User
|
from core.models import Group, User
|
||||||
from counter.baker_recipes import (
|
from counter.baker_recipes import (
|
||||||
@@ -130,9 +135,11 @@ def test_eboutic_basket_expiry(
|
|||||||
_bulk_create=True,
|
_bulk_create=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
soup = BeautifulSoup(client.get(reverse("eboutic:main")).text, "lxml")
|
||||||
assert (
|
assert (
|
||||||
f'x-data="basket({int(expected.timestamp() * 1000) if expected else "null"})"'
|
# remove any space from the value before asserting
|
||||||
in client.get(reverse("eboutic:main")).text
|
re.sub(r"\s+", "", soup.find(id="eboutic").attrs["x-data"])
|
||||||
|
== f"basket([],{int(expected.timestamp() * 1000) if expected else 'null'},)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -231,26 +238,66 @@ class TestEboutic(TestCase):
|
|||||||
|
|
||||||
def test_add_forbidden_product(self):
|
def test_add_forbidden_product(self):
|
||||||
self.client.force_login(self.new_customer)
|
self.client.force_login(self.new_customer)
|
||||||
response = self.submit_basket([BasketItem(self.beer.id, 1)])
|
for product in self.beer, self.cotiz, self.not_in_counter:
|
||||||
|
response = self.submit_basket([BasketItem(product.id, 1)])
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert Basket.objects.first() is None
|
assert not Basket.objects.exists()
|
||||||
|
|
||||||
response = self.submit_basket([BasketItem(self.cotiz.id, 1)])
|
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)])
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert Basket.objects.first() is None
|
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
|
||||||
|
|
||||||
response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)])
|
def test_refill_limit(self):
|
||||||
assert response.status_code == 200
|
"""Test that an eboutic basket cannot refill an account above the limit."""
|
||||||
assert Basket.objects.first() is None
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
self.client.force_login(self.new_customer)
|
response = self.submit_basket([BasketItem(price.id, 10)])
|
||||||
response = self.submit_basket([BasketItem(self.cotiz.id, 1)])
|
assert Basket.objects.count() == 1
|
||||||
assert response.status_code == 200
|
assertRedirects(response, reverse("eboutic:checkout", kwargs={"basket_id": 1}))
|
||||||
assert Basket.objects.first() is None
|
|
||||||
|
|
||||||
response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)])
|
response = self.submit_basket([BasketItem(price.id, 11)])
|
||||||
assert response.status_code == 200
|
assert Basket.objects.count() == 1
|
||||||
assert Basket.objects.first() is None
|
assert response.status_code == 200 # no redirect = form validation failed
|
||||||
|
|
||||||
def test_create_basket(self):
|
def test_create_basket(self):
|
||||||
self.client.force_login(self.new_customer)
|
self.client.force_login(self.new_customer)
|
||||||
|
|||||||
@@ -37,12 +37,9 @@ class TestBillingInfo:
|
|||||||
|
|
||||||
def test_edit_infos(self, client: Client, payload: dict[str, str]):
|
def test_edit_infos(self, client: Client, payload: dict[str, str]):
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
baker.make(BillingInfo, customer=user.customer)
|
baker.make(BillingInfo, customer=user.customer, phone_number="06 01 02 03 04")
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
response = client.post(
|
response = client.post(reverse("eboutic:billing_infos"), payload)
|
||||||
reverse("eboutic:billing_infos"),
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
user.refresh_from_db()
|
user.refresh_from_db()
|
||||||
infos = BillingInfo.objects.get(customer__user=user)
|
infos = BillingInfo.objects.get(customer__user=user)
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import urllib
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import freezegun
|
||||||
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
|
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
|
||||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||||
from cryptography.hazmat.primitives.hashes import SHA1
|
from cryptography.hazmat.primitives.hashes import SHA1
|
||||||
@@ -17,7 +18,7 @@ from pytest_django.asserts import assertRedirects
|
|||||||
|
|
||||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||||
from counter.baker_recipes import price_recipe, product_recipe
|
from counter.baker_recipes import price_recipe, product_recipe
|
||||||
from counter.models import Product, ProductType, Selling
|
from counter.models import Product, ProductType, Refilling, Selling
|
||||||
from counter.tests.test_counter import force_refill_user
|
from counter.tests.test_counter import force_refill_user
|
||||||
from eboutic.models import Basket, BasketItem
|
from eboutic.models import Basket, BasketItem
|
||||||
|
|
||||||
@@ -105,7 +106,7 @@ class TestPaymentSith(TestPaymentBase):
|
|||||||
),
|
),
|
||||||
reverse("eboutic:payment_result", kwargs={"result": "success"}),
|
reverse("eboutic:payment_result", kwargs={"result": "success"}),
|
||||||
)
|
)
|
||||||
assert Basket.objects.filter(id=self.basket.id).first() is None
|
assert not Basket.objects.filter(id=self.basket.id).exists()
|
||||||
self.customer.customer.refresh_from_db()
|
self.customer.customer.refresh_from_db()
|
||||||
assert self.customer.customer.amount == Decimal(1)
|
assert self.customer.customer.amount == Decimal(1)
|
||||||
|
|
||||||
@@ -139,10 +140,7 @@ class TestPaymentSith(TestPaymentBase):
|
|||||||
assert len(messages) == 1
|
assert len(messages) == 1
|
||||||
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
|
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
|
||||||
assert messages[0].message == "Solde insuffisant"
|
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):
|
def test_refilling_in_basket(self):
|
||||||
BasketItem.from_price(self.refilling.prices.first(), 1, self.basket).save()
|
BasketItem.from_price(self.refilling.prices.first(), 1, self.basket).save()
|
||||||
@@ -157,7 +155,7 @@ class TestPaymentSith(TestPaymentBase):
|
|||||||
response,
|
response,
|
||||||
reverse("eboutic:payment_result", kwargs={"result": "failure"}),
|
reverse("eboutic:payment_result", kwargs={"result": "failure"}),
|
||||||
)
|
)
|
||||||
assert Basket.objects.filter(id=self.basket.id).first() is not None
|
assert not Basket.objects.filter(id=self.basket.id).exists()
|
||||||
messages = list(get_messages(response.wsgi_request))
|
messages = list(get_messages(response.wsgi_request))
|
||||||
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
|
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
|
||||||
assert (
|
assert (
|
||||||
@@ -167,6 +165,24 @@ class TestPaymentSith(TestPaymentBase):
|
|||||||
self.customer.customer.refresh_from_db()
|
self.customer.customer.refresh_from_db()
|
||||||
assert self.customer.customer.amount == initial_account_balance
|
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):
|
class TestPaymentCard(TestPaymentBase):
|
||||||
def generate_bank_valid_answer(self, basket: Basket):
|
def generate_bank_valid_answer(self, basket: Basket):
|
||||||
@@ -236,6 +252,10 @@ class TestPaymentCard(TestPaymentBase):
|
|||||||
|
|
||||||
self.customer.customer.refresh_from_db()
|
self.customer.customer.refresh_from_db()
|
||||||
assert self.customer.customer.amount == price.amount * 2
|
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):
|
def test_multiple_responses(self):
|
||||||
bank_response = self.generate_bank_valid_answer(self.basket)
|
bank_response = self.generate_bank_valid_answer(self.basket)
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ from eboutic.views import (
|
|||||||
EbouticMainView,
|
EbouticMainView,
|
||||||
EbouticPayWithSith,
|
EbouticPayWithSith,
|
||||||
EtransactionAutoAnswer,
|
EtransactionAutoAnswer,
|
||||||
EurockPartnerFragment,
|
|
||||||
payment_result,
|
payment_result,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,5 +50,4 @@ urlpatterns = [
|
|||||||
EtransactionAutoAnswer.as_view(),
|
EtransactionAutoAnswer.as_view(),
|
||||||
name="etransation_autoanswer",
|
name="etransation_autoanswer",
|
||||||
),
|
),
|
||||||
path("eurock/", EurockPartnerFragment.as_view(), name="eurock"),
|
|
||||||
]
|
]
|
||||||
|
|||||||
+41
-36
@@ -33,19 +33,21 @@ from django.contrib.auth.mixins import LoginRequiredMixin
|
|||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.core.exceptions import SuspiciousOperation, ValidationError
|
from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||||
from django.db import DatabaseError, transaction
|
from django.db import DatabaseError, transaction
|
||||||
from django.db.models import Subquery
|
from django.db.models import Exists, OuterRef, Subquery
|
||||||
from django.db.models.fields import forms
|
from django.db.models.fields import forms
|
||||||
from django.db.utils import cached_property
|
from django.db.utils import cached_property
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
from django.urls import reverse
|
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.utils.translation import gettext_lazy as _
|
||||||
from django.views.decorators.http import require_GET
|
from django.views.decorators.http import require_GET
|
||||||
from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View
|
from django.views.generic import DetailView, FormView, UpdateView, View
|
||||||
from django.views.generic.edit import SingleObjectMixin
|
from django.views.generic.edit import SingleObjectMixin
|
||||||
from django_countries.fields import Country
|
from django_countries.fields import Country
|
||||||
|
|
||||||
from core.auth.mixins import CanViewMixin, IsSubscriberMixin
|
from core.auth.mixins import CanViewMixin
|
||||||
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
||||||
from counter.forms import BaseBasketForm, BasketItemForm, BillingInfoForm
|
from counter.forms import BaseBasketForm, BasketItemForm, BillingInfoForm
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
@@ -56,7 +58,7 @@ from counter.models import (
|
|||||||
Selling,
|
Selling,
|
||||||
get_eboutic,
|
get_eboutic,
|
||||||
)
|
)
|
||||||
from eboutic.models import Basket, BasketItem, BillingInfoState, Invoice, InvoiceItem
|
from eboutic.models import Basket, BasketItem, Invoice, InvoiceItem
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
||||||
@@ -64,9 +66,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
class BaseEbouticBasketForm(BaseBasketForm):
|
class BaseEbouticBasketForm(BaseBasketForm):
|
||||||
def _check_enough_money(self, *args, **kwargs):
|
min_result_balance = None # user can pay by card, so no minimum enforced
|
||||||
# Disable money check
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
EbouticBasketForm = forms.formset_factory(
|
EbouticBasketForm = forms.formset_factory(
|
||||||
@@ -86,13 +86,15 @@ class EbouticMainView(LoginRequiredMixin, FormView):
|
|||||||
form_class = EbouticBasketForm
|
form_class = EbouticBasketForm
|
||||||
|
|
||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
kwargs = super().get_form_kwargs()
|
return super().get_form_kwargs() | {
|
||||||
kwargs["form_kwargs"] = {
|
|
||||||
"customer": self.customer,
|
"customer": self.customer,
|
||||||
"counter": get_eboutic(),
|
"counter": get_eboutic(),
|
||||||
"allowed_prices": {price.id: price for price in self.prices},
|
"form_kwargs": {
|
||||||
|
"allowed_prices": {
|
||||||
|
price.id: price for price in self.prices if not price.sold_out
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return kwargs
|
|
||||||
|
|
||||||
def form_valid(self, formset):
|
def form_valid(self, formset):
|
||||||
if len(formset) == 0:
|
if len(formset) == 0:
|
||||||
@@ -116,9 +118,14 @@ class EbouticMainView(LoginRequiredMixin, FormView):
|
|||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def prices(self) -> list[Price]:
|
def prices(self) -> list[Price]:
|
||||||
return get_eboutic().get_prices_for(
|
eboutic = get_eboutic()
|
||||||
self.customer,
|
sold_out_subquery = ~Exists(
|
||||||
order_by=["product__product_type__order", "product_id", "amount"],
|
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")
|
||||||
)
|
)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
@@ -178,7 +185,7 @@ def payment_result(request, result: str) -> HttpResponse:
|
|||||||
class BillingInfoFormFragment(
|
class BillingInfoFormFragment(
|
||||||
LoginRequiredMixin, FragmentMixin, SuccessMessageMixin, UpdateView
|
LoginRequiredMixin, FragmentMixin, SuccessMessageMixin, UpdateView
|
||||||
):
|
):
|
||||||
"""Update billing info"""
|
"""Update or create billing info"""
|
||||||
|
|
||||||
model = BillingInfo
|
model = BillingInfo
|
||||||
form_class = BillingInfoForm
|
form_class = BillingInfoForm
|
||||||
@@ -187,9 +194,7 @@ class BillingInfoFormFragment(
|
|||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
if self.object is None:
|
if self.object is None:
|
||||||
return {
|
return {"country": Country(code="FR")}
|
||||||
"country": Country(code="FR"),
|
|
||||||
}
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def render_fragment(self, request, **kwargs) -> SafeString:
|
def render_fragment(self, request, **kwargs) -> SafeString:
|
||||||
@@ -211,24 +216,13 @@ class BillingInfoFormFragment(
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
kwargs = super().get_context_data(**kwargs)
|
kwargs = super().get_context_data(**kwargs)
|
||||||
kwargs["billing_infos_state"] = BillingInfoState.from_model(self.object)
|
|
||||||
kwargs["action"] = reverse("eboutic:billing_infos")
|
kwargs["action"] = reverse("eboutic:billing_infos")
|
||||||
match BillingInfoState.from_model(self.object):
|
if not self.object:
|
||||||
case BillingInfoState.EMPTY:
|
|
||||||
messages.warning(
|
messages.warning(
|
||||||
self.request,
|
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
|
return kwargs
|
||||||
@@ -255,6 +249,15 @@ class EbouticCheckout(CanViewMixin, UseFragmentsMixin, DetailView):
|
|||||||
kwargs["customer_amount"] = None
|
kwargs["customer_amount"] = None
|
||||||
kwargs["billing_infos"] = {}
|
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):
|
with contextlib.suppress(BillingInfo.DoesNotExist):
|
||||||
kwargs["billing_infos"] = json.dumps(
|
kwargs["billing_infos"] = json.dumps(
|
||||||
dict(self.object.get_e_transaction_data())
|
dict(self.object.get_e_transaction_data())
|
||||||
@@ -268,9 +271,14 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
|
|||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
basket = self.get_object()
|
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
|
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
||||||
if basket.items.filter(product__product_type_id=refilling).exists():
|
if basket.items.filter(product__product_type_id=refilling).exists():
|
||||||
messages.error(self.request, _("You can't buy a refilling with sith money"))
|
messages.error(self.request, _("You can't buy a refilling with sith money"))
|
||||||
|
basket.delete()
|
||||||
return redirect("eboutic:payment_result", "failure")
|
return redirect("eboutic:payment_result", "failure")
|
||||||
|
|
||||||
eboutic = get_eboutic()
|
eboutic = get_eboutic()
|
||||||
@@ -288,6 +296,7 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
|
|||||||
except DatabaseError as e:
|
except DatabaseError as e:
|
||||||
sentry_sdk.capture_exception(e)
|
sentry_sdk.capture_exception(e)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
|
basket.delete()
|
||||||
messages.error(self.request, e.message)
|
messages.error(self.request, e.message)
|
||||||
return redirect("eboutic:payment_result", "failure")
|
return redirect("eboutic:payment_result", "failure")
|
||||||
|
|
||||||
@@ -350,7 +359,3 @@ class EtransactionAutoAnswer(View):
|
|||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
"Payment failed with error: " + request.GET["Error"], status=202
|
"Payment failed with error: " + request.GET["Error"], status=202
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class EurockPartnerFragment(IsSubscriberMixin, TemplateView):
|
|
||||||
template_name = "eboutic/eurock_fragment.jinja"
|
|
||||||
|
|||||||
+138
-30
@@ -1,6 +1,18 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
from itertools import groupby, islice
|
||||||
|
from operator import attrgetter
|
||||||
|
|
||||||
from django import forms
|
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 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.models import User
|
||||||
from core.views.forms import SelectDateTime
|
from core.views.forms import SelectDateTime
|
||||||
from core.views.widgets.ajax_select import (
|
from core.views.widgets.ajax_select import (
|
||||||
@@ -79,26 +91,19 @@ class VoteForm(forms.Form):
|
|||||||
class RoleForm(forms.ModelForm):
|
class RoleForm(forms.ModelForm):
|
||||||
"""Form for creating a role."""
|
"""Form for creating a role."""
|
||||||
|
|
||||||
|
required_css_class = "required"
|
||||||
|
error_css_class = "error"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Role
|
model = Role
|
||||||
fields = ["title", "election", "description", "max_choice"]
|
fields = ["club_role", "title", "description", "max_choice"]
|
||||||
widgets = {"election": AutoCompleteSelect}
|
field_classes = {"club_role": ClubRoleChoiceField}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, election: Election, **kwargs):
|
||||||
election_id = kwargs.pop("election_id", None)
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
if election_id:
|
self.instance.election = election
|
||||||
self.fields["election"].queryset = Election.objects.filter(
|
self.fields["club_role"].queryset = ClubRole.objects.filter(
|
||||||
id=election_id
|
is_board=True, club__in=election.clubs.all()
|
||||||
).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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -108,21 +113,21 @@ class ElectionListForm(forms.ModelForm):
|
|||||||
fields = ("title", "election")
|
fields = ("title", "election")
|
||||||
widgets = {"election": AutoCompleteSelect}
|
widgets = {"election": AutoCompleteSelect}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, election: Election, **kwargs):
|
||||||
election_id = kwargs.pop("election_id", None)
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
if election_id:
|
self.instance.election = election
|
||||||
self.fields["election"].queryset = Election.objects.filter(
|
|
||||||
id=election_id
|
|
||||||
).all()
|
|
||||||
|
|
||||||
|
|
||||||
class ElectionForm(forms.ModelForm):
|
class ElectionForm(forms.ModelForm):
|
||||||
|
required_css_class = "required"
|
||||||
|
error_css_class = "error"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Election
|
model = Election
|
||||||
fields = [
|
fields = [
|
||||||
"title",
|
"title",
|
||||||
"description",
|
"description",
|
||||||
|
"clubs",
|
||||||
"archived",
|
"archived",
|
||||||
"start_candidature",
|
"start_candidature",
|
||||||
"end_candidature",
|
"end_candidature",
|
||||||
@@ -134,21 +139,124 @@ class ElectionForm(forms.ModelForm):
|
|||||||
"candidature_groups",
|
"candidature_groups",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
|
"clubs": AutoCompleteSelectMultipleClub,
|
||||||
"edit_groups": AutoCompleteSelectMultipleGroup,
|
"edit_groups": AutoCompleteSelectMultipleGroup,
|
||||||
"view_groups": AutoCompleteSelectMultipleGroup,
|
"view_groups": AutoCompleteSelectMultipleGroup,
|
||||||
"vote_groups": AutoCompleteSelectMultipleGroup,
|
"vote_groups": AutoCompleteSelectMultipleGroup,
|
||||||
"candidature_groups": AutoCompleteSelectMultipleGroup,
|
"candidature_groups": AutoCompleteSelectMultipleGroup,
|
||||||
|
"start_date": SelectDateTime,
|
||||||
|
"end_date": SelectDateTime,
|
||||||
|
"start_candidature": SelectDateTime,
|
||||||
|
"end_candidature": SelectDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
start_date = forms.DateTimeField(
|
|
||||||
label=_("Start date"), widget=SelectDateTime, required=True
|
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")
|
||||||
)
|
)
|
||||||
end_date = forms.DateTimeField(
|
yield from (
|
||||||
label=_("End date"), widget=SelectDateTime, required=True
|
(
|
||||||
|
f"{role.title} \u2013 {role.club_role.club.name}",
|
||||||
|
[self.choice(cand) for cand in islice(candidates, role.max_choice)],
|
||||||
)
|
)
|
||||||
start_candidature = forms.DateTimeField(
|
for role, candidates in groupby(qs, key=attrgetter("role"))
|
||||||
label=_("Start candidature"), widget=SelectDateTime, required=True
|
|
||||||
)
|
)
|
||||||
end_candidature = forms.DateTimeField(
|
|
||||||
label=_("End candidature"), widget=SelectDateTime, required=True
|
def choice(self, obj: Candidature):
|
||||||
|
return (
|
||||||
|
ModelChoiceIteratorValue(self.field.prepare_value(obj), obj),
|
||||||
|
obj.user.get_full_name(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
+46
-5
@@ -5,6 +5,7 @@ from django.utils.functional import cached_property
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from ordered_model.models import OrderedModel
|
from ordered_model.models import OrderedModel
|
||||||
|
|
||||||
|
from club.models import Club, ClubRole, Membership
|
||||||
from core.models import Group, User
|
from core.models import Group, User
|
||||||
|
|
||||||
|
|
||||||
@@ -13,6 +14,12 @@ class Election(models.Model):
|
|||||||
|
|
||||||
title = models.CharField(_("title"), max_length=255)
|
title = models.CharField(_("title"), max_length=255)
|
||||||
description = models.TextField(_("description"), null=True, blank=True)
|
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)
|
start_candidature = models.DateTimeField(_("start candidature"), blank=False)
|
||||||
end_candidature = models.DateTimeField(_("end candidature"), blank=False)
|
end_candidature = models.DateTimeField(_("end candidature"), blank=False)
|
||||||
start_date = models.DateTimeField(_("start date"), blank=False)
|
start_date = models.DateTimeField(_("start date"), blank=False)
|
||||||
@@ -94,9 +101,18 @@ class Election(models.Model):
|
|||||||
results[role.title] = role.results(total_vote)
|
results[role.title] = role.results(total_vote)
|
||||||
return results
|
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):
|
class Role(OrderedModel):
|
||||||
"""This class allows to create a new role avaliable for a candidature."""
|
"""This class allows to create a new role available for a candidature."""
|
||||||
|
|
||||||
election = models.ForeignKey(
|
election = models.ForeignKey(
|
||||||
Election,
|
Election,
|
||||||
@@ -105,17 +121,42 @@ class Role(OrderedModel):
|
|||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
)
|
)
|
||||||
title = models.CharField(_("title"), max_length=255)
|
title = models.CharField(_("title"), max_length=255)
|
||||||
description = models.TextField(_("description"), null=True, blank=True)
|
description = models.TextField(_("description"), default="", blank=True)
|
||||||
max_choice = models.IntegerField(_("max choice"), default=1)
|
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",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.title} - {self.election.title}"
|
return f"{self.title} - {self.election.title}"
|
||||||
|
|
||||||
def results(self, total_vote: int) -> dict[str, dict[str, int | float]]:
|
def results(self, total_vote: int) -> dict[str, dict[str, int | float]]:
|
||||||
if total_vote == 0:
|
if total_vote == 0:
|
||||||
candidates = self.candidatures.values_list("user__username")
|
candidates = self.candidatures.values_list("user__username", flat=True)
|
||||||
return {
|
return {
|
||||||
key: {"vote": 0, "percent": 0} for key in ["blank_votes", *candidates]
|
key: {"vote": 0, "percent": 0} for key in ["blank vote", *candidates]
|
||||||
}
|
}
|
||||||
total_vote *= self.max_choice
|
total_vote *= self.max_choice
|
||||||
results = {"total vote": total_vote}
|
results = {"total vote": total_vote}
|
||||||
|
|||||||
@@ -29,13 +29,25 @@
|
|||||||
{% trans %}Polls closed {% endtrans %}
|
{% trans %}Polls closed {% endtrans %}
|
||||||
{%- else %}
|
{%- else %}
|
||||||
{% trans %}Polls will open {% endtrans %}
|
{% trans %}Polls will open {% endtrans %}
|
||||||
<time datetime="{{ election.start_date }}">{{ election.start_date|localtime|date(DATETIME_FORMAT)}}</time>
|
<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 %}
|
{% trans %}and will close {% endtrans %}
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
<time datetime="{{ election.end_date }}">{{ election.end_date|localtime|date(DATETIME_FORMAT)}}</time>
|
<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>
|
</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 %}
|
{%- if user_has_voted %}
|
||||||
<p class="election__elector-infos">
|
<p class="election__elector-infos">
|
||||||
{%- if election.is_vote_active %}
|
{%- if election.is_vote_active %}
|
||||||
@@ -47,17 +59,27 @@
|
|||||||
{%- endif %}
|
{%- endif %}
|
||||||
</section>
|
</section>
|
||||||
<section class="election_vote">
|
<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 %}
|
{% csrf_token %}
|
||||||
<table class="election_table">
|
<table class="election_table">
|
||||||
<thead class="lists">
|
<thead class="lists">
|
||||||
<tr>
|
<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 %}
|
{%- for election_list in election_lists %}
|
||||||
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">
|
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">
|
||||||
<span>{{ election_list.title }}</span>
|
<span>{{ election_list.title }}</span>
|
||||||
{% if user.can_edit(election_list) and election.is_vote_editable -%}
|
{% 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 %}
|
{% endif %}
|
||||||
</th>
|
</th>
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
@@ -103,22 +125,45 @@
|
|||||||
<button disabled><i class="fa fa-arrow-down"></i></button>
|
<button disabled><i class="fa fa-arrow-down"></i></button>
|
||||||
<button disabled><i class="fa fa-caret-down"></i></button>
|
<button disabled><i class="fa fa-caret-down"></i></button>
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=bottom');"><i class="fa fa-arrow-down"></i></button>
|
<button
|
||||||
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=down');"><i class="fa fa-caret-down"></i></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 -%}
|
{%- endif -%}
|
||||||
{%- if loop.first -%}
|
{%- if loop.first -%}
|
||||||
<button disabled><i class="fa fa-caret-up"></i></button>
|
<button disabled><i class="fa fa-caret-up"></i></button>
|
||||||
<button disabled><i class="fa fa-arrow-up"></i></button>
|
<button disabled><i class="fa fa-arrow-up"></i></button>
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=up');"><i class="fa fa-caret-up"></i></button>
|
<button
|
||||||
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=top');"><i class="fa fa-arrow-up"></i></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 -%}
|
{%- endif -%}
|
||||||
</div>
|
</div>
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="role_candidates">
|
<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 %}
|
{%- if role.max_choice == 1 and show_vote_buttons %}
|
||||||
<div class="radio-btn">
|
<div class="radio-btn">
|
||||||
{% set input_id = "blank_vote_" + role.id|string %}
|
{% set input_id = "blank_vote_" + role.id|string %}
|
||||||
@@ -131,26 +176,46 @@
|
|||||||
{%- if election.is_vote_finished %}
|
{%- if election.is_vote_finished %}
|
||||||
{%- set results = election_results[role.title]['blank vote'] %}
|
{%- set results = election_results[role.title]['blank vote'] %}
|
||||||
<div class="election__results">
|
<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>
|
</div>
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
</td>
|
</td>
|
||||||
{%- for election_list in election_lists %}
|
{%- 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">
|
<ul class="candidates">
|
||||||
{%- for candidature in election_list.candidatures.select_related("user", "user__profile_pict").filter(role=role) %}
|
{%- for candidature in election_list.candidatures.select_related("user", "user__profile_pict").filter(role=role) %}
|
||||||
<li class="candidate">
|
<li class="candidate">
|
||||||
{%- if show_vote_buttons %}
|
{%- if show_vote_buttons %}
|
||||||
{% set input_id = "candidature_" + candidature.id|string %}
|
{% set input_id = "candidature_" + candidature.id|string %}
|
||||||
<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 }}">
|
<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 }}"
|
||||||
|
>
|
||||||
<label for="{{ input_id }}">
|
<label for="{{ input_id }}">
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
<figure>
|
<figure>
|
||||||
{%- if user.can_view(candidature.user) %}
|
{%- if user.can_view(candidature.user) %}
|
||||||
{% if candidature.user.profile_pict %}
|
{% 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 %}
|
{% 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 %}
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
<figcaption class="candidate__details">
|
<figcaption class="candidate__details">
|
||||||
@@ -164,8 +229,12 @@
|
|||||||
{%- if user.can_edit(candidature) -%}
|
{%- if user.can_edit(candidature) -%}
|
||||||
{%- if election.is_vote_editable -%}
|
{%- if election.is_vote_editable -%}
|
||||||
<div class="edit_btns">
|
<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:update_candidate', candidature_id=candidature.id) }}">
|
||||||
<a href="{{url('election:delete_candidate', candidature_id=candidature.id)}}"><i class="fa-regular fa-trash-can delete-action"></i></a>
|
<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>
|
</div>
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{{ super() -}}
|
{{ super() -}}
|
||||||
<style type="text/css">
|
<style>
|
||||||
small {
|
small {
|
||||||
font-size: smaller;
|
font-size: smaller;
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,9 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h3>{% trans %}Current elections{% endtrans %}</h3>
|
<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 %}
|
{%- for election in object_list %}
|
||||||
<hr>
|
<hr>
|
||||||
<section>
|
<section>
|
||||||
@@ -32,7 +35,7 @@
|
|||||||
{% trans %} at {% endtrans %}<time>{{ election.start_candidature|localtime|time(DATETIME_FORMAT) }}</time>
|
{% trans %} at {% endtrans %}<time>{{ election.start_candidature|localtime|time(DATETIME_FORMAT) }}</time>
|
||||||
{% trans %}to{% endtrans %}
|
{% trans %}to{% endtrans %}
|
||||||
<time datetime="{{ election.end_candidature }}">{{ election.end_candidature|localtime|date(DATETIME_FORMAT) }}</time>
|
<time datetime="{{ election.end_candidature }}">{{ election.end_candidature|localtime|date(DATETIME_FORMAT) }}</time>
|
||||||
{% trans %} at {% endtrans %}<time>{{ election.end_candidature|time(DATETIME_FORMAT) }}</time>
|
{% trans %} at {% endtrans %}<time>{{ election.end_candidature|localtime|time(DATETIME_FORMAT) }}</time>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{% trans %}Polls open from{% endtrans %}
|
{% trans %}Polls open from{% endtrans %}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
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
|
||||||
@@ -2,13 +2,15 @@ from datetime import timedelta
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import localtime, now
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
from pytest_django.asserts import assertRedirects
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
|
from club.models import Club
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import Group, User
|
from core.models import Group, User
|
||||||
from election.models import Candidature, Election, ElectionList, Role, Vote
|
from election.models import Candidature, Election, ElectionList, Role, Vote
|
||||||
@@ -38,7 +40,6 @@ class TestElectionDetail(TestElection):
|
|||||||
reverse("election:detail", args=str(self.election.id))
|
reverse("election:detail", args=str(self.election.id))
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "La roue tourne" in str(response.content)
|
|
||||||
|
|
||||||
|
|
||||||
class TestElectionUpdateView(TestElection):
|
class TestElectionUpdateView(TestElection):
|
||||||
@@ -213,3 +214,42 @@ def test_election_results():
|
|||||||
"total vote": 100,
|
"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"
|
||||||
|
]
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
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,6 +1,7 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from election.views import (
|
from election.views import (
|
||||||
|
ApplyResultFragment,
|
||||||
CandidatureCreateView,
|
CandidatureCreateView,
|
||||||
CandidatureDeleteView,
|
CandidatureDeleteView,
|
||||||
CandidatureUpdateView,
|
CandidatureUpdateView,
|
||||||
@@ -56,4 +57,9 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
path("<int:election_id>/vote/", VoteFormView.as_view(), name="vote"),
|
path("<int:election_id>/vote/", VoteFormView.as_view(), name="vote"),
|
||||||
path("<int:election_id>/detail/", ElectionDetailView.as_view(), name="detail"),
|
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,7 +18,9 @@ from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateVi
|
|||||||
|
|
||||||
from core.auth.mixins import CanEditMixin, CanViewMixin
|
from core.auth.mixins import CanEditMixin, CanViewMixin
|
||||||
from election.forms import (
|
from election.forms import (
|
||||||
|
ApplyElectionResultForm,
|
||||||
CandidateForm,
|
CandidateForm,
|
||||||
|
ElectionCreateForm,
|
||||||
ElectionForm,
|
ElectionForm,
|
||||||
ElectionListForm,
|
ElectionListForm,
|
||||||
RoleForm,
|
RoleForm,
|
||||||
@@ -208,7 +210,7 @@ class CandidatureCreateView(LoginRequiredMixin, CreateView):
|
|||||||
|
|
||||||
class ElectionCreateView(PermissionRequiredMixin, CreateView):
|
class ElectionCreateView(PermissionRequiredMixin, CreateView):
|
||||||
model = Election
|
model = Election
|
||||||
form_class = ElectionForm
|
form_class = ElectionCreateForm
|
||||||
template_name = "core/create.jinja"
|
template_name = "core/create.jinja"
|
||||||
permission_required = "election.add_election"
|
permission_required = "election.add_election"
|
||||||
|
|
||||||
@@ -219,7 +221,7 @@ class ElectionCreateView(PermissionRequiredMixin, CreateView):
|
|||||||
class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
|
class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
|
||||||
model = Role
|
model = Role
|
||||||
form_class = RoleForm
|
form_class = RoleForm
|
||||||
template_name = "core/create.jinja"
|
template_name = "election/role_form.jinja"
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def election(self):
|
def election(self):
|
||||||
@@ -228,22 +230,17 @@ class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
|
|||||||
def test_func(self):
|
def test_func(self):
|
||||||
if not self.election.is_vote_editable:
|
if not self.election.is_vote_editable:
|
||||||
return False
|
return False
|
||||||
if self.request.user.has_perm("election.add_role"):
|
user = self.request.user
|
||||||
return True
|
return user.has_perm("election.add_role") or user.can_edit(self.election)
|
||||||
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):
|
def get_form_kwargs(self):
|
||||||
return super().get_form_kwargs() | {"election_id": self.election.id}
|
return super().get_form_kwargs() | {"election": self.election}
|
||||||
|
|
||||||
def get_success_url(self, **kwargs):
|
def get_success_url(self, **kwargs):
|
||||||
return reverse(
|
return reverse("election:detail", kwargs={"election_id": self.election.id})
|
||||||
"election:detail", kwargs={"election_id": self.object.election_id}
|
|
||||||
)
|
def get_context_data(self, **kwargs):
|
||||||
|
return super().get_context_data(**kwargs) | {"election": self.election}
|
||||||
|
|
||||||
|
|
||||||
class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
|
class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
|
||||||
@@ -267,16 +264,11 @@ class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView
|
|||||||
)
|
)
|
||||||
return not groups.isdisjoint(self.request.user.all_groups.keys())
|
return not groups.isdisjoint(self.request.user.all_groups.keys())
|
||||||
|
|
||||||
def get_initial(self):
|
|
||||||
return {"election": self.election}
|
|
||||||
|
|
||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
return super().get_form_kwargs() | {"election_id": self.election.id}
|
return super().get_form_kwargs() | {"election": self.election}
|
||||||
|
|
||||||
def get_success_url(self, **kwargs):
|
def get_success_url(self, **kwargs):
|
||||||
return reverse(
|
return reverse("election:detail", kwargs={"election_id": self.election.id})
|
||||||
"election:detail", kwargs={"election_id": self.object.election_id}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Update view
|
# Update view
|
||||||
@@ -288,18 +280,6 @@ class ElectionUpdateView(CanEditMixin, UpdateView):
|
|||||||
template_name = "core/edit.jinja"
|
template_name = "core/edit.jinja"
|
||||||
pk_url_kwarg = "election_id"
|
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):
|
def get_success_url(self, **kwargs):
|
||||||
return reverse_lazy("election:detail", kwargs={"election_id": self.object.id})
|
return reverse_lazy("election:detail", kwargs={"election_id": self.object.id})
|
||||||
|
|
||||||
@@ -324,48 +304,30 @@ class CandidatureUpdateView(LoginRequiredMixin, CanEditMixin, UpdateView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class RoleUpdateView(CanEditMixin, UpdateView):
|
class RoleUpdateView(UserPassesTestMixin, UpdateView):
|
||||||
model = Role
|
model = Role
|
||||||
form_class = RoleForm
|
form_class = RoleForm
|
||||||
template_name = "core/edit.jinja"
|
template_name = "election/role_form.jinja"
|
||||||
pk_url_kwarg = "role_id"
|
pk_url_kwarg = "role_id"
|
||||||
|
|
||||||
def dispatch(self, request, *arg, **kwargs):
|
@cached_property
|
||||||
self.object = self.get_object()
|
def election(self):
|
||||||
if not self.object.election.is_vote_editable:
|
return self.get_object().election
|
||||||
raise PermissionDenied
|
|
||||||
return super().dispatch(request, *arg, **kwargs)
|
|
||||||
|
|
||||||
def remove_fields(self):
|
def test_func(self):
|
||||||
self.form.fields.pop("election", None)
|
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 get(self, request, *args, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
self.object = self.get_object()
|
return super().get_context_data(**kwargs) | {"election": self.election}
|
||||||
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):
|
def get_form_kwargs(self):
|
||||||
kwargs = super().get_form_kwargs()
|
return super().get_form_kwargs() | {"election": self.election}
|
||||||
kwargs["election_id"] = self.object.election.id
|
|
||||||
return kwargs
|
|
||||||
|
|
||||||
def get_success_url(self, **kwargs):
|
def get_success_url(self, **kwargs):
|
||||||
return reverse_lazy(
|
return reverse("election:detail", kwargs={"election_id": self.election.id})
|
||||||
"election:detail", kwargs={"election_id": self.object.election.id}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Delete Views
|
# Delete Views
|
||||||
@@ -425,3 +387,41 @@ class ElectionListDeleteView(CanEditMixin, DeleteView):
|
|||||||
|
|
||||||
def get_success_url(self, **kwargs):
|
def get_success_url(self, **kwargs):
|
||||||
return reverse("election:detail", kwargs={"election_id": self.election.id})
|
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}
|
||||||
|
)
|
||||||
|
|||||||
@@ -25,13 +25,14 @@ import warnings
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Final, Optional
|
from typing import Final, Optional
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, ClubRole, Membership
|
||||||
from core.models import Group, Page, User
|
from core.models import Group, Page, SithFile, User
|
||||||
from core.utils import RED_PIXEL_PNG
|
from core.utils import RED_PIXEL_PNG
|
||||||
from sas.models import Album, PeoplePictureRelation, Picture
|
from sas.models import Album, PeoplePictureRelation, Picture
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
@@ -91,8 +92,13 @@ class Command(BaseCommand):
|
|||||||
self.NB_CLUBS = options["club_count"]
|
self.NB_CLUBS = options["club_count"]
|
||||||
|
|
||||||
root = User.objects.filter(username="root").first()
|
root = User.objects.filter(username="root").first()
|
||||||
|
sas = SithFile.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)
|
||||||
self.galaxy_album = Album.objects.create(
|
self.galaxy_album = Album.objects.create(
|
||||||
name="galaxy-register-file", owner=root, is_moderated=True
|
name="galaxy-register-file",
|
||||||
|
owner=root,
|
||||||
|
is_moderated=True,
|
||||||
|
is_in_sas=True,
|
||||||
|
parent=sas,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.make_clubs()
|
self.make_clubs()
|
||||||
@@ -288,10 +294,14 @@ class Command(BaseCommand):
|
|||||||
owner=u,
|
owner=u,
|
||||||
name=f"galaxy-picture {u} {i // self.NB_USERS}",
|
name=f"galaxy-picture {u} {i // self.NB_USERS}",
|
||||||
is_moderated=True,
|
is_moderated=True,
|
||||||
|
is_folder=False,
|
||||||
parent=self.galaxy_album,
|
parent=self.galaxy_album,
|
||||||
original=ContentFile(RED_PIXEL_PNG),
|
is_in_sas=True,
|
||||||
|
file=ContentFile(RED_PIXEL_PNG),
|
||||||
compressed=ContentFile(RED_PIXEL_PNG),
|
compressed=ContentFile(RED_PIXEL_PNG),
|
||||||
thumbnail=ContentFile(RED_PIXEL_PNG),
|
thumbnail=ContentFile(RED_PIXEL_PNG),
|
||||||
|
mime_type="image/png",
|
||||||
|
size=len(RED_PIXEL_PNG),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.picts[i].file.name = self.picts[i].name
|
self.picts[i].file.name = self.picts[i].name
|
||||||
|
|||||||
+197
-894
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