Compare commits

..

12 Commits

Author SHA1 Message Date
imperosol e25f173c19 apply review comments 2026-05-23 15:09:54 +02:00
imperosol 751e1328be tweak documentation 2026-05-23 15:08:08 +02:00
imperosol d52dd74a4b doc: third-party auth 2026-05-23 15:08:08 +02:00
imperosol e0d5cc44de translation: third-party authentication 2026-05-23 15:08:08 +02:00
imperosol f951b8c985 write tests 2026-05-23 15:08:08 +02:00
imperosol f87d54ebce third-party authentication views 2026-05-23 15:08:08 +02:00
imperosol ba6d83dca7 add CGU/EULA to populate command 2026-05-23 15:08:06 +02:00
imperosol dfdcf0bdab test populate_more command 2026-05-23 15:05:33 +02:00
imperosol 6ad1dc3c06 hmac_hexdigest util function 2026-05-23 15:05:32 +02:00
imperosol 4da50e34a1 add hmac_key to ApiClient 2026-05-23 15:05:32 +02:00
imperosol 524b7d0bd8 move ResultConverter to core app 2026-05-23 15:05:32 +02:00
imperosol 9042ebfb55 feat: api route to get api client infos 2026-05-23 15:05:32 +02:00
70 changed files with 1561 additions and 1112 deletions
+9
View File
@@ -17,6 +17,15 @@ class ApiClientAdmin(admin.ModelAdmin):
"owner__nick_name", "owner__nick_name",
) )
autocomplete_fields = ("owner", "groups", "client_permissions") autocomplete_fields = ("owner", "groups", "client_permissions")
readonly_fields = ("hmac_key",)
actions = ("reset_hmac_key",)
@admin.action(permissions=["change"], description=_("Reset HMAC key"))
def reset_hmac_key(self, _request: HttpRequest, queryset: QuerySet[ApiClient]):
objs = list(queryset)
for obj in objs:
obj.reset_hmac(commit=False)
ApiClient.objects.bulk_update(objs, fields=["hmac_key"])
@admin.register(ApiKey) @admin.register(ApiKey)
+16
View File
@@ -0,0 +1,16 @@
from ninja_extra import ControllerBase, api_controller, route
from api.auth import ApiKeyAuth
from api.schemas import ApiClientSchema
@api_controller("/client")
class ApiClientController(ControllerBase):
@route.get(
"/me",
auth=[ApiKeyAuth()],
response=ApiClientSchema,
url_name="api-client-infos",
)
def get_client_info(self):
return self.context.request.auth
+35
View File
@@ -0,0 +1,35 @@
from django import forms
from django.forms import HiddenInput
from django.utils.translation import gettext_lazy as _
class ThirdPartyAuthForm(forms.Form):
"""Form to complete to authenticate on the sith from a third-party app.
For the form to be valid, the user approve the EULA (french: CGU)
and give its username from the third-party app.
"""
cgu_accepted = forms.BooleanField(
required=True,
label=_("I have read and I accept the terms and conditions of use"),
error_messages={
"required": _("You must approve the terms and conditions of use.")
},
)
is_username_valid = forms.BooleanField(
required=True,
error_messages={"required": _("You must confirm that this is your username.")},
)
client_id = forms.IntegerField(widget=HiddenInput())
third_party_app = forms.CharField(widget=HiddenInput())
privacy_link = forms.URLField(widget=HiddenInput())
username = forms.CharField(widget=HiddenInput())
callback_url = forms.URLField(widget=HiddenInput())
signature = forms.CharField(widget=HiddenInput())
def __init__(self, *args, label_suffix: str = "", initial, **kwargs):
super().__init__(*args, label_suffix=label_suffix, initial=initial, **kwargs)
self.fields["is_username_valid"].label = _(
"I confirm that %(username)s is my username on %(app)s"
) % {"username": initial.get("username"), "app": initial.get("third_party_app")}
+19
View File
@@ -0,0 +1,19 @@
# Generated by Django 5.2.3 on 2025-10-26 10:15
from django.db import migrations, models
import api.models
class Migration(migrations.Migration):
dependencies = [("api", "0001_initial")]
operations = [
migrations.AddField(
model_name="apiclient",
name="hmac_key",
field=models.CharField(
default=api.models.get_hmac_key, max_length=128, verbose_name="HMAC Key"
),
),
]
+32 -21
View File
@@ -1,13 +1,20 @@
import secrets
from typing import Iterable from typing import Iterable
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.db import models from django.db import models
from django.db.models import Q
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext_lazy from django.utils.translation import pgettext_lazy
from core.models import Group, User from core.models import Group, User
def get_hmac_key():
return secrets.token_hex(64)
class ApiClient(models.Model): class ApiClient(models.Model):
name = models.CharField(_("name"), max_length=64) name = models.CharField(_("name"), max_length=64)
owner = models.ForeignKey( owner = models.ForeignKey(
@@ -26,11 +33,10 @@ class ApiClient(models.Model):
help_text=_("Specific permissions for this api client."), help_text=_("Specific permissions for this api client."),
related_name="clients", related_name="clients",
) )
hmac_key = models.CharField(_("HMAC Key"), max_length=128, default=get_hmac_key)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
_perm_cache: set[str] | None = None
class Meta: class Meta:
verbose_name = _("api client") verbose_name = _("api client")
verbose_name_plural = _("api clients") verbose_name_plural = _("api clients")
@@ -38,33 +44,38 @@ class ApiClient(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def has_perm(self, perm: str): @cached_property
"""Return True if the client has the specified permission.""" def all_permissions(self) -> set[str]:
permissions = (
if self._perm_cache is None: Permission.objects.filter(
group_permissions = ( Q(group__group__in=self.groups.all()) | Q(clients=self)
Permission.objects.filter(group__group__in=self.groups.all()) )
.values_list("content_type__app_label", "codename") .values_list("content_type__app_label", "codename")
.order_by() .order_by()
) )
client_permissions = self.client_permissions.values_list( return {f"{content_type}.{name}" for content_type, name in permissions}
"content_type__app_label", "codename"
).order_by()
self._perm_cache = {
f"{content_type}.{name}"
for content_type, name in (*group_permissions, *client_permissions)
}
return perm in self._perm_cache
def has_perms(self, perm_list): def has_perm(self, perm: str):
""" """Return True if the client has the specified permission."""
Return True if the client has each of the specified permissions. If return perm in self.all_permissions
object is passed, check if the client has all required perms for it.
""" def has_perms(self, perm_list: Iterable[str]) -> bool:
"""Return True if the client has each of the specified permissions."""
if not isinstance(perm_list, Iterable) or isinstance(perm_list, str): if not isinstance(perm_list, Iterable) or isinstance(perm_list, str):
raise ValueError("perm_list must be an iterable of permissions.") raise ValueError("perm_list must be an iterable of permissions.")
return all(self.has_perm(perm) for perm in perm_list) return all(self.has_perm(perm) for perm in perm_list)
def reset_hmac(self, *, commit: bool = True) -> str:
"""Reset and return the HMAC key for this client.
Args:
commit: if True (the default), persist the new hmac in db.
"""
self.hmac_key = get_hmac_key()
if commit:
self.save()
return self.hmac_key
class ApiKey(models.Model): class ApiKey(models.Model):
PREFIX_LENGTH = 5 PREFIX_LENGTH = 5
+7 -2
View File
@@ -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.utils import is_logged_in_counter from counter.models import Counter
class IsInGroup(BasePermission): class IsInGroup(BasePermission):
@@ -186,7 +186,12 @@ 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:
return is_logged_in_counter(request) if "/counter/" not in request.META.get("HTTP_REFERER", ""):
return False
token = request.session.get("counter_token")
if not token:
return False
return Counter.objects.filter(token=token).exists()
CanAccessLookup = IsLoggedInCounter | HasPerm("core.access_lookup") CanAccessLookup = IsLoggedInCounter | HasPerm("core.access_lookup")
+23
View File
@@ -0,0 +1,23 @@
from ninja import ModelSchema, Schema
from pydantic import Field, HttpUrl
from api.models import ApiClient
from core.schemas import SimpleUserSchema
class ApiClientSchema(ModelSchema):
class Meta:
model = ApiClient
fields = ["id", "name"]
owner: SimpleUserSchema
permissions: list[str] = Field(alias="all_permissions")
class ThirdPartyAuthParamsSchema(Schema):
client_id: int
third_party_app: str
privacy_link: HttpUrl
username: str
callback_url: HttpUrl
signature: str
+32
View File
@@ -0,0 +1,32 @@
{% extends "core/base.jinja" %}
{% block content %}
<form method="post">
{% csrf_token %}
<h3>{% trans %}Confidentiality{% endtrans %}</h3>
<p>
{% trans trimmed app=third_party_app %}
By ticking this box and clicking on the send button, you
acknowledge and agree to provide {{ app }} with your
first name, last name, nickname and any other information
that was the third party app was explicitly authorized to fetch
and that it must have acknowledged to you, in a complete and accurate manner.
{% endtrans %}
</p>
<p class="margin-bottom">
{% trans trimmed app=third_party_app, privacy_link=third_party_cgu, sith_cgu_link=sith_cgu %}
The privacy policies of <a href="{{ privacy_link }}">{{ app }}</a>
and of <a href="{{ sith_cgu_link }}">the Students' Association</a>
applies as soon as the form is submitted.
{% endtrans %}
</p>
<div class="row">{{ form.cgu_accepted }} {{ form.cgu_accepted.label_tag() }}</div>
<br>
<h3 class="margin-bottom">{% trans %}Confirmation of identity{% endtrans %}</h3>
<div class="row margin-bottom">
{{ form.is_username_valid }} {{ form.is_username_valid.label_tag() }}
</div>
{% for field in form.hidden_fields() %}{{ field }}{% endfor %}
<input type="submit" class="btn btn-blue">
</form>
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
import pytest
from django.contrib.admin import AdminSite
from django.http import HttpRequest
from model_bakery import baker
from pytest_django.asserts import assertNumQueries
from api.admin import ApiClientAdmin
from api.models import ApiClient
@pytest.mark.django_db
def test_reset_hmac_action():
client_admin = ApiClientAdmin(ApiClient, AdminSite())
api_clients = baker.make(ApiClient, _quantity=4, _bulk_create=True)
old_hmac_keys = [c.hmac_key for c in api_clients]
with assertNumQueries(2):
qs = ApiClient.objects.filter(id__in=[c.id for c in api_clients[2:4]])
client_admin.reset_hmac_key(HttpRequest(), qs)
for c in api_clients:
c.refresh_from_db()
assert api_clients[0].hmac_key == old_hmac_keys[0]
assert api_clients[1].hmac_key == old_hmac_keys[1]
assert api_clients[2].hmac_key != old_hmac_keys[2]
assert api_clients[3].hmac_key != old_hmac_keys[3]
+18
View File
@@ -0,0 +1,18 @@
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from api.hashers import generate_key
from api.models import ApiClient, ApiKey
from api.schemas import ApiClientSchema
@pytest.mark.django_db
def test_api_client_controller(client: Client):
key, hashed = generate_key()
api_client = baker.make(ApiClient)
baker.make(ApiKey, client=api_client, hashed_key=hashed)
res = client.get(reverse("api:api-client-infos"), headers={"X-APIKey": key})
assert res.status_code == 200
assert res.json() == ApiClientSchema.from_orm(api_client).model_dump()
+59
View File
@@ -0,0 +1,59 @@
import pytest
from django.contrib.auth.models import Permission
from django.test import TestCase
from model_bakery import baker
from api.models import ApiClient
from core.models import Group
class TestClientPermissions(TestCase):
@classmethod
def setUpTestData(cls):
cls.api_client = baker.make(ApiClient)
cls.perms = baker.make(Permission, _quantity=10, _bulk_create=True)
cls.api_client.groups.set(
[
baker.make(Group, permissions=cls.perms[0:3]),
baker.make(Group, permissions=cls.perms[3:5]),
]
)
cls.api_client.client_permissions.set(
[cls.perms[3], cls.perms[5], cls.perms[6], cls.perms[7]]
)
def test_all_permissions(self):
assert self.api_client.all_permissions == {
f"{p.content_type.app_label}.{p.codename}" for p in self.perms[0:8]
}
def test_has_perm(self):
assert self.api_client.has_perm(
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}"
)
assert not self.api_client.has_perm(
f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}"
)
def test_has_perms(self):
assert self.api_client.has_perms(
[
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}",
f"{self.perms[2].content_type.app_label}.{self.perms[2].codename}",
]
)
assert not self.api_client.has_perms(
[
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}",
f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}",
],
)
@pytest.mark.django_db
def test_reset_hmac_key():
client = baker.make(ApiClient)
original_key = client.hmac_key
client.reset_hmac(commit=True)
assert len(client.hmac_key) == len(original_key)
assert client.hmac_key != original_key
+134
View File
@@ -0,0 +1,134 @@
from unittest import mock
from unittest.mock import Mock
from django.contrib.messages import Message, get_messages
from django.db.models import Max
from django.test import TestCase
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from api.models import ApiClient, get_hmac_key
from core.baker_recipes import subscriber_user
from core.schemas import UserProfileSchema
from core.utils import hmac_hexdigest
def mocked_post(*, ok: bool):
class MockedResponse(Mock):
@property
def ok(self):
return ok
def mocked():
return MockedResponse()
return mocked
class TestThirdPartyAuth(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = subscriber_user.make()
cls.api_client = baker.make(ApiClient)
def setUp(self):
self.query = {
"client_id": self.api_client.id,
"third_party_app": "app",
"privacy_link": "https://foobar.fr/",
"username": "bibou",
"callback_url": "https://callback.fr/",
}
self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query)
self.callback_data = {
"user": UserProfileSchema.from_orm(self.user).model_dump()
}
self.callback_data["signature"] = hmac_hexdigest(
self.api_client.hmac_key, self.callback_data["user"]
)
def test_auth_ok(self):
self.client.force_login(self.user)
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert res.status_code == 200
with mock.patch("requests.post", new_callable=mocked_post(ok=True)) as mocked:
res = self.client.post(
reverse("api-link:third-party-auth"),
data={"cgu_accepted": True, "is_username_valid": True, **self.query},
)
mocked.assert_called_once_with(
self.query["callback_url"], json=self.callback_data
)
assertRedirects(
res,
reverse("api-link:third-party-auth-result", kwargs={"result": "success"}),
)
def test_callback_error(self):
"""Test that the user see the failure page if the callback request failed."""
self.client.force_login(self.user)
with mock.patch("requests.post", new_callable=mocked_post(ok=False)) as mocked:
res = self.client.post(
reverse("api-link:third-party-auth"),
data={"cgu_accepted": True, "is_username_valid": True, **self.query},
)
mocked.assert_called_once_with(
self.query["callback_url"], json=self.callback_data
)
assertRedirects(
res,
reverse("api-link:third-party-auth-result", kwargs={"result": "failure"}),
)
def test_wrong_signature(self):
"""Test that a 403 is raised if the signature of the query is wrong."""
self.client.force_login(subscriber_user.make())
new_key = get_hmac_key()
del self.query["signature"]
self.query["signature"] = hmac_hexdigest(new_key, self.query)
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert list(get_messages(res.wsgi_request)) == [
Message(
level=40,
message=(
"La signature est incorrecte. "
"Nous ne pouvons pas garantir l'authenticité de la requête."
),
)
]
def test_cgu_not_accepted(self):
self.client.force_login(self.user)
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert res.status_code == 200
res = self.client.post(reverse("api-link:third-party-auth"), data=self.query)
assert res.status_code == 200 # no redirect means invalid form
res = self.client.post(
reverse("api-link:third-party-auth"),
data={"cgu_accepted": False, "is_username_valid": False, **self.query},
)
assert res.status_code == 200
def test_invalid_client(self):
self.client.force_login(self.user)
self.query["client_id"] = ApiClient.objects.aggregate(res=Max("id"))["res"] + 1
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert list(get_messages(res.wsgi_request)) == [
Message(
level=40,
message="Les données fournies pour l'authentification sont incorrectes.",
)
]
def test_missing_parameter(self):
self.client.force_login(self.user)
del self.query["username"]
self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query)
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert list(get_messages(res.wsgi_request)) == [
Message(
level=40,
message="Les données fournies pour l'authentification sont incorrectes.",
)
]
+15
View File
@@ -1,6 +1,10 @@
from django.urls import path, register_converter
from ninja.security import SessionAuth from ninja.security import SessionAuth
from ninja_extra import NinjaExtraAPI from ninja_extra import NinjaExtraAPI
from api.views import ThirdPartyAuthResultView, ThirdPartyAuthView
from core.converters import ResultConverter
api = NinjaExtraAPI( api = NinjaExtraAPI(
title="PICON", title="PICON",
description="Portail Interactif de Communication avec les Outils Numériques", description="Portail Interactif de Communication avec les Outils Numériques",
@@ -9,3 +13,14 @@ api = NinjaExtraAPI(
auth=[SessionAuth()], auth=[SessionAuth()],
) )
api.auto_discover_controllers() api.auto_discover_controllers()
register_converter(ResultConverter, "res")
urlpatterns = [
path("auth/", ThirdPartyAuthView.as_view(), name="third-party-auth"),
path(
"auth/<res:result>/",
ThirdPartyAuthResultView.as_view(),
name="third-party-auth-result",
),
]
+146
View File
@@ -0,0 +1,146 @@
import hmac
from urllib.parse import unquote
import pydantic
import requests
import sentry_sdk
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import AccessMixin, LoginRequiredMixin
from django.shortcuts import render
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import FormView, TemplateView
from ninja_extra.shortcuts import get_object_or_none
from api.forms import ThirdPartyAuthForm
from api.models import ApiClient
from api.schemas import ThirdPartyAuthParamsSchema
from core.models import SithFile
from core.schemas import UserProfileSchema
from core.utils import hmac_hexdigest
class ThirdPartyAuthView(AccessMixin, FormView):
form_class = ThirdPartyAuthForm
template_name = "api/third_party/auth.jinja"
success_url = reverse_lazy("core:index")
def parse_params(self) -> ThirdPartyAuthParamsSchema | None:
"""Parse and check the authentication parameters.
If parsing fails, messages will be created using the django message
infrastructure.
Returns:
The parses parameters, or None if the parsing failed.
"""
# This is here rather than in ThirdPartyAuthForm because
# the given parameters and their signature are checked during both
# POST (for obvious reasons) and GET (in order not to make
# the user fill a form just to get an error he won't understand)
params = self.request.GET or self.request.POST
params = {key: unquote(val) for key, val in params.items()}
try:
params = ThirdPartyAuthParamsSchema(**params)
except pydantic.ValidationError:
messages.error(
self.request, _("The data provided for authentication is incorrect")
)
return None
client: ApiClient = get_object_or_none(ApiClient, id=params.client_id)
if not client:
messages.error(
self.request, _("The data provided for authentication is incorrect")
)
return None
if not hmac.compare_digest(
hmac_hexdigest(client.hmac_key, params.model_dump(exclude={"signature"})),
params.signature,
):
messages.error(
self.request,
_(
"The signature is incorrect. "
"We cannot ensure the provenance of the request."
),
)
return None
return params
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
self.params = self.parse_params()
if not self.params:
# if parameters parsing failed, shortcut the operation and display
# an empty page with just the error messages.
return render(request, "core/base.jinja")
return super().dispatch(request, *args, **kwargs)
def get(self, *args, **kwargs):
messages.warning(
self.request,
_(
"You are going to link your AE account and your %(app)s account. "
"Continue only if this page was opened from %(app)s."
)
% {"app": self.params.third_party_app},
)
return super().get(*args, **kwargs)
def get_initial(self):
return self.params.model_dump()
def form_valid(self, form):
client = ApiClient.objects.get(id=form.cleaned_data["client_id"])
user = UserProfileSchema.from_orm(self.request.user).model_dump()
data = {"user": user, "signature": hmac_hexdigest(client.hmac_key, user)}
try:
ok = requests.post(form.cleaned_data["callback_url"], json=data).ok
except requests.RequestException as e:
sentry_sdk.capture_exception(e)
ok = False
self.success_url = reverse(
"api-link:third-party-auth-result",
kwargs={"result": "success" if ok else "failure"},
)
return super().form_valid(form)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"third_party_app": self.params.third_party_app,
"third_party_cgu": self.params.privacy_link,
"sith_cgu": SithFile.objects.get(id=settings.SITH_CGU_FILE_ID),
}
class ThirdPartyAuthResultView(LoginRequiredMixin, TemplateView):
"""View that the user will see if its authentication on sith was successful.
This can show either a success or a failure message :
- success : everything is good, the user is successfully authenticated
and can close the page
- failure : the authentication has been processed on the sith side,
but the request to the callback url received an error.
In such a case, there is nothing much we can do but to advice
the user to contact the developers of the third-party app.
"""
template_name = "core/base.jinja"
success_message = _(
"You have been successfully authenticated. You can now close this page."
)
error_message = _(
"Your authentication on the AE website was successful, "
"but an error happened during the interaction "
"with the third-party application. "
"Please contact the managers of the latter."
)
def get(self, request, *args, **kwargs):
if self.kwargs.get("result") == "success":
messages.success(request, self.success_message)
else:
messages.error(request, self.error_message)
return super().get(request, *args, **kwargs)
-24
View File
@@ -392,30 +392,6 @@ 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.
+2 -1
View File
@@ -25,7 +25,8 @@ class Migration(migrations.Migration):
"url_base", "url_base",
models.URLField( models.URLField(
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. `https://www.instagram.com`)"
), ),
unique=True, unique=True,
verbose_name="url base", verbose_name="url base",
+4 -1
View File
@@ -793,7 +793,10 @@ class LinkType(models.Model):
url_base = models.URLField( url_base = models.URLField(
"url base", "url base",
unique=True, unique=True,
help_text=_("The base url that links with this type must respect"), help_text=_(
"The base url that links with this type must respect (e.g. `%(url)s`)"
)
% {"url": "https://www.instagram.com"},
) )
icon = models.CharField( icon = models.CharField(
_("icon"), _("icon"),
+1 -28
View File
@@ -4,7 +4,6 @@ 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
@@ -240,7 +239,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 loses access to the update page.""" then it's redirected to another page and loses access to the update page."""
self.payload["roles-0-is_presidency"] = False self.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)
@@ -252,29 +251,3 @@ 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)
+11 -8
View File
@@ -1,19 +1,16 @@
class FourDigitYearConverter: from django.urls.converters import IntConverter, StringConverter
regex = "[0-9]{4}"
def to_python(self, value):
return int(value) class FourDigitYearConverter(IntConverter):
regex = "[0-9]{4}"
def to_url(self, value): def to_url(self, value):
return str(value).zfill(4) return str(value).zfill(4)
class TwoDigitMonthConverter: class TwoDigitMonthConverter(IntConverter):
regex = "[0-9]{2}" regex = "[0-9]{2}"
def to_python(self, value):
return int(value)
def to_url(self, value): def to_url(self, value):
return str(value).zfill(2) return str(value).zfill(2)
@@ -28,3 +25,9 @@ class BooleanStringConverter:
def to_url(self, value): def to_url(self, value):
return str(value) return str(value)
class ResultConverter(StringConverter):
"""Converter whose regex match either "success" or "failure"."""
regex = "(success|failure)"
+15 -9
View File
@@ -28,6 +28,7 @@ from typing import ClassVar, NamedTuple
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.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.files.base import ContentFile
from django.core.management import call_command from django.core.management import call_command
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import connection from django.db import connection
@@ -43,7 +44,6 @@ 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,
@@ -119,15 +119,21 @@ class Command(BaseCommand):
) )
self.profiles_root = SithFile.objects.create(name="profiles", owner=root) self.profiles_root = SithFile.objects.create(name="profiles", owner=root)
home_root = SithFile.objects.create(name="users", owner=root) home_root = SithFile.objects.create(name="users", owner=root)
# Page needed for club creation
p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
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( sas = SithFile.objects.create(
name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID
) )
SithFile.objects.create(
name="CGU",
is_folder=False,
file=ContentFile(
content="Conditions générales d'utilisation", name="cgu.txt"
),
owner=root,
)
# Page needed for club creation
p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
p.save(force_lock=True)
clubs = self._create_clubs() clubs = self._create_clubs()
self.reset_index("club") self.reset_index("club")
@@ -365,10 +371,10 @@ 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
CounterSellers.objects.bulk_create( Counter.sellers.through.objects.bulk_create(
[ [
CounterSellers(counter_id=1, user=skia, is_regular=True), # MDE Counter.sellers.through(counter_id=1, user=skia), # MDE
CounterSellers(counter_id=2, user=krophil, is_regular=True), # Foyer Counter.sellers.through(counter_id=2, user=krophil), # Foyer
] ]
) )
+13 -6
View File
@@ -1,3 +1,4 @@
import math
import random import random
from datetime import date, timedelta from datetime import date, timedelta
from datetime import timezone as tz from datetime import timezone as tz
@@ -35,12 +36,17 @@ class Command(BaseCommand):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.faker = Faker("fr_FR") self.faker = Faker("fr_FR")
def add_arguments(self, parser):
parser.add_argument(
"-n", "--nb-users", help="Number of users to create", type=int, default=600
)
def handle(self, *args, **options): def handle(self, *args, **options):
if not settings.DEBUG: if not settings.DEBUG:
raise Exception("Never call this command in prod. Never.") raise Exception("Never call this command in prod. Never.")
self.stdout.write("Creating users...") self.stdout.write("Creating users...")
users = self.create_users() users = self.create_users(options["nb_users"])
self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of users self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of users
subscribers = random.sample(users, k=int(0.8 * len(users))) subscribers = random.sample(users, k=int(0.8 * len(users)))
self.stdout.write("Creating subscriptions...") self.stdout.write("Creating subscriptions...")
@@ -80,7 +86,7 @@ class Command(BaseCommand):
self.stdout.write("Creating products...") self.stdout.write("Creating products...")
self.create_products() self.create_products()
self.stdout.write("Creating sales and refills...") self.stdout.write("Creating sales and refills...")
sellers = random.sample(list(User.objects.all()), 100) sellers = random.sample(users, len(users) // 10)
self.create_sales(sellers) self.create_sales(sellers)
self.stdout.write("Creating permanences...") self.stdout.write("Creating permanences...")
self.create_permanences(sellers) self.create_permanences(sellers)
@@ -89,7 +95,7 @@ class Command(BaseCommand):
self.stdout.write("Done") self.stdout.write("Done")
def create_users(self) -> list[User]: def create_users(self, nb_users: int = 600) -> list[User]:
# Create a single password hash for all users to make it faster. # Create a single password hash for all users to make it faster.
# It's insecure as hell, but it's ok since it's only for dev purposes. # It's insecure as hell, but it's ok since it's only for dev purposes.
password = make_password("plop") password = make_password("plop")
@@ -108,7 +114,7 @@ class Command(BaseCommand):
address=self.faker.address(), address=self.faker.address(),
password=password, password=password,
) )
for _ in range(600) for _ in range(nb_users)
] ]
# there may a duplicate or two # there may a duplicate or two
# Not a problem, we will just have 599 users instead of 600 # Not a problem, we will just have 599 users instead of 600
@@ -415,8 +421,9 @@ class Command(BaseCommand):
Permanency.objects.bulk_create(perms) Permanency.objects.bulk_create(perms)
def create_forums(self): def create_forums(self):
forumers = random.sample(list(User.objects.all()), 100) users = list(User.objects.all())
most_actives = random.sample(forumers, 10) forumers = random.sample(users, math.ceil(len(users) / 10))
most_actives = random.sample(forumers, math.ceil(len(forumers) / 6))
categories = list(Forum.objects.filter(is_category=True)) categories = list(Forum.objects.filter(is_category=True))
new_forums = [ new_forums = [
Forum(name=self.faker.text(20), parent=random.choice(categories)) Forum(name=self.faker.text(20), parent=random.choice(categories))
-4
View File
@@ -46,10 +46,6 @@ 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) {
+1 -6
View File
@@ -29,12 +29,7 @@
align-items: center; align-items: center;
gap: 20px; gap: 20px;
&:disabled { &.clickable:hover {
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%);
} }
+1 -2
View File
@@ -23,7 +23,7 @@
border-radius: 5px; border-radius: 5px;
color: black; color: black;
&:not(.link-like):not(:disabled):hover { &: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;
+2 -2
View File
@@ -123,7 +123,7 @@ $background-color-hovered: #283747;
justify-content: center; justify-content: center;
} }
a.button { >.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;
&:not(.link-like):not(:disabled):hover { &:hover {
background-color: $background-color-hovered; background-color: $background-color-hovered;
} }
} }
+8 -3
View File
@@ -22,9 +22,14 @@
</form> </form>
<ul class="bars"> <ul class="bars">
{% cache 100 "counters_activity" %} {% cache 100 "counters_activity" %}
{# It would be cleaner to handle the timeout with django-celery-beat, {# The sith has no periodic tasks manager
but doing it here is simpler and less error-prone #} and using cron jobs would be way too overkill here.
{% do Counter.objects.filter(type="BAR").handle_timeout() %} Thus the barmen timeout is handled in the only place that
is loaded on every page : the header bar.
However, let's be clear : this has nothing to do here.
It's' merely a contrived workaround that should
replaced by a proper task manager as soon as possible. #}
{% set _ = Counter.objects.filter(type="BAR").handle_timeout() %}
{% endcache %} {% 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>
+1 -1
View File
@@ -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="$store.notifications = $store.notifications.filter((item, i) => i !== index)"> <span class="clickable" @click="messages = messages.filter((item, i) => i !== index)">
<i class="fa fa-close"></i> <i class="fa fa-close"></i>
</span> </span>
</div> </div>
+13
View File
@@ -0,0 +1,13 @@
import contextlib
import os
import pytest
from django.core.management import call_command
@pytest.mark.django_db
def test_populate_more(settings):
"""Just check that populate more doesn't crash"""
settings.DEBUG = True
with open(os.devnull, "w") as devnull, contextlib.redirect_stdout(devnull):
call_command("populate_more", "--nb-users", "50")
+40 -3
View File
@@ -12,21 +12,31 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from __future__ import annotations
import hmac
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 Final from typing import TYPE_CHECKING
from urllib.parse import urlencode
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.http import HttpRequest
from django.utils.timezone import localdate from django.utils.timezone import localdate
from PIL.Image import Image, Resampling from PIL.Image import Image, Resampling
if TYPE_CHECKING:
from _hashlib import HASH
from collections.abc import Buffer, Mapping, Sequence
from typing import Any, Callable, Final
from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest
RED_PIXEL_PNG: Final[bytes] = ( RED_PIXEL_PNG: Final[bytes] = (
b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52" b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52"
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53" b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53"
@@ -188,3 +198,30 @@ def get_client_ip(request: HttpRequest) -> str | None:
return ip return ip
return None return None
def hmac_hexdigest(
key: str | bytes,
data: Mapping[str, Any] | Sequence[tuple[str, Any]],
digest: str | Callable[[Buffer], HASH] = "sha512",
) -> str:
"""Return the hexdigest of the signature of the given data.
Args:
key: the HMAC key used for the signature
data: the data to sign
digest: a PEP247 hashing algorithm (by default, sha512)
Examples:
```python
data = {
"foo": 5,
"bar": "somevalue",
}
hmac_key = secrets.token_hex(64)
signature = hmac_hexdigest(hmac_key, data, "sha256")
```
"""
if isinstance(key, str):
key = key.encode()
return hmac.digest(key, urlencode(data).encode(), digest).hex()
+16 -36
View File
@@ -9,7 +9,6 @@ from django import forms
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.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
@@ -18,7 +17,6 @@ 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,
@@ -93,18 +91,30 @@ class StudentCardForm(forms.ModelForm):
class GetUserForm(forms.Form): class GetUserForm(forms.Form):
"""Find a user to show its click page.""" """The Form class aims at providing a valid user_id field in its cleaned data, in order to pass it to some view,
reverse function, or any other use.
The Form implements a nice JS widget allowing the user to type a customer account id, or search the database with
some nickname, first name, or last name (TODO)
"""
code = forms.CharField( code = forms.CharField(
label="Code", label="Code",
max_length=StudentCard.UID_SIZE, max_length=StudentCard.UID_SIZE,
required=False, required=False,
widget=NFCTextInput(attrs={"autofocus": True}), widget=NFCTextInput,
) )
id = forms.CharField( id = forms.CharField(
label=_("Select user"), widget=AutoCompleteSelectUser, required=False label=_("Select user"),
help_text=None,
widget=AutoCompleteSelectUser,
required=False,
) )
def as_p(self):
self.fields["code"].widget.attrs["autofocus"] = True
return super().as_p()
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()
customer = None customer = None
@@ -126,40 +136,11 @@ 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 user in self.request.barmen:
message = (
_("You are already logged in this counter.")
if user in self.counter.barmen_list
else _("You are already logged in another counter.")
)
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,
@@ -428,7 +409,6 @@ class ProductForm(forms.ModelForm):
"club", "club",
"limit_age", "limit_age",
"tray", "tray",
"clic_limit",
"archived", "archived",
] ]
help_texts = { help_texts = {
-64
View File
@@ -1,64 +0,0 @@
from typing import TYPE_CHECKING, Callable
from django.db.models import Exists, OuterRef
from django.http import HttpRequest, HttpResponse
from django.utils.functional import SimpleLazyObject, empty
from core.models import User
from counter.models import Permanency
if TYPE_CHECKING:
from django.contrib.sessions.backends.base import SessionBase
SESSION_BARMEN_KEY = "barmen_ids"
def get_cached_barmen(request: HttpRequest) -> set[User]:
if not hasattr(request, "_cached_barmen"):
session: SessionBase = request.session
barmen_ids = session.get(SESSION_BARMEN_KEY, [])
if barmen_ids:
request._cached_barmen = set(
User.objects.filter(
Exists(Permanency.objects.filter(user=OuterRef("pk"), end=None)),
id__in=barmen_ids,
)
)
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))
response = self.get_response(request)
if request.barmen._wrapped is not empty and {
b.id for b in request.barmen
} != set(request.session.get(SESSION_BARMEN_KEY, [])):
# update the session data only if `session.barmen`
# has been accessed and modified.
request.session[SESSION_BARMEN_KEY] = [b.id for b in request.barmen]
return response
@@ -1,25 +0,0 @@
# Generated by Django 5.2.13 on 2026-05-13 11:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("counter", "0039_price")]
operations = [
migrations.RemoveField(model_name="product", name="buying_groups"),
migrations.AddField(
model_name="product",
name="clic_limit",
field=models.PositiveSmallIntegerField(
blank=True,
help_text=(
"If a limit is set, the product won't be purchasable "
"anymore once the latter is reached."
),
null=True,
verbose_name="clic limit",
),
),
migrations.RemoveField(model_name="counter", name="token"),
]
+16 -49
View File
@@ -22,7 +22,7 @@ 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 Literal, Self from typing import TYPE_CHECKING, Literal, Self
from dict2xml import dict2xml from dict2xml import dict2xml
from django.conf import settings from django.conf import settings
@@ -34,7 +34,6 @@ 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
@@ -48,6 +47,9 @@ 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()
@@ -351,40 +353,6 @@ 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."""
@@ -402,7 +370,8 @@ 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"), help_text=_("Initial cost of purchasing the product") _("purchase price"),
help_text=_("Initial cost of purchasing the product"),
) )
icon = ResizedImageField( icon = ResizedImageField(
height=70, height=70,
@@ -419,21 +388,13 @@ 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
) )
clic_limit = models.PositiveSmallIntegerField( buying_groups = models.ManyToManyField(
_("clic limit"), Group, related_name="products", verbose_name=_("buying groups"), 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,
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")
@@ -619,6 +580,7 @@ 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()
@@ -771,8 +733,10 @@ 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(self, customer: Customer) -> PriceQuerySet: def get_prices_for(
return ( self, customer: Customer, *, order_by: Sequence[str] | None = None
) -> 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
) )
@@ -780,6 +744,9 @@ 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):
+14 -7
View File
@@ -20,34 +20,41 @@
# 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 Refilling, Selling from counter.models import Counter, Refilling, Selling
def write_log(instance: Selling | Refilling, operation_type): def write_log(instance, operation_type):
def get_user(): def get_user():
request = get_signal_request() request = get_signal_request()
if not request: if not request:
return None return None
if request.barmen: # Get a random barmen if deletion is from a counter
return random.choice(list(request.barmen)) session = getattr(request, "session", {})
session_token = session.get("counter_token", None)
if session_token:
counter = Counter.objects.filter(token=session_token).first()
if counter and len(counter.barmen_list) > 0:
return counter.get_random_barman()
# Get the current logged user if not from a counter # Get the current logged user if not from a counter
if request.user.is_authenticated: if request.user and not request.user.is_anonymous:
return request.user return request.user
# Return None by default
return None return None
OperationLog( OperationLog(
label=str(instance), operator=get_user(), operation_type=operation_type label=str(instance),
operator=get_user(),
operation_type=operation_type,
).save() ).save()
@@ -1,6 +1,6 @@
import type { RecursivePartial, TomSettings } from "tom-select/src/types"; import type { RecursivePartial, TomSettings } from "tom-select/dist/types/types";
import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base"; import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components"; import { registerComponent } from "#core:utils/web-components.ts";
const productParsingRegex = /^(\d+x)?(.*)/i; const productParsingRegex = /^(\d+x)?(.*)/i;
const codeParsingRegex = / \((\w+)\)$/; const codeParsingRegex = / \((\w+)\)$/;
@@ -63,6 +63,13 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
); );
}, },
); );
this.widget.hook("after", "onOptionSelect", () => {
/* Focus the next element if it's an input */
if (this.nextElementSibling.nodeName === "INPUT") {
(this.nextElementSibling as HTMLInputElement).focus();
}
});
} }
protected tomSelectSettings(): RecursivePartial<TomSettings> { 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 */
@@ -73,7 +80,9 @@ 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 },
], ],
}; };
@@ -25,9 +25,6 @@ document.addEventListener("alpine:init", () => {
} }
this.codeField = this.$refs.codeField; this.codeField = this.$refs.codeField;
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
@@ -157,7 +154,6 @@ 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();
}, },
})); }));
+1 -22
View File
@@ -42,28 +42,7 @@
min-width: 350px; min-width: 350px;
ul { ul {
list-style: none; list-style-type: 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;
} }
} }
+9 -18
View File
@@ -56,15 +56,10 @@
<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="" @submit.prevent="handleCode"> <form method="post" action=""
class="code_form" @submit.prevent="handleCode">
<counter-product-select <counter-product-select name="code" x-ref="codeField" autofocus required placeholder="{% trans %}Select a product...{% endtrans %}">
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>
@@ -73,11 +68,13 @@
{%- 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 }} ({{ price.product.code }})</option> <option value="{{ price.id }}">{{ price.full_label }}</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() %}
@@ -105,9 +102,7 @@
{{ form.management_form }} {{ form.management_form }}
</div> </div>
<ul> <ul>
<li x-show="getBasketSize() === 0"> <li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
<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">
@@ -115,15 +110,12 @@
</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 class="product-name" x-text="item.product.name"></span> <span 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>
@@ -131,7 +123,6 @@
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"
+15 -33
View File
@@ -32,11 +32,12 @@
</ul> </ul>
<p><strong>{% trans %}Total: {% endtrans %}{{ last_total }} €</strong></p> <p><strong>{% trans %}Total: {% endtrans %}{{ last_total }} €</strong></p>
{% endif %} {% endif %}
{% if can_click %} {% if barmen %}
<p>{% trans %}Enter client code:{% endtrans %}</p> <p>{% trans %}Enter client code:{% endtrans %}</p>
<form method="post" action="" id="select-user-form"> <form method="post" action="">
{% csrf_token %} {% csrf_token %}
{{ form }} <input type="hidden" name="counter_token" value="{{ counter.token }}" />
{{ 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 %}
@@ -44,36 +45,17 @@
{% 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>
<h4>{% trans %}On this device{% endtrans %}</h4> <h3>{% trans %}Barman: {% endtrans %}</h3>
{% 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 %}
{% endif %} <form method="post" action="{{ url('counter:login', counter_id=counter.id) }}">
{{ login_fragment }} {% csrf_token %}
{{ login_form.as_p() }}
<p><input type="submit" value="{% trans %}login{% endtrans %}" /></p>
</form>
</div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@@ -81,10 +63,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){
@@ -1,5 +0,0 @@
<form hx-post="{{ action }}" hx-swap="outerHTML">
{% csrf_token %}
{{ form }}
<input type="submit" value="{% trans %}Confirm{% endtrans %}"/>
</form>
@@ -118,7 +118,6 @@
</div> </div>
</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>
+51 -115
View File
@@ -17,11 +17,9 @@ 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
@@ -39,7 +37,6 @@ 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,
@@ -69,14 +66,10 @@ 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")
CounterSellers.objects.bulk_create( cls.other_counter.sellers.add(cls.barmen)
[
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")
@@ -121,10 +114,7 @@ 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( reverse("counter:refilling_create", kwargs={"customer_id": user.pk}),
"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}
@@ -148,10 +138,7 @@ class TestRefilling(TestFullClickBase):
return self.client.post( return self.client.post(
reverse( reverse(
"counter:refilling_create", "counter:refilling_create",
kwargs={ kwargs={"customer_id": self.customer.pk},
"customer_id": self.customer.pk,
"counter_id": self.counter.pk,
},
), ),
{"amount": "10", "payment_method": "CASH"}, {"amount": "10", "payment_method": "CASH"},
) )
@@ -455,19 +442,9 @@ 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
) )
@@ -619,7 +596,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 = list(counter.get_prices_for(customer)) customer_prices = counter.get_prices_for(customer)
assert unarchived_prices == customer_prices assert unarchived_prices == customer_prices
@@ -741,97 +718,59 @@ class TestCounterStats(TestCase):
class TestBarmanConnection(TestCase): class TestBarmanConnection(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.barman = subscriber_user.make() cls.krophil = User.objects.get(username="krophil")
cls.barman.set_password("plop") cls.skia = User.objects.get(username="skia")
cls.barman.save() cls.skia.customer.account = 800
cls.counter = baker.make(Counter, type="BAR", sellers=[cls.barman]) cls.krophil.customer.save()
cls.login_url = reverse("counter:login", kwargs={"counter_id": cls.counter.id}) cls.skia.customer.save()
cls.detail_url = reverse(
"counter:details", kwargs={"counter_id": cls.counter.id} cls.counter = Counter.objects.get(id=2)
)
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
assert self.barman in response.wsgi_request.barmen
response = self.client.get(
self.detail_url, {"username": self.barman.username, "password": "plop"}
)
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(
self.login_url, {"username": self.barman.username, "password": "plop"} reverse("counter:login", args=[self.counter.id]),
{"username": "krophil", "password": "plop"},
) )
self.assert_counter_login_fails(self.barman) response = self.client.get(reverse("counter:details", args=[self.counter.id]))
def test_barman_already_logged_elsewhere(self): assert "<p>Entrez un code client : </p>" in str(response.content)
"""Test when the barman is already logged in another counter."""
other_counter = baker.make(Counter, type="BAR") def test_counters_list_barmen(self):
CounterSellers.objects.create(counter=other_counter, user=self.barman)
self.client.post( self.client.post(
reverse("counter:login", kwargs={"counter_id": other_counter.id}), reverse("counter:login", args=[self.counter.id]),
{"username": self.barman.username, "password": "plop"}, {"username": "krophil", "password": "plop"},
) )
self.assert_counter_login_fails(self.barman) response = self.client.get(reverse("counter:activity", args=[self.counter.id]))
def test_login_on_non_bar_counter(self): assert '<li><a href="/user/10/">Kro Phil&#39;</a></li>' in str(response.content)
counter = baker.make(Counter, type="OFFICE")
CounterSellers.objects.create(counter=counter, user=self.barman) def test_barman_denied(self):
url = reverse("counter:login", kwargs={"counter_id": counter.id}) self.client.post(
response = self.client.get(url) reverse("counter:login", args=[self.counter.id]),
assert response.status_code == 403 {"username": "skia", "password": "plop"},
response = self.client.post(
url, {"username": self.barman.username, "password": "plop"}
) )
assert response.status_code == 403 response_get = self.client.get(
reverse("counter:details", args=[self.counter.id])
)
assert "<p>Merci de vous identifier</p>" in str(response_get.content)
def test_counters_list_no_barmen(self):
self.client.post(
reverse("counter:login", args=[self.counter.id]),
{"username": "krophil", "password": "plop"},
)
response = self.client.get(reverse("counter:activity", args=[self.counter.id]))
assert '<li><a href="/user/1/">S&#39; Kia</a></li>' not in str(response.content)
@pytest.mark.django_db @pytest.mark.django_db
def test_barman_timeout(client: Client): def test_barman_timeout():
"""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)
CounterSellers.objects.create(counter=bar, user=user) bar.sellers.add(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)
@@ -847,8 +786,6 @@ def test_barman_timeout(client: Client):
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):
@@ -898,14 +835,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"""
CounterSellers.objects.create(counter=self.counter, user=self.user) self.counter.sellers.add(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."""
CounterSellers.objects.create(counter=self.counter, user=self.user) self.counter.sellers.add(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
) )
@@ -931,15 +868,14 @@ class TestCounterLogout:
) )
assertRedirects( assertRedirects(
res, res,
reverse("counter:details", kwargs={"counter_id": permanence.counter_id}), reverse(
"counter:details", kwargs={"counter_id": permanence.counter_id}
),
) )
permanence.refresh_from_db() permanence.refresh_from_db()
assert permanence.end == permanence.activity assert permanence.end == now()
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,
@@ -960,6 +896,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 == permanence.activity assert permanence.end == now()
old_permanence.refresh_from_db() old_permanence.refresh_from_db()
assert old_permanence.end == old_end assert old_permanence.end == old_end
+2 -61
View File
@@ -1,4 +1,3 @@
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
@@ -9,7 +8,6 @@ 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
@@ -18,10 +16,9 @@ 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, sale_recipe from counter.baker_recipes import product_recipe
from counter.forms import ProductForm, ProductPriceFormSet from counter.forms import ProductForm, ProductPriceFormSet
from counter.models import Price, Product, ProductType, Selling from counter.models import Price, Product, ProductType
from eboutic.models import Basket, BasketItem
@pytest.mark.django_db @pytest.mark.django_db
@@ -225,59 +222,3 @@ def test_price_for_user():
assert list(qs.for_user(users[0])) == [prices[0], prices[1], prices[4]] assert list(qs.for_user(users[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:]
+3 -4
View File
@@ -41,6 +41,7 @@ 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,
@@ -56,9 +57,7 @@ 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
@@ -67,7 +66,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(
"<int:counter_id>/refill/<int:customer_id>/", "refill/<int:customer_id>/",
RefillingCreateView.as_view(), RefillingCreateView.as_view(),
name="refilling_create", name="refilling_create",
), ),
@@ -83,7 +82,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/", CounterLoginFragment.as_view(), name="login"), path("<int:counter_id>/login/", counter_login, 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(
+16 -3
View File
@@ -3,6 +3,8 @@ 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.
@@ -18,13 +20,24 @@ 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)
- There are barmen logged in the current session - The current session has a counter token associated with it.
- A counter with this token exists.
- The counter is open
""" """
referer_ok = ( 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"
) )
if not referer_ok and request.resolver_match.app_name != "counter": has_token = (
(referer_ok or request.resolver_match.app_name == "counter")
and "counter_token" in request.session
and request.session["counter_token"]
)
if not has_token:
return False return False
return bool(request.barmen) return (
Counter.objects.annotate_is_open()
.filter(token=request.session["counter_token"], is_open=True)
.exists()
)
+53
View File
@@ -0,0 +1,53 @@
#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from django.http import HttpRequest, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.utils import timezone
from django.utils.timezone import now
from django.views.decorators.http import require_POST
from core.views.forms import LoginForm
from counter.models import Counter, Permanency
@require_POST
def counter_login(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
"""Log a user in a counter.
A successful login will result in the beginning of a counter duty
for the user.
"""
counter = get_object_or_404(Counter, pk=counter_id)
form = LoginForm(request, data=request.POST)
if not form.is_valid():
return redirect(counter.get_absolute_url() + "?credentials")
user = form.get_user()
if not counter.sellers.contains(user) or user in counter.barmen_list:
return redirect(counter.get_absolute_url() + "?sellers")
if len(counter.barmen_list) == 0:
counter.gen_token()
request.session["counter_token"] = counter.token
counter.permanencies.create(user=user, start=timezone.now())
return redirect(counter)
@require_POST
def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
"""End the permanency of a user in this counter."""
Permanency.objects.filter(
counter=counter_id, user=request.POST["user_id"], end=None
).update(end=now())
return redirect("counter:details", counter_id=counter_id)
+20 -20
View File
@@ -12,10 +12,8 @@
# 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
@@ -23,7 +21,6 @@ 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.utils.translation import gettext as _
from django.views.generic import FormView from django.views.generic import 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
@@ -32,7 +29,13 @@ from core.auth.mixins import CanViewMixin
from core.models import User from core.models import User
from core.views.mixins import FragmentMixin, UseFragmentsMixin from core.views.mixins import FragmentMixin, UseFragmentsMixin
from counter.forms import BasketForm, RefillForm from counter.forms import BasketForm, RefillForm
from counter.models import Counter, Customer, ProductFormula, ReturnableProduct, Selling from counter.models import (
Counter,
Customer,
ProductFormula,
ReturnableProduct,
Selling,
)
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
from counter.views.student_card import StudentCardFormFragment from counter.views.student_card import StudentCardFormFragment
@@ -43,7 +46,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 random.choice(list(request.barmen)) return counter.get_random_barman()
class CounterClick( class CounterClick(
@@ -75,7 +78,7 @@ class CounterClick(
return kwargs 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:
@@ -93,13 +96,14 @@ class CounterClick(
# or a seller of this counter. # or a seller of this counter.
raise PermissionDenied raise PermissionDenied
if obj.type == "BAR" and not ( if obj.type == "BAR" and (
request.barmen and request.barmen.issubset(set(obj.barmen_list)) not obj.is_open
or "counter_token" not in request.session
or request.session["counter_token"] != obj.token
): ):
messages.error(request, _("You cannot click users on this counter"))
return redirect(obj) # Redirect to counter return redirect(obj) # Redirect to counter
self.prices = list(obj.get_prices_for(self.customer)) self.prices = obj.get_prices_for(self.customer)
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@@ -195,7 +199,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, counter=self.object self.request, customer=self.customer
) )
return res return res
@@ -233,13 +237,11 @@ 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(Counter, id=self.kwargs["counter_id"]) self.counter: Counter = get_object_or_404(
Counter, token=request.session["counter_token"]
)
if not ( if not self.counter.can_refill():
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) self.operator = get_operator(request, self.counter, self.customer)
@@ -248,7 +250,6 @@ class RefillingCreateView(FragmentMixin, FormView):
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")
return super().render_fragment(request, **kwargs) return super().render_fragment(request, **kwargs)
def form_valid(self, form): def form_valid(self, form):
@@ -263,8 +264,7 @@ class RefillingCreateView(FragmentMixin, FormView):
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", "counter:refilling_create", kwargs={"customer_id": self.customer.pk}
kwargs={"customer_id": self.customer.pk, "counter_id": self.counter.pk},
) )
return kwargs return kwargs
+52 -97
View File
@@ -15,120 +15,78 @@
from datetime import timedelta from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied from django.http import HttpResponseRedirect
from django.db.models import F from django.urls import reverse, reverse_lazy
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.safestring import SafeString from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_POST
from django.views.generic import DetailView from django.views.generic import DetailView
from django.views.generic.detail import SingleObjectMixin from django.views.generic.edit import FormMixin, ProcessFormView
from django.views.generic.edit import FormView
from core.auth.mixins import CanViewMixin from core.auth.mixins import CanViewMixin
from core.views import FragmentMixin, UseFragmentsMixin from core.views.forms import LoginForm
from counter.forms import CounterLoginForm, GetUserForm from counter.forms import GetUserForm
from counter.models import Counter, Permanency from counter.models import Counter
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()
self.object.permanencies.create(user=user, start=timezone.now())
self.request.barmen.add(user)
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, UseFragmentsMixin, CanViewMixin, SingleObjectMixin, FormView CounterTabsMixin, CanViewMixin, DetailView, ProcessFormView, FormMixin
): ):
"""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 = GetUserForm form_class = (
GetUserForm # Form to enter a client code and get the corresponding user id
)
current_tab = "counter" current_tab = "counter"
def dispatch(self, request, *args, **kwargs): def get_queryset(self):
self.object: Counter = self.get_object() return super().get_queryset().exclude(type="EBOUTIC")
if self.object.type == "BAR":
self.object.update_activity()
return super().dispatch(request, *args, **kwargs)
def get_fragment_context_data(self) -> dict[str, SafeString]: def post(self, request, *args, **kwargs):
login_fragment = ( self.object = self.get_object()
CounterLoginFragment.as_fragment()(self.request, counter=self.object) if self.object.type == "BAR" and not (
if self.object.type == "BAR" "counter_token" in self.request.session
else "" and self.request.session["counter_token"] == self.object.token
): # Check the token to avoid the bar to be stolen
return HttpResponseRedirect(
reverse_lazy(
"counter:details",
args=self.args,
kwargs={"counter_id": self.object.id},
) )
return super().get_fragment_context_data() | {"login_fragment": login_fragment} + "?bad_location"
)
return super().post(request, *args, **kwargs)
def get_context_data(self, **kwargs): 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
kwargs["barmen_here"] = list( elif self.request.user.is_authenticated:
self.request.barmen.intersection(self.object.barmen_list) kwargs["barmen"] = [self.request.user]
)
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")
@@ -138,17 +96,14 @@ class CounterMain(
) )
return kwargs return kwargs
def form_valid(self, form: GetUserForm): def form_valid(self, form):
"""We handle here the redirection, passing the user id of the asked customer.""" """We handle here the redirection, passing the user id of the asked customer."""
self.success_url = reverse( self.kwargs["user_id"] = form.cleaned_data["user_id"]
"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."""
+1
View File
@@ -0,0 +1 @@
::: api.schemas
+1
View File
@@ -0,0 +1 @@
::: api.views
+372
View File
@@ -0,0 +1,372 @@
Le site AE offre des mécanismes permettant aux applications tierces
de récupérer les informations sur un utilisateur du site AE.
De cette manière, il devient possible de synchroniser les informations
qu possède l'application tierce sur l'utilisateur, directement depuis
le site AE.
## Fonctionnement général
Pour authentifier vos utilisateurs, vous aurez besoin d'un serveur web
et d'un client d'API (celui auquel est liée votre
[clef d'API](./connect.md#obtenir-une-clef-dapi)).
Deux informations vous sont nécessaires, en plus de votre clef d'API :
- l'id du client : vous pouvez l'obtenir soit en le demandant à l'équipe info,
soit en appelant la route `GET /api/client/me` avec votre clef d'API
renseignée dans le header [X-APIKey](./connect.md#x-apikey)
- la clef HMAC du client : vous devez la demander à l'équipe info.
Grâce à ces informations, vous allez pouvoir fournir le contexte nécessaire
au site AE pour qu'il authentifie vos utilisateurs.
En effet, la démarche d'authentification s'effectue presque entièrement
sur le site : le travail de l'application tierce consiste uniquement
à fournir à l'utilisateur une url avec les bons paramètres, puis
à recevoir la réponse du serveur si tout s'est bien passé.
Comme un dessin vaut parfois mieux que mille mots,
voici les diagrammes décrivant le processus.
L'un montre l'entièreté de la démarche ;
l'autre dans un souci de simplicité, ne montre que ce qui est visible
directement par l'application tierce.
=== "Intégralité du processus"
```mermaid
sequenceDiagram
actor User
participant App
User->>+App: Authentifie-moi, stp
App-->>-User: url de connexion<br/>avec signature
User->>+Sith: GET url
opt Utilisateur non-connecté
Sith->>+User: Formulaire de connexion
User-->>-Sith: Connexion
end
Sith->>Sith: vérification de la signature
Sith->>+User: Formulaire<br/>des conditions<br/>d'utilisation
User-->>-Sith: Validation
Sith->>+App: URL de retour<br/>avec données utilisateur
App->>App: Traitement des <br/>données utilisateur
App-->>-Sith: 204 OK, No content
Sith-->>-User: Message de succès
App--)User: Message de succès
```
=== "Point de vue de l'application tierce"
```mermaid
sequenceDiagram
actor User
participant App
User->>+App: Authentifie-moi, stp
App-->>-User: url de connexion<br/>avec signature
opt
Sith->>+App: URL de retour<br/>avec données utilisateur
App->>App: Traitement des <br/>données utilisateur
App-->>-Sith: 204 OK, No content
App--)User: Message de succès
end
```
## Données attendues
### URL de connexion
L'URL de connexion que vous allez fournir à l'utilisateur doit
être `https://ae.utbm.fr/api-link/auth/`
et doit contenir les données décrites dans
[`ThirdPartyAuthParamsSchema`][api.schemas.ThirdPartyAuthParamsSchema] :
- `client_id` (integer) : l'id de votre client, que vous pouvez obtenir
de la manière décrite plus haut
- `third_party_app`(string) : le nom de la plateforme pour laquelle
l'authentification va être réalisée (si votre application est un bot
discord, mettez la valeur "discord")
- `privacy_link`(URL) : l'URL vers la page de politique de confidentialité
qui s'appliquera dans le cadre de l'application
(s'il s'agit d'un bot discord, donnez le lien vers celles de Discord)
- `username`(string) : le pseudonyme que l'utilisateur possède sur
votre application
- `callback_url`(URL) : l'URL que le site AE appellera si l'authentification
réussit
- `signature`(string) : la signature des données de la requête.
Il s'agit d'une signature par clef HMAC dont le fonctionnement
est détaillé plus bas.
Ces données doivent être url-encodées et passées dans les paramètres GET.
!!!warning "URL de retour"
Les URLs fournies doivent être des URLs HTTP valides.
En outre, elles doivent obligatoirement inclure la barre oblique finale.
=== "URL correcte ✔️"
`https://exemple.ae.utbm.fr/foo/`
=== "URL incorrecte ❌"
`https://exemple.ae.utbm.fr/foo`
!!!tip
Inclure l'id de votre utilisateur dans l'URL de retour
peut être un bon moyen de l'identifier lors du callback.
Par exemple : `GET /callback/{int:user_id}/`.
???Example
Supposons que votre client d'API soit utilisé dans le cadre d'un bot Discord,
avec les données suivantes :
- l'id du client est 15
- sa clef HMAC est "beb99dd53"
(c'est pour l'exemple, une vraie clef sera beaucoup plus longue)
- le pseudonyme discord de votre utilisateur est Brian
- son id sur discord est 123456789
- votre route de callback est `GET /callback/{int:user_id}/`,
accessible au domaine `https://bot.ae.utbm.fr`
Alors les paramètres de votre URL seront :
| Paramètre | valeur |
|-----------------|-----------------------------------------------------------------------|
| client_id | 15 |
| third_party_app | discord |
| privacy_link | `https://discord.com/privacy` |
| username | Brian |
| callback_url | `https://bot.ae.utbm.fr/callback/123456789/` |
| signature | 1a383c51060be64f07772aa42e07<br/>18ae096b8f21f2cdb4061c0834a416d12101 |
Et l'url fournie à l'utilisateur sera :
`https://ae.utbm.fr/api-link/auth/?client_id=15&third_party_app=discord
&privacy_link=https%3A%2F%2Fdiscord.com%2Fprivacy&username=Brian
&callback_url=https%3A%2F%2Fbot.ae.utbm.fr%2Fcallback%2F123456789%2F
&signature=1a383c51060be64f07772aa42e0718ae096b8f21f2cdb4061c0834a416d12101`
### Données de retour
Si l'authentification réussit, le site AE enverra une requête HTTP POST
à l'URL de retour fournie dans l'URL de connexion.
Le corps de la requête de callback et au format JSON
et contient deux paires clef-valeur :
- `user` : les données utilisateur, telles que décrites
par [UserProfileSchema][core.schemas.UserProfileSchema]
- `signature` : la signature des données utilisateur
???Example
En reprenant les mêmes paramètres que dans l'exemple précédent,
le site AE pourra renvoyer à l'application la requête suivante :
```http
POST https://bot.ae.utbm.fr/callback/123456789/
content-type: application/json
body: {
"user": {
"id": 144131,
"nick_name": "inzekitchen",
"first_name": "Brian",
...
},
"signature": "f16955bab6b805f6e1abbb98a86dfee53fed0bf812aa6513ca46cfd461b70020"
}
```
L'application doit répondre avec un des codes HTTP suivants :
| Code | Raison |
|------|--------------------------------------------------------------------------------|
| 204 | Tout s'est bien passé |
| 403 | Les données de retour ne sont <br>pas signées ou sont mal signées |
| 404 | L'URL de retour ne permet pas <br>d'identifier un utilisateur de l'application |
!!!note "Code d'erreur par défaut"
Si l'appel de la route fait face à plusieurs problèmes en même temps
(par exemple, l'URL ne permet pas de retrouver votre utilisateur,
et en plus les données sont mal signées),
le 403 prime et doit être retourné par défaut.
## Signature des données
Les données de l'URL de connexion doivent être signées,
et la signature de l'URL de retour doit être vérifiée.
Dans le deux cas, la signature est le digest HMAC-SHA512
des données url-encodées, en utilisant la clef HMAC du client d'API.
L'ordre dans lequel ces données sont placées dans l'encodage URL
doit être strictement le même que celui donné plus haut.
???Example "Signature de l'URL de connexion"
En reprenant le même exemple que les fois précédentes,
l'url-encodage des données est :
`client_id=15&third_party_app=discord
&privacy_link=https%3A%2F%2Fdiscord.com%2Fprivacy%2F&username=Brian
&callback_url=https%3A%2F%2Fbot.ae.utbm.fr%2Fcallback%2F123456789%2F`
Notez que la signature n'est pas (encore) dedans.
Cette dernière peut-être obtenue avec le code suivant :
=== ":simple-python: Python"
Dépendances :
- `environs` (>=14.1)
```python
import hmac
from urllib.parse import urlencode
from environs import Env
env = Env()
env.read_env()
key = env.str("HMAC_KEY").encode()
data = {
"client_id": 15,
"third_party_app": "discord",
"privacy_link": "https://discord.com/privacy/",
"username": "Brian",
"callback_url": "https://bot.ae.utbm.fr/callback/123456789/",
}
urlencoded = urlencode(data)
data["signature"] = hmac.digest(key, urlencoded.encode(), "sha512").hex()
# URL a fournir à l'utilisateur pour son authentification
user_url = f"https://ae.ubtm.fr/api-link/auth/?{urlencode(data)}"
```
=== ":simple-rust: Rust"
Dépendances :
- `hmac` (>=0.12.1)
- `url` (>=2.5.7, features `serde`)
- `serde` (>=1.0.228, features `derive`)
- `serde_urlencoded` (>="0.7.1)
- `sha2` (>=0.10.9)
- `dotenvy` (>= 0.15)
```rust
use hmac::{Mac, SimpleHmac};
use serde::Serialize;
use sha2::Sha512;
use url::Url;
#[derive(Serialize, Debug)]
struct UrlData<'a> {
client_id: u32,
third_party_app: &'a str,
privacy_link: Url,
username: &'a str,
callback_url: Url,
}
impl<'a> UrlData<'a> {
pub fn signature(&self, key: &[u8]) -> CtOutput<SimpleHmac<Sha512>> {
let urlencoded = serde_urlencoded::to_string(self).unwrap();
SimpleHmac::<Sha512>::new_from_slice(key)
.unwrap()
.chain_update(urlencoded.as_bytes())
.finalize()
}
}
impl Into<Url> for UrlData<'_> {
fn into(self) -> Url {
let key = std::env::var("HMAC_KEY").unwrap();
let mut url = Url::parse("http://ae.utbm.fr/api-link/auth/").unwrap();
url.set_query(Some(
format!(
"{}&signature={:x}",
serde_urlencoded::to_string(&self).unwrap(),
self.signature(key.as_bytes()).into_bytes()
)
.as_str(),
));
url
}
}
fn main() {
dotenvy::dotenv().expect("Couldn't load env");
let data = UrlData {
client_id: 1,
third_party_app: "discord",
privacy_link: "https://discord.com/privacy/".parse().unwrap(),
username: "Brian",
callback_url: "https://bot.ae.utbm.fr/callback/123456789/"
.parse()
.unwrap(),
};
let url: Url = data.into();
println!("{:?}", url);
}
```
???Example "Vérification de la signature de la réponse"
Les données utilisateur peuvent ressembler à :
```json
{
"user": {
"display_name": "Matthieu Vincent",
"profile_url": "/user/380/",
"profile_pict": "/static/core/img/unknown.jpg",
"id": 380,
"nick_name": None,
"first_name": "Matthieu",
"last_name": "Vincent",
},
"signature": "3802a280fbb01bd9fetc."
}
```
Vous pouvez vérifier la signature ainsi :
```python
import hmac
from urllib.parse import urlencode
from environs import Env
env = Env()
env.read_env()
def is_signature_valid(user_data: dict, signature: str) -> bool:
key = env.str("HMAC_KEY").encode()
urlencoded = urlencode(user_data)
return hmac.compare_digest(
hmac.digest(key, urlencoded.encode(), "sha512").hex(),
signature,
)
post_data = <récupération des données POST>
print(
"signature valide :",
is_signature_valid(post_data["user"], post_data["signature"]
)
```
!!!Warning
Vous devez impérativement vérifier la signature
des données de la requête de callback !
Ne pas vérifier la signature permet à n'importe quel acteur
tierce malveillant de vous appeler sur votre callback.
Ce serait une faille de sécurité majeure de votre côté.
Si l'équipe informatique se rend compte que vous ne le faites pas,
elle se réserve le droit de suspendre votre application,
immédiatement et sans préavis.
+4 -4
View File
@@ -112,7 +112,7 @@ cf. [HTTP persistant connection (wikipedia)](https://en.wikipedia.org/wiki/HTTP_
Voici quelques exemples : Voici quelques exemples :
=== "Python (requests)" === ":simple-python: Python (requests)"
Dépendances : Dépendances :
@@ -132,7 +132,7 @@ Voici quelques exemples :
print(response.json()) print(response.json())
``` ```
=== "Python (aiohttp)" === ":simple-python: Python (aiohttp)"
Dépendances : Dépendances :
@@ -158,7 +158,7 @@ Voici quelques exemples :
asyncio.run(main()) asyncio.run(main())
``` ```
=== "Javascript (axios)" === ":simple-javascript: Javascript (axios)"
Dépendances : Dépendances :
@@ -178,7 +178,7 @@ Voici quelques exemples :
console.log(await instance.get("club/1").json()); console.log(await instance.get("club/1").json());
``` ```
=== "Rust (reqwest)" === ":simple-rust: Rust (reqwest)"
Dépendances : Dépendances :
-31
View File
@@ -1,6 +1,4 @@
## 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.
@@ -11,32 +9,3 @@ 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.
+1 -10
View File
@@ -1,6 +1,3 @@
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
@@ -11,19 +8,13 @@ from eboutic.models import Basket
@api_controller("/etransaction", permissions=[CanView]) @api_controller("/etransaction", permissions=[CanView])
class EtransactionInfoController(ControllerBase): class EtransactionInfoController(ControllerBase):
@route.get( @route.get("/data/{basket_id}", url_name="etransaction_data")
"/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:
-37
View File
@@ -1,37 +0,0 @@
#
# Copyright 2022
# - Maréchal <thgirod@hotmail.com
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
class PaymentResultConverter:
"""Converter used for url mapping of the `eboutic.views.payment_result` view.
It's meant to build an url that can match
either `/eboutic/pay/success/` or `/eboutic/pay/failure/`
but nothing else.
"""
regex = "(success|failure)"
def to_python(self, value):
return str(value)
def to_url(self, value):
return str(value)
+7 -35
View File
@@ -24,7 +24,6 @@ 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
@@ -96,19 +95,6 @@ class Basket(models.Model):
] ]
) )
@property
def is_expired(self) -> bool:
"""Return True if this basket is expired.
An expired basket can no longer be used tp pay with sith account
or to start an etransaction.
Warnings:
Users have an additional time if they pay with an etransaction,
so an expired basket may be purchased after its expiration in that case.
"""
return (self.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT) <= now()
def generate_sales( def generate_sales(
self, counter, seller: User, payment_method: Selling.PaymentMethod self, counter, seller: User, payment_method: Selling.PaymentMethod
): ):
@@ -147,20 +133,9 @@ 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")
@@ -180,10 +155,6 @@ 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"),
@@ -248,14 +219,16 @@ 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 = {"counter": get_eboutic(), "customer": customer, "date": self.date} kwargs = {
"counter": get_eboutic(),
"customer": customer,
"date": self.date,
"payment_method": Selling.PaymentMethod.CARD,
}
for i in self.items.select_related("product"): 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, **kwargs, operator=self.user, amount=i.unit_price * i.quantity
operator=self.user,
amount=i.unit_price * i.quantity,
payment_method=Refilling.PaymentMethod.CARD,
) )
else: else:
Selling.objects.create( Selling.objects.create(
@@ -266,7 +239,6 @@ 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,71 +1,21 @@
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, basket: Basket) => ({ Alpine.data("etransaction", (initialData, basketId: number) => ({
data: initialData, data: initialData,
isCbAvailable: Object.keys(initialData).length > 0, isCbAvailable: Object.keys(initialData).length > 0,
isSithAvailable: true,
init() {
const now = new Date();
const timeout = basket.timeout.getTime() - now.getTime();
if (timeout <= 0) {
// basket was already outdated at initial page load
this.timeoutBasket();
} else {
setTimeout(() => this.timeoutBasket(), timeout);
}
},
/**
* Make this basket into a timeout state.
* All submission inputs are disabled, and an error message is displayed.
*/
timeoutBasket() {
this.isCbAvailable = false;
this.isSithAvailable = false;
const message = gettext("Basket expired");
const existingNotif: Notification | undefined = this.$notifications
.getAll()
.find(
(n: Notification) =>
n.tag === NotificationLevel.Error && n.message === 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() { async fill() {
if (new Date() > basket.timeout) {
// refresh etransaction data only if the basket is still valid.
this.timeoutBasket();
return;
}
this.isCbAvailable = false; this.isCbAvailable = false;
const res = await etransactioninfoFetchEtransactionData({ const res = await etransactioninfoFetchEtransactionData({
path: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { basket_id: basket.id }, basket_id: basketId,
},
}); });
if (res.response.ok) { if (res.response.ok) {
this.data = res.data; this.data = res.data;
this.isCbAvailable = true; 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();
} }
}, },
})); }));
+11 -17
View File
@@ -11,7 +11,7 @@ const BASKET_CACHE_KEY = "basket";
const BASKET_CACHE_VERSION = 1; const BASKET_CACHE_VERSION = 1;
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("basket", (validPrices: number[], lastPurchaseTime?: number) => ({ Alpine.data("basket", (lastPurchaseTime?: number) => ({
basket: [] as BasketItem[], basket: [] as BasketItem[],
init() { init() {
@@ -19,6 +19,15 @@ 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");
@@ -28,22 +37,7 @@ document.addEventListener("alpine:init", () => {
const cached = versionedLocalStorage.getItem<BasketItem[]>(BASKET_CACHE_KEY, { const cached = versionedLocalStorage.getItem<BasketItem[]>(BASKET_CACHE_KEY, {
version: BASKET_CACHE_VERSION, version: BASKET_CACHE_VERSION,
}); });
if (!cached) { return cached ?? [];
return [];
}
if (
lastPurchaseTime !== null &&
localStorage.basketTimestamp !== undefined &&
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() {
@@ -21,7 +21,6 @@
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.as_p() }}
@@ -16,13 +16,10 @@
<h3>{% trans %}Eboutic{% endtrans %}</h3> <h3>{% trans %}Eboutic{% endtrans %}</h3>
<script type="text/javascript"> <script type="text/javascript">
const billingInfos = {{ billing_infos|safe }}; let billingInfos = {{ billing_infos|safe }};
</script> </script>
<div x-data='etransaction( <div x-data="etransaction(billingInfos, {{ basket.id }})">
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>
@@ -75,11 +72,7 @@
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 %}"
/> />
@@ -100,16 +93,7 @@
{% 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) }}" name="sith-pay-form">
{% csrf_token %} {% csrf_token %}
<input <input class="btn btn-blue" type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/>
{% 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>
+2 -16
View File
@@ -30,17 +30,7 @@
{% block content %} {% block content %}
<h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1> <h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1>
<div <div id="eboutic" x-data="basket({{ last_purchase_time }})">
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="">
@@ -197,10 +187,9 @@
{% for price in prices %} {% for price in prices %}
<button <button
id="{{ price.id }}" id="{{ price.id }}"
class="card clickable shadow" class="card product-button 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 }})'
{% if price.sold_out %}disabled{% endif %}
> >
{% if price.product.icon %} {% if price.product.icon %}
<img <img
@@ -213,9 +202,6 @@
{% 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>
+20 -46
View File
@@ -1,19 +1,14 @@
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, now from django.utils.timezone import localdate
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 (
@@ -135,11 +130,9 @@ def test_eboutic_basket_expiry(
_bulk_create=True, _bulk_create=True,
) )
soup = BeautifulSoup(client.get(reverse("eboutic:main")).text, "lxml")
assert ( assert (
# remove any space from the value before asserting f'x-data="basket({int(expected.timestamp() * 1000) if expected else "null"})"'
re.sub(r"\s+", "", soup.find(id="eboutic").attrs["x-data"]) in client.get(reverse("eboutic:main")).text
== f"basket([],{int(expected.timestamp() * 1000) if expected else 'null'},)"
) )
@@ -238,45 +231,26 @@ 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)
for product in self.beer, self.cotiz, self.not_in_counter: response = self.submit_basket([BasketItem(self.beer.id, 1)])
response = self.submit_basket([BasketItem(product.id, 1)])
assert response.status_code == 200 assert response.status_code == 200
assert not Basket.objects.exists() assert Basket.objects.first() is None
def test_sold_out_product(self): response = self.submit_basket([BasketItem(self.cotiz.id, 1)])
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.count() == 1 assert Basket.objects.first() is None
with freezegun.freeze_time(
now() response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)])
+ settings.SITH_EBOUTIC_BASKET_TIMEOUT assert response.status_code == 200
+ settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT assert Basket.objects.first() is None
):
# after a while, unpaid basket items should expire and make the self.client.force_login(self.new_customer)
# product available again. response = self.submit_basket([BasketItem(self.cotiz.id, 1)])
response = self.submit_basket([BasketItem(price.id, 1)]) assert response.status_code == 200
assertRedirects( assert Basket.objects.first() is None
response,
reverse("eboutic:checkout", kwargs={"basket_id": Basket.objects.last().id}), response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)])
) assert response.status_code == 200
assert Basket.objects.count() == 2 assert Basket.objects.first() is None
def test_create_basket(self): def test_create_basket(self):
self.client.force_login(self.new_customer) self.client.force_login(self.new_customer)
+7 -27
View File
@@ -3,7 +3,6 @@ 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
@@ -18,7 +17,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, Refilling, Selling from counter.models import Product, ProductType, 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
@@ -106,7 +105,7 @@ class TestPaymentSith(TestPaymentBase):
), ),
reverse("eboutic:payment_result", kwargs={"result": "success"}), reverse("eboutic:payment_result", kwargs={"result": "success"}),
) )
assert not Basket.objects.filter(id=self.basket.id).exists() assert Basket.objects.filter(id=self.basket.id).first() is None
self.customer.customer.refresh_from_db() self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == Decimal(1) assert self.customer.customer.amount == Decimal(1)
@@ -140,7 +139,10 @@ 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()
@@ -155,7 +157,7 @@ class TestPaymentSith(TestPaymentBase):
response, response,
reverse("eboutic:payment_result", kwargs={"result": "failure"}), reverse("eboutic:payment_result", kwargs={"result": "failure"}),
) )
assert not Basket.objects.filter(id=self.basket.id).exists() assert Basket.objects.filter(id=self.basket.id).first() is not None
messages = list(get_messages(response.wsgi_request)) messages = list(get_messages(response.wsgi_request))
assert messages[0].level == DEFAULT_LEVELS["ERROR"] assert messages[0].level == DEFAULT_LEVELS["ERROR"]
assert ( assert (
@@ -165,24 +167,6 @@ 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):
@@ -252,10 +236,6 @@ 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)
+2 -2
View File
@@ -24,7 +24,7 @@
from django.urls import path, register_converter from django.urls import path, register_converter
from eboutic.converters import PaymentResultConverter from core.converters import ResultConverter
from eboutic.views import ( from eboutic.views import (
BillingInfoFormFragment, BillingInfoFormFragment,
EbouticCheckout, EbouticCheckout,
@@ -35,7 +35,7 @@ from eboutic.views import (
payment_result, payment_result,
) )
register_converter(PaymentResultConverter, "res") register_converter(ResultConverter, "res")
urlpatterns = [ urlpatterns = [
# Subscription views # Subscription views
+8 -30
View File
@@ -33,14 +33,12 @@ 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 Exists, OuterRef, Subquery from django.db.models import 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, TemplateView, UpdateView, View
@@ -92,9 +90,7 @@ class EbouticMainView(LoginRequiredMixin, FormView):
kwargs["form_kwargs"] = { kwargs["form_kwargs"] = {
"customer": self.customer, "customer": self.customer,
"counter": get_eboutic(), "counter": get_eboutic(),
"allowed_prices": { "allowed_prices": {price.id: price for price in self.prices},
price.id: price for price in self.prices if not price.sold_out
},
} }
return kwargs return kwargs
@@ -120,14 +116,9 @@ class EbouticMainView(LoginRequiredMixin, FormView):
@cached_property @cached_property
def prices(self) -> list[Price]: def prices(self) -> list[Price]:
eboutic = get_eboutic() return get_eboutic().get_prices_for(
sold_out_subquery = ~Exists( self.customer,
eboutic.products.under_clic_limit().filter(id=OuterRef("product_id")) order_by=["product__product_type__order", "product_id", "amount"],
)
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
@@ -196,7 +187,9 @@ class BillingInfoFormFragment(
def get_initial(self): def get_initial(self):
if self.object is None: if self.object is None:
return {"country": Country(code="FR")} return {
"country": Country(code="FR"),
}
return {} return {}
def render_fragment(self, request, **kwargs) -> SafeString: def render_fragment(self, request, **kwargs) -> SafeString:
@@ -262,15 +255,6 @@ 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())
@@ -284,14 +268,9 @@ 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()
@@ -309,7 +288,6 @@ 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")
+124 -56
View File
@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-06-02 10:53+0200\n" "POT-Creation-Date: 2026-05-23 15:09+0200\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -35,6 +35,10 @@ msgstr ""
"True si gardé à jour par le biais d'un fournisseur externe de domains " "True si gardé à jour par le biais d'un fournisseur externe de domains "
"toxics, False sinon" "toxics, False sinon"
#: api/admin.py
msgid "Reset HMAC key"
msgstr "Réinitialiser la clef HMAC"
#: api/admin.py #: api/admin.py
#, python-format #, python-format
msgid "" msgid ""
@@ -48,6 +52,23 @@ msgstr ""
msgid "Revoke selected API keys" msgid "Revoke selected API keys"
msgstr "Révoquer les clefs d'API sélectionnées" msgstr "Révoquer les clefs d'API sélectionnées"
#: api/forms.py
msgid "I have read and I accept the terms and conditions of use"
msgstr "J'ai lu et j'accepte les conditions générales d'utilisation."
#: api/forms.py
msgid "You must approve the terms and conditions of use."
msgstr "Vous devez approuver les conditions générales d'utilisation."
#: api/forms.py
msgid "You must confirm that this is your username."
msgstr "Vous devez confirmer que c'est bien votre nom d'utilisateur."
#: api/forms.py
#, python-format
msgid "I confirm that %(username)s is my username on %(app)s"
msgstr "Je confirme que %(username)s est mon nom d'utilisateur sur %(app)s"
#: api/models.py club/models.py com/models.py counter/models.py forum/models.py #: api/models.py club/models.py com/models.py counter/models.py forum/models.py
msgid "name" msgid "name"
msgstr "nom" msgstr "nom"
@@ -68,6 +89,10 @@ msgstr "permissions du client"
msgid "Specific permissions for this api client." msgid "Specific permissions for this api client."
msgstr "Permissions spécifiques pour ce client d'API" msgstr "Permissions spécifiques pour ce client d'API"
#: api/models.py
msgid "HMAC Key"
msgstr "Clef HMAC"
#: api/models.py #: api/models.py
msgid "api client" msgid "api client"
msgstr "client d'api" msgstr "client d'api"
@@ -97,6 +122,76 @@ msgstr "clef d'api"
msgid "api keys" msgid "api keys"
msgstr "clefs d'api" msgstr "clefs d'api"
#: api/templates/api/third_party/auth.jinja
msgid "Confidentiality"
msgstr "Confidentialité"
#: api/templates/api/third_party/auth.jinja
#, python-format
msgid ""
"By ticking this box and clicking on the send button, you acknowledge and "
"agree to provide %(app)s with your first name, last name, nickname and any "
"other information that was the third party app was explicitly authorized to "
"fetch and that it must have acknowledged to you, in a complete and accurate "
"manner."
msgstr ""
"En cochant cette case et en cliquant sur le bouton « Envoyer », vous "
"reconnaissez et acceptez de fournir à %(app)s votre prénom, nom, pseudonyme "
"et toute autre information que l'application tierce a été explicitement "
"autorisée à récupérer et qu'elle doit vous avoir communiqué de manière "
"complète et exacte."
#: api/templates/api/third_party/auth.jinja
#, python-format
msgid ""
"The privacy policies of <a href=\"%(privacy_link)s\">%(app)s</a> and of <a "
"href=\"%(sith_cgu_link)s\">the Students' Association</a> applies as soon as "
"the form is submitted."
msgstr ""
"Les politiques de confidentialité de <a href=\"%(privacy_link)s\">%(app)s</"
"a> et de <a href=\"%(sith_cgu_link)s\">l'Association des Etudiants</a> "
"s'appliquent dès la soumission du formulaire."
#: api/templates/api/third_party/auth.jinja
msgid "Confirmation of identity"
msgstr "Confirmation d'identité"
#: api/views.py
msgid "The data provided for authentication is incorrect"
msgstr "Les données fournies pour l'authentification sont incorrectes."
#: api/views.py
msgid ""
"The signature is incorrect. We cannot ensure the provenance of the request."
msgstr ""
"La signature est incorrecte. Nous ne pouvons pas garantir l'authenticité de "
"la requête."
#: api/views.py
#, python-format
msgid ""
"You are going to link your AE account and your %(app)s account. Continue "
"only if this page was opened from %(app)s."
msgstr ""
"Vous allez lier votre compte AE et votre compte %(app)s. Poursuivez "
"uniquement si cette page a été ouverte depuis %(app)s."
#: api/views.py
msgid "You have been successfully authenticated. You can now close this page."
msgstr ""
"Vous avez été authentifié avec succès. Vous pouvez maintenant fermer cette "
"page."
#: api/views.py
msgid ""
"Your authentication on the AE website was successful, but an error happened "
"during the interaction with the third-party application. Please contact the "
"managers of the latter."
msgstr ""
"Votre authentification sur le site AE a fonctionné, mais une erreur est "
"arrivée durant l'interaction avec l'application tierce. Veuillez contacter "
"les responsables de cette dernière."
#: club/forms.py #: club/forms.py
msgid "Users to add" msgid "Users to add"
msgstr "Utilisateurs à ajouter" msgstr "Utilisateurs à ajouter"
@@ -363,8 +458,11 @@ msgid "Unregistered user"
msgstr "Utilisateur non enregistré" msgstr "Utilisateur non enregistré"
#: club/models.py #: club/models.py
msgid "The base url that links with this type must respect" #, python-format
msgstr "L'url de base que tous les liens de ce type doivent respecter" msgid "The base url that links with this type must respect (e.g. `%(url)s`)"
msgstr ""
"L'url de base que tous les liens de ce type doivent respecter (par exemple "
"`%(url)s`)"
#: club/models.py counter/models.py #: club/models.py counter/models.py
msgid "icon" msgid "icon"
@@ -2200,7 +2298,6 @@ msgstr "Êtes-vous sûr de vouloir supprimer \"%(name)s\" ?"
#: core/templates/core/delete_confirm.jinja #: core/templates/core/delete_confirm.jinja
#: core/templates/core/file_delete_confirm.jinja #: core/templates/core/file_delete_confirm.jinja
#: counter/templates/counter/fragments/delete_student_card.jinja #: counter/templates/counter/fragments/delete_student_card.jinja
#: counter/templates/counter/fragments/login.jinja
msgid "Confirm" msgid "Confirm"
msgstr "Confirmation" msgstr "Confirmation"
@@ -3204,18 +3301,6 @@ msgstr "Cet UID est invalide"
msgid "User not found" msgid "User not found"
msgstr "Utilisateur non trouvé" msgstr "Utilisateur non trouvé"
#: counter/forms.py
msgid "You are not a barman of this counter."
msgstr "Vous n'êtes pas barman sur ce comptoir."
#: counter/forms.py
msgid "You are already logged in this counter."
msgstr "Vous êtes déjà connecté à ce comptoir."
#: counter/forms.py
msgid "You are already logged in another counter."
msgstr "Vous êtes déjà connecté à un autre comptoir."
#: counter/forms.py #: counter/forms.py
msgid "Regular barmen" msgid "Regular barmen"
msgstr "Barmen réguliers" msgstr "Barmen réguliers"
@@ -3418,16 +3503,8 @@ msgid "Buy five, get the sixth free"
msgstr "Pour cinq achetés, le sixième offert" msgstr "Pour cinq achetés, le sixième offert"
#: counter/models.py #: counter/models.py
msgid "clic limit" msgid "buying groups"
msgstr "limite de clic" msgstr "groupe d'achat"
#: counter/models.py
msgid ""
"If a limit is set, the product won't be purchasable anymore on the eboutic "
"once the latter is reached."
msgstr ""
"Si une limite est donnée, le produit ne sera plus achetable sur l'eboutic "
"une fois celle-ci atteinte."
#: counter/models.py election/models.py #: counter/models.py election/models.py
msgid "archived" msgid "archived"
@@ -3494,6 +3571,10 @@ msgstr "Bureau"
msgid "sellers" msgid "sellers"
msgstr "vendeurs" msgstr "vendeurs"
#: counter/models.py
msgid "token"
msgstr "jeton"
#: counter/models.py #: counter/models.py
msgid "regular barman" msgid "regular barman"
msgstr "barman régulier" msgstr "barman régulier"
@@ -3818,7 +3899,7 @@ msgstr ""
#: counter/templates/counter/counter_click.jinja #: counter/templates/counter/counter_click.jinja
msgid "No products available on this counter for this user" msgid "No products available on this counter for this user"
msgstr "Pas de produits disponibles dans ce comptoir pour cet utilisateur" msgstr "Pas de produits disponnibles dans ce comptoir pour cet utilisateur"
#: counter/templates/counter/counter_list.jinja #: counter/templates/counter/counter_list.jinja
msgid "Counter admin list" msgid "Counter admin list"
@@ -3879,20 +3960,12 @@ msgid "Please, login"
msgstr "Merci de vous identifier" msgstr "Merci de vous identifier"
#: counter/templates/counter/counter_main.jinja #: counter/templates/counter/counter_main.jinja
msgid "Barmen:" msgid "Barman: "
msgstr "Barmen :" msgstr "Barman : "
#: counter/templates/counter/counter_main.jinja #: counter/templates/counter/counter_main.jinja
msgid "On this device" msgid "login"
msgstr "Sur cet appareil" msgstr "login"
#: counter/templates/counter/counter_main.jinja
msgid "Elsewhere"
msgstr "Ailleurs"
#: counter/templates/counter/counter_main.jinja
msgid "No barman logged elsewhere"
msgstr "Pas de barman connecté ailleurs"
#: counter/templates/counter/eticket_list.jinja #: counter/templates/counter/eticket_list.jinja
msgid "Eticket list" msgid "Eticket list"
@@ -4297,14 +4370,22 @@ msgstr "Montant du chèque"
msgid "Check quantity" msgid "Check quantity"
msgstr "Nombre de chèque" msgstr "Nombre de chèque"
#: counter/views/click.py
msgid "You cannot click users on this counter"
msgstr "Vous ne pouvez pas cliquer des gens sur ce comptoir"
#: counter/views/eticket.py #: counter/views/eticket.py
msgid "people(s)" msgid "people(s)"
msgstr "personne(s)" msgstr "personne(s)"
#: counter/views/home.py
msgid "Bad credentials"
msgstr "Mauvais identifiants"
#: counter/views/home.py
msgid "User is not barman"
msgstr "L'utilisateur n'est pas barman."
#: counter/views/home.py
msgid "Bad location, someone is already logged in somewhere else"
msgstr "Mauvais comptoir, quelqu'un est déjà connecté ailleurs"
#: counter/views/invoice.py #: counter/views/invoice.py
msgid "Invoice calls status has been updated." msgid "Invoice calls status has been updated."
msgstr "Le statut des appels à facture a été mis à jour." msgstr "Le statut des appels à facture a été mis à jour."
@@ -4476,10 +4557,6 @@ msgstr ""
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, " "billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
"du vendredi au dimanche." "du vendredi au dimanche."
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Product sold out"
msgstr "Produit épuisé"
#: eboutic/templates/eboutic/eboutic_main.jinja #: eboutic/templates/eboutic/eboutic_main.jinja
msgid "There are no items available for sale" msgid "There are no items available for sale"
msgstr "Aucun article n'est disponible à la vente" msgstr "Aucun article n'est disponible à la vente"
@@ -4523,15 +4600,6 @@ msgstr ""
"souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux " "souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux "
"données que vous aviez déjà fourni." "données que vous aviez déjà fourni."
#: eboutic/views.py
msgid "Basket expired"
msgstr "Panier expiré"
#: eboutic/views.py
#, python-format
msgid "Basket available until %(until)s"
msgstr "Panier disponible jusqu'à %(until)s"
#: eboutic/views.py #: eboutic/views.py
msgid "You can't buy a refilling with sith money" msgid "You can't buy a refilling with sith money"
msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith" msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith"
+1 -5
View File
@@ -7,7 +7,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-17 10:03+0200\n" "POT-Creation-Date: 2026-04-17 22:42+0200\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n" "Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -263,10 +263,6 @@ msgstr "Types de produits réordonnés !"
msgid "Product type reorganisation failed with status code : %d" msgid "Product type reorganisation failed with status code : %d"
msgstr "La réorganisation des types de produit a échoué avec le code : %d" msgstr "La réorganisation des types de produit a échoué avec le code : %d"
#: eboutic/static/bundled/eboutic/checkout-index.ts
msgid "Basket expired"
msgstr "Panier expiré"
#: sas/static/bundled/sas/pictures-download-index.ts #: sas/static/bundled/sas/pictures-download-index.ts
msgid "pictures.%(extension)s" msgid "pictures.%(extension)s"
msgstr "photos.%(extension)s" msgstr "photos.%(extension)s"
+3
View File
@@ -71,6 +71,7 @@ nav:
- API: - API:
- Développement: tutorial/api/dev.md - Développement: tutorial/api/dev.md
- Connexion à l'API: tutorial/api/connect.md - Connexion à l'API: tutorial/api/connect.md
- Liaison avec le compte AE: tutorial/api/account-link.md
- Etransactions: tutorial/etransaction.md - Etransactions: tutorial/etransaction.md
- How-to: - How-to:
- L'ORM de Django: howto/querysets.md - L'ORM de Django: howto/querysets.md
@@ -94,6 +95,8 @@ nav:
- reference/api/hashers.md - reference/api/hashers.md
- reference/api/models.md - reference/api/models.md
- reference/api/perms.md - reference/api/perms.md
- reference/api/schemas.md
- reference/api/views.md
- club: - club:
- reference/club/models.md - reference/club/models.md
- reference/club/views.md - reference/club/views.md
+4 -10
View File
@@ -34,7 +34,6 @@ https://docs.djangoproject.com/en/1.8/ref/settings/
""" """
import binascii import binascii
import contextlib
import os import os
import sys import sys
from datetime import timedelta from datetime import timedelta
@@ -42,7 +41,6 @@ from pathlib import Path
import sentry_sdk import sentry_sdk
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.utils.deprecation import RemovedInDjango60Warning
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from environs import Env from environs import Env
from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.django import DjangoIntegration
@@ -93,7 +91,6 @@ ALLOWED_HOSTS = ["*"]
# RemovedInDjango60Warning: It's a transitional setting helpful in early # RemovedInDjango60Warning: It's a transitional setting helpful in early
# adoption of "https" as the new default value of forms.URLField.assume_scheme. # adoption of "https" as the new default value of forms.URLField.assume_scheme.
# Remove this after upgrading to Django 6.x # Remove this after upgrading to Django 6.x
with contextlib.suppress(RemovedInDjango60Warning):
FORMS_URLFIELD_ASSUME_HTTPS = True FORMS_URLFIELD_ASSUME_HTTPS = True
# Application definition # Application definition
@@ -141,13 +138,13 @@ MIDDLEWARE = (
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"core.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.locale.LocaleMiddleware", "django.middleware.locale.LocaleMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"core.middleware.AuthenticationMiddleware",
"core.middleware.SignalRequestMiddleware", "core.middleware.SignalRequestMiddleware",
"counter.middleware.BarmenMiddleware",
) )
ROOT_URLCONF = "sith.urls" ROOT_URLCONF = "sith.urls"
@@ -412,6 +409,8 @@ SITH_FORUM_PAGE_LENGTH = 30
SITH_SAS_ROOT_DIR_ID = env.int("SITH_SAS_ROOT_DIR_ID", default=4) SITH_SAS_ROOT_DIR_ID = env.int("SITH_SAS_ROOT_DIR_ID", default=4)
SITH_SAS_IMAGES_PER_PAGE = 60 SITH_SAS_IMAGES_PER_PAGE = 60
SITH_CGU_FILE_ID = env.int("SITH_CGU_FILE_ID", default=5)
SITH_PROFILE_DEPARTMENTS = [ SITH_PROFILE_DEPARTMENTS = [
("TC", _("TC")), ("TC", _("TC")),
("IMSI", _("IMSI")), ("IMSI", _("IMSI")),
@@ -574,11 +573,6 @@ SITH_BARMAN_TIMEOUT = 30
# Minutes to delete the last operations # Minutes to delete the last operations
SITH_LAST_OPERATIONS_LIMIT = 10 SITH_LAST_OPERATIONS_LIMIT = 10
# time before a basket is considered expired
SITH_EBOUTIC_BASKET_TIMEOUT = timedelta(minutes=10)
# time that a user can spend on the CB payment page before it to timeout
SITH_EBOUTIC_ETRANSACTION_TIMEOUT = timedelta(minutes=10)
# ET variables # ET variables
SITH_EBOUTIC_CB_ENABLED = env.bool("SITH_EBOUTIC_CB_ENABLED", default=True) SITH_EBOUTIC_CB_ENABLED = env.bool("SITH_EBOUTIC_CB_ENABLED", default=True)
SITH_EBOUTIC_ET_URL = env.str( SITH_EBOUTIC_ET_URL = env.str(
+1
View File
@@ -34,6 +34,7 @@ urlpatterns = [
path("", include(("core.urls", "core"), namespace="core")), path("", include(("core.urls", "core"), namespace="core")),
path("sitemap.xml", cache_page(86400)(sitemap), {"sitemaps": sitemaps}), path("sitemap.xml", cache_page(86400)(sitemap), {"sitemaps": sitemaps}),
path("api/", api.urls), path("api/", api.urls),
path("api-link/", include(("api.urls", "api-link"), namespace="api-link")),
path("rootplace/", include(("rootplace.urls", "rootplace"), namespace="rootplace")), path("rootplace/", include(("rootplace.urls", "rootplace"), namespace="rootplace")),
path( path(
"subscription/", "subscription/",