mirror of
https://github.com/ae-utbm/sith.git
synced 2026-06-05 15:49:21 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e25f173c19 | |||
| 751e1328be | |||
| d52dd74a4b | |||
| e0d5cc44de | |||
| f951b8c985 | |||
| f87d54ebce | |||
| ba6d83dca7 | |||
| dfdcf0bdab | |||
| 6ad1dc3c06 | |||
| 4da50e34a1 | |||
| 524b7d0bd8 | |||
| 9042ebfb55 |
@@ -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
@@ -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
|
||||||
@@ -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")}
|
||||||
@@ -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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
+33
-22
@@ -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
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def all_permissions(self) -> set[str]:
|
||||||
|
permissions = (
|
||||||
|
Permission.objects.filter(
|
||||||
|
Q(group__group__in=self.groups.all()) | Q(clients=self)
|
||||||
|
)
|
||||||
|
.values_list("content_type__app_label", "codename")
|
||||||
|
.order_by()
|
||||||
|
)
|
||||||
|
return {f"{content_type}.{name}" for content_type, name in permissions}
|
||||||
|
|
||||||
def has_perm(self, perm: str):
|
def has_perm(self, perm: str):
|
||||||
"""Return True if the client has the specified permission."""
|
"""Return True if the client has the specified permission."""
|
||||||
|
return perm in self.all_permissions
|
||||||
|
|
||||||
if self._perm_cache is None:
|
def has_perms(self, perm_list: Iterable[str]) -> bool:
|
||||||
group_permissions = (
|
"""Return True if the client has each of the specified permissions."""
|
||||||
Permission.objects.filter(group__group__in=self.groups.all())
|
|
||||||
.values_list("content_type__app_label", "codename")
|
|
||||||
.order_by()
|
|
||||||
)
|
|
||||||
client_permissions = self.client_permissions.values_list(
|
|
||||||
"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):
|
|
||||||
"""
|
|
||||||
Return True if the client has each of the specified permissions. If
|
|
||||||
object is passed, check if the client has all required perms for it.
|
|
||||||
"""
|
|
||||||
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
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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 %}
|
||||||
@@ -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]
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
@@ -21,13 +21,10 @@
|
|||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
import itertools
|
|
||||||
from operator import attrgetter
|
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.db.models import Exists, OuterRef, Q, QuerySet
|
from django.db.models import Exists, OuterRef, Q, QuerySet
|
||||||
from django.db.models.functions import Lower
|
from django.db.models.functions import Lower
|
||||||
from django.forms.models import ModelChoiceField, ModelChoiceIterator
|
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@@ -49,37 +46,6 @@ from counter.models import Counter, Selling
|
|||||||
from counter.schemas import SaleFilterSchema
|
from counter.schemas import SaleFilterSchema
|
||||||
|
|
||||||
|
|
||||||
class ClubRoleChoiceIterator(ModelChoiceIterator):
|
|
||||||
"""Custom `ModelChoiceIterator` for `ClubRoleChoiceField`"""
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
if self.field.empty_label is not None:
|
|
||||||
yield "", self.field.empty_label
|
|
||||||
queryset = self.queryset.select_related("club").order_by("club", "order")
|
|
||||||
groups = [
|
|
||||||
(club, [self.choice(role) for role in roles])
|
|
||||||
for club, roles in itertools.groupby(queryset, key=attrgetter("club"))
|
|
||||||
]
|
|
||||||
if len(groups) == 1:
|
|
||||||
# there is only one club involved, no need to have optgroups
|
|
||||||
yield from groups[0][1]
|
|
||||||
else:
|
|
||||||
# there are multiple clubs, optgroups are necessary to differentiate
|
|
||||||
# roles having the same name
|
|
||||||
yield from groups
|
|
||||||
|
|
||||||
|
|
||||||
class ClubRoleChoiceField(ModelChoiceField):
|
|
||||||
"""Custom `ModelChoiceField` for `[ClubRole][club.models.ClubRole]`.
|
|
||||||
|
|
||||||
If only one club is involved, behave like the base `ModelChoiceField`.
|
|
||||||
If dealing with the roles of multiple clubs, group the roles
|
|
||||||
into a different `optgroup` for each club.
|
|
||||||
"""
|
|
||||||
|
|
||||||
iterator = ClubRoleChoiceIterator
|
|
||||||
|
|
||||||
|
|
||||||
class ClubLinkForm(forms.ModelForm):
|
class ClubLinkForm(forms.ModelForm):
|
||||||
error_css_class = "error"
|
error_css_class = "error"
|
||||||
required_css_class = "required"
|
required_css_class = "required"
|
||||||
@@ -426,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.
|
||||||
|
|||||||
@@ -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
@@ -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"),
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
+1
-1
@@ -170,7 +170,7 @@ class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
|
|||||||
form_class = NewsForm
|
form_class = NewsForm
|
||||||
template_name = "com/news_edit.jinja"
|
template_name = "com/news_edit.jinja"
|
||||||
pk_url_kwarg = "news_id"
|
pk_url_kwarg = "news_id"
|
||||||
permission_required = "com.change_news"
|
permission_required = "com.edit_news"
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
response = super().form_valid(form) # Does the saving part
|
response = super().form_valid(form) # Does the saving part
|
||||||
|
|||||||
+11
-8
@@ -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)"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, timedelta
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import ClassVar, NamedTuple
|
from typing import ClassVar, NamedTuple
|
||||||
@@ -28,13 +28,13 @@ 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
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.lorem_ipsum import paragraphs
|
from django.utils.timezone import localdate
|
||||||
from django.utils.timezone import localdate, now
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from club.models import Club, ClubLink, ClubRole, LinkType, Membership
|
from club.models import Club, ClubLink, ClubRole, LinkType, Membership
|
||||||
@@ -44,14 +44,13 @@ from core.models import BanGroup, Group, Page, PageRev, SithFile, User
|
|||||||
from core.utils import resize_image
|
from core.utils import resize_image
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
Counter,
|
Counter,
|
||||||
CounterSellers,
|
|
||||||
Price,
|
Price,
|
||||||
Product,
|
Product,
|
||||||
ProductType,
|
ProductType,
|
||||||
ReturnableProduct,
|
ReturnableProduct,
|
||||||
StudentCard,
|
StudentCard,
|
||||||
)
|
)
|
||||||
from election.models import Candidature, Election, ElectionList, Role, Vote
|
from election.models import Candidature, Election, ElectionList, Role
|
||||||
from forum.models import Forum
|
from forum.models import Forum
|
||||||
from pedagogy.models import UE
|
from pedagogy.models import UE
|
||||||
from sas.models import Album, PeoplePictureRelation, Picture
|
from sas.models import Album, PeoplePictureRelation, Picture
|
||||||
@@ -120,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")
|
||||||
@@ -366,15 +371,62 @@ 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
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create an election
|
# Create an election
|
||||||
self._create_elections(groups, clubs, skia, sli, krophil)
|
el = Election.objects.create(
|
||||||
|
title="Élection 2017",
|
||||||
|
description="La roue tourne",
|
||||||
|
start_candidature="1942-06-12 10:28:45+01",
|
||||||
|
end_candidature="2042-06-12 10:28:45+01",
|
||||||
|
start_date="1942-06-12 10:28:45+01",
|
||||||
|
end_date="7942-06-12 10:28:45+01",
|
||||||
|
)
|
||||||
|
el.view_groups.add(groups.public)
|
||||||
|
el.edit_groups.add(clubs.ae.board_group)
|
||||||
|
el.candidature_groups.add(groups.subscribers)
|
||||||
|
el.vote_groups.add(groups.subscribers)
|
||||||
|
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
|
||||||
|
listeT = ElectionList.objects.create(title="Troll", election=el)
|
||||||
|
pres = Role.objects.create(
|
||||||
|
election=el, title="Président AE", description="Roi de l'AE"
|
||||||
|
)
|
||||||
|
resp = Role.objects.create(
|
||||||
|
election=el, title="Co Respo Info", max_choice=2, description="Ghetto++"
|
||||||
|
)
|
||||||
|
Candidature.objects.bulk_create(
|
||||||
|
[
|
||||||
|
Candidature(
|
||||||
|
role=resp,
|
||||||
|
user=skia,
|
||||||
|
election_list=liste,
|
||||||
|
program="Refesons le site AE",
|
||||||
|
),
|
||||||
|
Candidature(
|
||||||
|
role=resp,
|
||||||
|
user=sli,
|
||||||
|
election_list=liste,
|
||||||
|
program="Vasy je deviens mon propre adjoint",
|
||||||
|
),
|
||||||
|
Candidature(
|
||||||
|
role=resp,
|
||||||
|
user=krophil,
|
||||||
|
election_list=listeT,
|
||||||
|
program="Le Pôle Troll !",
|
||||||
|
),
|
||||||
|
Candidature(
|
||||||
|
role=pres,
|
||||||
|
user=sli,
|
||||||
|
election_list=listeT,
|
||||||
|
program="En fait j'aime pas l'info, je voulais faire GMC",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
# Forum
|
# Forum
|
||||||
room = Forum.objects.create(
|
room = Forum.objects.create(
|
||||||
@@ -965,132 +1017,3 @@ class Command(BaseCommand):
|
|||||||
BanGroup.objects.create(name="Banned from buying alcohol", description="")
|
BanGroup.objects.create(name="Banned from buying alcohol", description="")
|
||||||
BanGroup.objects.create(name="Banned from counters", description="")
|
BanGroup.objects.create(name="Banned from counters", description="")
|
||||||
BanGroup.objects.create(name="Banned to subscribe", description="")
|
BanGroup.objects.create(name="Banned to subscribe", description="")
|
||||||
|
|
||||||
def _create_elections(
|
|
||||||
self,
|
|
||||||
groups: PopulatedGroups,
|
|
||||||
clubs: PopulatedClubs,
|
|
||||||
skia: User,
|
|
||||||
sli: User,
|
|
||||||
krophil: User,
|
|
||||||
):
|
|
||||||
"""Populate elections.
|
|
||||||
|
|
||||||
4 elections are created :
|
|
||||||
|
|
||||||
- one that has not started yet,
|
|
||||||
- one on the candidature period
|
|
||||||
- one on the vote period
|
|
||||||
- one that is finished
|
|
||||||
|
|
||||||
All elections have two lists, are linked to the AE and Troll clubs,
|
|
||||||
and have one role for each board role of thos two clubs, plus
|
|
||||||
an additional role linked to no club roles.
|
|
||||||
|
|
||||||
The ongoing vote and finished elections have candidates.
|
|
||||||
|
|
||||||
The finished election has 10 voters.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def election_factory(title: str, start_candidature: datetime):
|
|
||||||
return Election(
|
|
||||||
title=title,
|
|
||||||
description="",
|
|
||||||
start_candidature=start_candidature,
|
|
||||||
end_candidature=start_candidature + timedelta(days=7),
|
|
||||||
start_date=start_candidature + timedelta(days=7),
|
|
||||||
end_date=start_candidature + timedelta(days=14),
|
|
||||||
)
|
|
||||||
|
|
||||||
# create the elections
|
|
||||||
elections = Election.objects.bulk_create(
|
|
||||||
[
|
|
||||||
election_factory("Election terminée", now() - timedelta(days=14)),
|
|
||||||
election_factory("Votes en cours", now() - timedelta(days=7)),
|
|
||||||
election_factory("Candidatures en cours", now()),
|
|
||||||
election_factory("Election à venir", now() + timedelta(days=7)),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
finished, ongoing_vote, _ongoing_candidature, _not_started = elections
|
|
||||||
|
|
||||||
# set the groups (all elections have the same groups)
|
|
||||||
groups.public.viewable_elections.set(elections)
|
|
||||||
clubs.ae.board_group.editable_elections.set(elections)
|
|
||||||
groups.subscribers.candidate_elections.set(elections)
|
|
||||||
groups.subscribers.votable_elections.set(elections)
|
|
||||||
|
|
||||||
# link elections to clubs (AE and Troll for all elections)
|
|
||||||
Election.clubs.through.objects.bulk_create(
|
|
||||||
[
|
|
||||||
*[Election.clubs.through(club=clubs.ae, election=e) for e in elections],
|
|
||||||
*[
|
|
||||||
Election.clubs.through(club=clubs.troll, election=e)
|
|
||||||
for e in elections
|
|
||||||
],
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create lists (all elections have two lists)
|
|
||||||
ElectionList.objects.bulk_create(
|
|
||||||
[
|
|
||||||
*[ElectionList(title="Candidat libre", election=e) for e in elections],
|
|
||||||
*[ElectionList(title="Troll", election=e) for e in elections],
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create roles.
|
|
||||||
# Elections have a role for each board club role of AE and Troll,
|
|
||||||
# +an additional role linked to no club role
|
|
||||||
club_roles = list(
|
|
||||||
ClubRole.objects.filter(club__in=[clubs.ae, clubs.troll], is_board=True)
|
|
||||||
.select_related("club")
|
|
||||||
.order_by("club_id", "order")
|
|
||||||
)
|
|
||||||
Role.objects.bulk_create(
|
|
||||||
[
|
|
||||||
*[
|
|
||||||
Role(election=e, title=f"{r.name} {r.club.name}", club_role=r)
|
|
||||||
for r in club_roles
|
|
||||||
for e in elections
|
|
||||||
],
|
|
||||||
*[Role(election=e, title="Rôle libre") for e in elections],
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
# create candidatures for ongoing_vote and finished elections
|
|
||||||
candidatures = []
|
|
||||||
lipsum = "\n\n".join(paragraphs(2))
|
|
||||||
for election in ongoing_vote, finished:
|
|
||||||
lists = list(election.election_lists.order_by("id"))
|
|
||||||
roles = list(election.roles.order_by("order")[:3])
|
|
||||||
candidatures.extend(
|
|
||||||
[
|
|
||||||
Candidature(
|
|
||||||
role=roles[0], user=skia, election_list=lists[0], program=lipsum
|
|
||||||
),
|
|
||||||
Candidature(
|
|
||||||
role=roles[1], user=sli, election_list=lists[0], program=lipsum
|
|
||||||
),
|
|
||||||
Candidature(
|
|
||||||
role=roles[2], user=krophil, election_list=lists[1], program=""
|
|
||||||
),
|
|
||||||
Candidature(
|
|
||||||
role=roles[2], user=sli, election_list=lists[0], program=lipsum
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
candidatures = Candidature.objects.bulk_create(candidatures)
|
|
||||||
|
|
||||||
skia, sli_vp, krophil, sli_treso = candidatures[4:] # candidates of finished
|
|
||||||
votes = Vote.objects.bulk_create(
|
|
||||||
[
|
|
||||||
*[Vote(role=skia.role) for _ in range(6)],
|
|
||||||
*[Vote(role=sli_vp.role) for _ in range(8)],
|
|
||||||
*[Vote(role=krophil.role) for _ in range(9)],
|
|
||||||
]
|
|
||||||
)
|
|
||||||
skia.votes.set(votes[:6])
|
|
||||||
sli_vp.votes.set(votes[6:14])
|
|
||||||
krophil.votes.set(votes[14:20])
|
|
||||||
sli_treso.votes.set(votes[20:23])
|
|
||||||
finished.voters.set(list(User.objects.all()[:10]))
|
|
||||||
|
|||||||
@@ -1,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))
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 = {
|
||||||
|
|||||||
@@ -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 on the eboutic once the latter is reached."
|
|
||||||
),
|
|
||||||
null=True,
|
|
||||||
verbose_name="clic limit",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.RemoveField(model_name="counter", name="token"),
|
|
||||||
]
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Generated by Django 5.2.14 on 2026-06-02 10:45
|
|
||||||
|
|
||||||
import django_countries.fields
|
|
||||||
import phonenumber_field.modelfields
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [("counter", "0040_product_clic_limit")]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="billinginfo",
|
|
||||||
name="country",
|
|
||||||
field=django_countries.fields.CountryField(
|
|
||||||
max_length=2, verbose_name="Country"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="billinginfo",
|
|
||||||
name="phone_number",
|
|
||||||
field=phonenumber_field.modelfields.PhoneNumberField(
|
|
||||||
max_length=128, region=None, verbose_name="Phone number"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
+25
-51
@@ -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()
|
||||||
@@ -228,8 +230,15 @@ class BillingInfo(models.Model):
|
|||||||
address_2 = models.CharField(_("Address 2"), max_length=50, blank=True, null=True)
|
address_2 = models.CharField(_("Address 2"), max_length=50, blank=True, null=True)
|
||||||
zip_code = models.CharField(_("Zip code"), max_length=16) # code postal
|
zip_code = models.CharField(_("Zip code"), max_length=16) # code postal
|
||||||
city = models.CharField(_("City"), max_length=50)
|
city = models.CharField(_("City"), max_length=50)
|
||||||
country = CountryField(_("Country"))
|
country = CountryField(blank_label=_("Country"))
|
||||||
phone_number = PhoneNumberField(_("Phone number"))
|
|
||||||
|
# This table was created during the A22 semester.
|
||||||
|
# However, later on, CA asked for the phone number to be added to the billing info.
|
||||||
|
# As the table was already created, this new field had to be nullable,
|
||||||
|
# even tough it is required by the bank and shouldn't be null.
|
||||||
|
# If one day there is no null phone number remaining,
|
||||||
|
# please make the field non-nullable.
|
||||||
|
phone_number = PhoneNumberField(_("Phone number"), null=True, blank=False)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.first_name} {self.last_name}"
|
return f"{self.first_name} {self.last_name}"
|
||||||
@@ -344,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."""
|
||||||
|
|
||||||
@@ -395,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,
|
||||||
@@ -412,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")
|
||||||
|
|
||||||
@@ -612,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()
|
||||||
|
|
||||||
@@ -764,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
|
||||||
)
|
)
|
||||||
@@ -773,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
@@ -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();
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,23 +110,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="basket-row">
|
<button @click.prevent="addToBasket(item.product.price.id, -1)">-</button>
|
||||||
<div>
|
<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>
|
||||||
<span class="quantity" x-text="item.quantity"></span>
|
|
||||||
<button @click.prevent="addToBasket(item.product.price.id, 1)">+</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="product-name" x-text="item.product.name"></span>
|
<span x-text="item.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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
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"
|
||||||
|
|||||||
@@ -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>
|
<div>
|
||||||
|
<h3>{% trans %}Barman: {% endtrans %}</h3>
|
||||||
{% if barmen_here %}
|
|
||||||
<div class="row gap-2x">
|
|
||||||
<div>
|
|
||||||
<h4>{% trans %}On this device{% endtrans %}</h4>
|
|
||||||
{% for b in barmen_here %}
|
|
||||||
<p>{{ barman_logout_link(b) }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4>{% trans %}Elsewhere{% endtrans %}</h4>
|
|
||||||
{% if barmen_here|length == barmen|length %}
|
|
||||||
{# all logged barmen are logged in this session #}
|
|
||||||
<p><em>{% trans %}No barman logged elsewhere{% endtrans %}</em></p>
|
|
||||||
{% else %}
|
|
||||||
{% for b in barmen %}
|
|
||||||
{%- if b not in barmen_here -%}
|
|
||||||
<p>{{ barman_logout_link(b) }}</p>
|
|
||||||
{%- endif -%}
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
{% 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>
|
||||||
|
|||||||
+55
-119
@@ -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'</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' 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
|
||||||
)
|
)
|
||||||
@@ -929,17 +866,16 @@ class TestCounterLogout:
|
|||||||
reverse("counter:logout", kwargs={"counter_id": permanence.counter_id}),
|
reverse("counter:logout", kwargs={"counter_id": permanence.counter_id}),
|
||||||
data={"user_id": permanence.user_id},
|
data={"user_id": permanence.user_id},
|
||||||
)
|
)
|
||||||
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()
|
),
|
||||||
assert permanence.end == permanence.activity
|
)
|
||||||
assert permanence.user not in res.wsgi_request.barmen
|
permanence.refresh_from_db()
|
||||||
|
assert permanence.end == now()
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
+53
-98
@@ -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 super().get_fragment_context_data() | {"login_fragment": login_fragment}
|
return HttpResponseRedirect(
|
||||||
|
reverse_lazy(
|
||||||
|
"counter:details",
|
||||||
|
args=self.args,
|
||||||
|
kwargs={"counter_id": self.object.id},
|
||||||
|
)
|
||||||
|
+ "?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."""
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
::: api.schemas
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
::: api.views
|
||||||
@@ -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.
|
||||||
@@ -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 :
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
|
||||||
+37
-36
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hmac
|
import hmac
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
from typing import Self
|
from typing import Self
|
||||||
|
|
||||||
from dict2xml import dict2xml
|
from dict2xml import dict2xml
|
||||||
@@ -23,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
|
||||||
@@ -39,6 +39,30 @@ from counter.models import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BillingInfoState(Enum):
|
||||||
|
VALID = 1
|
||||||
|
EMPTY = 2
|
||||||
|
MISSING_PHONE_NUMBER = 3
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_model(cls, info: BillingInfo | None) -> BillingInfoState:
|
||||||
|
if info is None:
|
||||||
|
return cls.EMPTY
|
||||||
|
for attr in [
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"address_1",
|
||||||
|
"zip_code",
|
||||||
|
"city",
|
||||||
|
"country",
|
||||||
|
]:
|
||||||
|
if getattr(info, attr) == "":
|
||||||
|
return cls.EMPTY
|
||||||
|
if info.phone_number is None:
|
||||||
|
return cls.MISSING_PHONE_NUMBER
|
||||||
|
return cls.VALID
|
||||||
|
|
||||||
|
|
||||||
class Basket(models.Model):
|
class Basket(models.Model):
|
||||||
"""Basket is built when the user connects to an eboutic page."""
|
"""Basket is built when the user connects to an eboutic page."""
|
||||||
|
|
||||||
@@ -71,19 +95,6 @@ class Basket(models.Model):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
|
||||||
def is_expired(self) -> bool:
|
|
||||||
"""Return True if this basket is expired.
|
|
||||||
|
|
||||||
An expired basket can no longer be used tp pay with sith account
|
|
||||||
or to start an etransaction.
|
|
||||||
|
|
||||||
Warnings:
|
|
||||||
Users have an additional time if they pay with an etransaction,
|
|
||||||
so an expired basket may be purchased after its expiration in that case.
|
|
||||||
"""
|
|
||||||
return (self.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT) <= now()
|
|
||||||
|
|
||||||
def generate_sales(
|
def generate_sales(
|
||||||
self, counter, seller: User, payment_method: Selling.PaymentMethod
|
self, counter, seller: User, payment_method: Selling.PaymentMethod
|
||||||
):
|
):
|
||||||
@@ -122,22 +133,15 @@ class Basket(models.Model):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def get_e_transaction_data(self) -> list[tuple[str, str]]:
|
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 not hasattr(user.customer, "billing_infos"):
|
if (
|
||||||
|
not hasattr(user.customer, "billing_infos")
|
||||||
|
or BillingInfoState.from_model(user.customer.billing_infos)
|
||||||
|
!= BillingInfoState.VALID
|
||||||
|
):
|
||||||
raise BillingInfo.DoesNotExist
|
raise BillingInfo.DoesNotExist
|
||||||
cart = {
|
cart = {
|
||||||
"shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}}
|
"shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}}
|
||||||
@@ -151,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"),
|
||||||
@@ -219,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(
|
||||||
@@ -237,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,76 +1,22 @@
|
|||||||
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(
|
Alpine.data("etransaction", (initialData, basketId: number) => ({
|
||||||
"etransaction",
|
data: initialData,
|
||||||
(initialData: Record<string, string>, basket: Basket) => ({
|
isCbAvailable: Object.keys(initialData).length > 0,
|
||||||
data: initialData,
|
|
||||||
isCbAvailable: Object.keys(initialData).length > 0,
|
|
||||||
isSithAvailable: true,
|
|
||||||
|
|
||||||
init() {
|
async fill() {
|
||||||
const now = new Date();
|
this.isCbAvailable = false;
|
||||||
const timeout = basket.timeout.getTime() - now.getTime();
|
const res = await etransactioninfoFetchEtransactionData({
|
||||||
if (timeout > 0) {
|
path: {
|
||||||
// if not going inside this condition, it means that
|
|
||||||
// basket was already outdated at initial page load,
|
|
||||||
// in which case disabling buttons and displaying
|
|
||||||
// error message has been done at rendering time
|
|
||||||
setTimeout(() => this.timeoutBasket(), timeout);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make this basket into a timeout state.
|
|
||||||
* All submission inputs are disabled, and an error message is displayed.
|
|
||||||
*/
|
|
||||||
timeoutBasket() {
|
|
||||||
this.isCbAvailable = false;
|
|
||||||
this.isSithAvailable = false;
|
|
||||||
const message = gettext("Basket expired");
|
|
||||||
|
|
||||||
const existingNotif: Notification | undefined = this.$notifications
|
|
||||||
.getAll()
|
|
||||||
.find(
|
|
||||||
(n: Notification) =>
|
|
||||||
n.tag === NotificationLevel.Error && n.text === message,
|
|
||||||
);
|
|
||||||
if (existingNotif === undefined) {
|
|
||||||
this.$notifications.error(message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh the data used for etransaction.
|
|
||||||
*
|
|
||||||
* Note: if this is called while the basket is expired, it will be a no-op
|
|
||||||
*/
|
|
||||||
async fill() {
|
|
||||||
if (new Date() > basket.timeout) {
|
|
||||||
// refresh etransaction data only if the basket is still valid.
|
|
||||||
this.timeoutBasket();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.isCbAvailable = false;
|
|
||||||
const res = await etransactioninfoFetchEtransactionData({
|
|
||||||
// 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) {
|
});
|
||||||
this.data = res.data as Record<string, string>;
|
if (res.response.ok) {
|
||||||
this.isCbAvailable = true;
|
this.data = res.data;
|
||||||
} else if (res.response.status === 410) {
|
this.isCbAvailable = true;
|
||||||
// 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,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,10 +21,9 @@
|
|||||||
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 }}
|
{{ form.as_p() }}
|
||||||
<br>
|
<br>
|
||||||
<input
|
<input
|
||||||
type="submit" class="btn btn-blue clickable"
|
type="submit" class="btn btn-blue clickable"
|
||||||
|
|||||||
@@ -16,20 +16,17 @@
|
|||||||
<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>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Article</td>
|
<td>Article</td>
|
||||||
<td>{% trans %}Quantity{% endtrans %}</td>
|
<td>Quantity</td>
|
||||||
<td>{% trans %}Unit price{% endtrans %}</td>
|
<td>Unit price</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -63,41 +60,10 @@
|
|||||||
<div @htmx:after-request="fill">
|
<div @htmx:after-request="fill">
|
||||||
{{ billing_infos_form }}
|
{{ billing_infos_form }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% include "core/base/notifications.jinja" %}
|
||||||
{% include "core/base/notifications.jinja" %}
|
|
||||||
{% if settings.SITH_EBOUTIC_CB_ENABLED or (basket.total <= user.account_balance and not basket.contains_refilling_item) %}
|
|
||||||
{# don't display the cgv form if no payment mean is available #}
|
|
||||||
<form id="cgv-form" x-ref="cgvForm">
|
|
||||||
{# In order to have one CGV button for both payment means,
|
|
||||||
we have a third dummy form, containing only the cgv button,
|
|
||||||
which validation is triggered when one of the two other forms is submitted.
|
|
||||||
If the validation of this form fails, the submit event will be cancelled. #}
|
|
||||||
<div class="form-group">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="cgv-checkbox"
|
|
||||||
name="cgv"
|
|
||||||
required
|
|
||||||
{% if basket.is_expired %}
|
|
||||||
disabled="disabled"
|
|
||||||
{% else %}
|
|
||||||
:disabled="!isCbAvailable && !isSithAvailable"
|
|
||||||
{% endif %}
|
|
||||||
>
|
|
||||||
<label for="cgv-checkbox">
|
|
||||||
{% trans trimmed %}I have read and I accept{% endtrans %}
|
|
||||||
<a href="{{ url('core:page', 'cgv') }}">{% trans %}the general terms and conditions{% endtrans%}</a>
|
|
||||||
{%trans%}of the student association of the UTBM{% endtrans %}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
{% if settings.SITH_EBOUTIC_CB_ENABLED %}
|
|
||||||
<form
|
<form
|
||||||
method="post"
|
method="post"
|
||||||
id="bank-payment-form"
|
|
||||||
action="{{ settings.SITH_EBOUTIC_ET_URL }}"
|
action="{{ settings.SITH_EBOUTIC_ET_URL }}"
|
||||||
@submit="if (!$refs.cgvForm.reportValidity()) $event.preventDefault()"
|
|
||||||
>
|
>
|
||||||
<template x-for="[key, value] in Object.entries(data)" :key="key">
|
<template x-for="[key, value] in Object.entries(data)" :key="key">
|
||||||
<input type="hidden" :name="key" :value="value">
|
<input type="hidden" :name="key" :value="value">
|
||||||
@@ -106,11 +72,7 @@
|
|||||||
x-cloak
|
x-cloak
|
||||||
type="submit"
|
type="submit"
|
||||||
id="bank-submit-button"
|
id="bank-submit-button"
|
||||||
{% if basket.is_expired %}
|
:disabled="!isCbAvailable"
|
||||||
disabled="disabled"
|
|
||||||
{% else %}
|
|
||||||
: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 %}"
|
||||||
/>
|
/>
|
||||||
@@ -129,23 +91,9 @@
|
|||||||
{% elif basket.total > user.account_balance %}
|
{% elif basket.total > user.account_balance %}
|
||||||
<p>{% trans %}AE account payment disabled because you do not have enough money remaining.{% endtrans %}</p>
|
<p>{% trans %}AE account payment disabled because you do not have enough money remaining.{% endtrans %}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form
|
<form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form">
|
||||||
method="post"
|
|
||||||
action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}"
|
|
||||||
id="sith-payment-form"
|
|
||||||
@submit="if (!$refs.cgvForm.reportValidity()) $event.preventDefault()"
|
|
||||||
>
|
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 not Basket.objects.exists()
|
|
||||||
|
|
||||||
def test_sold_out_product(self):
|
|
||||||
sold_out = product_recipe.make(
|
|
||||||
clic_limit=3, counters=[self.eboutic], product_type=baker.make(ProductType)
|
|
||||||
)
|
|
||||||
price = price_recipe.make(product=sold_out, groups=[self.group_cotiz], amount=0)
|
|
||||||
sale_recipe.make(
|
|
||||||
product=sold_out,
|
|
||||||
customer=self.subscriber.customer,
|
|
||||||
unit_price=0,
|
|
||||||
quantity=1,
|
|
||||||
)
|
|
||||||
baker.make(
|
|
||||||
eboutic.models.BasketItem,
|
|
||||||
basket=baker.make(Basket),
|
|
||||||
product=sold_out,
|
|
||||||
quantity=2,
|
|
||||||
)
|
|
||||||
self.client.force_login(self.subscriber)
|
|
||||||
response = self.submit_basket([BasketItem(price.id, 1)])
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert Basket.objects.count() == 1
|
assert Basket.objects.first() is None
|
||||||
with freezegun.freeze_time(
|
|
||||||
now()
|
response = self.submit_basket([BasketItem(self.cotiz.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
|
response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)])
|
||||||
# product available again.
|
assert response.status_code == 200
|
||||||
response = self.submit_basket([BasketItem(price.id, 1)])
|
assert Basket.objects.first() is None
|
||||||
assertRedirects(
|
|
||||||
response,
|
self.client.force_login(self.new_customer)
|
||||||
reverse("eboutic:checkout", kwargs={"basket_id": Basket.objects.last().id}),
|
response = self.submit_basket([BasketItem(self.cotiz.id, 1)])
|
||||||
)
|
assert response.status_code == 200
|
||||||
assert Basket.objects.count() == 2
|
assert Basket.objects.first() is None
|
||||||
|
|
||||||
|
response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)])
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert Basket.objects.first() is None
|
||||||
|
|
||||||
def test_create_basket(self):
|
def test_create_basket(self):
|
||||||
self.client.force_login(self.new_customer)
|
self.client.force_login(self.new_customer)
|
||||||
|
|||||||
@@ -37,9 +37,12 @@ class TestBillingInfo:
|
|||||||
|
|
||||||
def test_edit_infos(self, client: Client, payload: dict[str, str]):
|
def test_edit_infos(self, client: Client, payload: dict[str, str]):
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
baker.make(BillingInfo, customer=user.customer, phone_number="06 01 02 03 04")
|
baker.make(BillingInfo, customer=user.customer)
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
response = client.post(reverse("eboutic:billing_infos"), payload)
|
response = client.post(
|
||||||
|
reverse("eboutic:billing_infos"),
|
||||||
|
payload,
|
||||||
|
)
|
||||||
user.refresh_from_db()
|
user.refresh_from_db()
|
||||||
infos = BillingInfo.objects.get(customer__user=user)
|
infos = BillingInfo.objects.get(customer__user=user)
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|||||||
@@ -3,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
@@ -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
|
||||||
|
|||||||
+32
-43
@@ -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
|
||||||
@@ -58,7 +56,7 @@ from counter.models import (
|
|||||||
Selling,
|
Selling,
|
||||||
get_eboutic,
|
get_eboutic,
|
||||||
)
|
)
|
||||||
from eboutic.models import Basket, BasketItem, Invoice, InvoiceItem
|
from eboutic.models import Basket, BasketItem, BillingInfoState, Invoice, InvoiceItem
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
||||||
@@ -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
|
||||||
@@ -187,7 +178,7 @@ def payment_result(request, result: str) -> HttpResponse:
|
|||||||
class BillingInfoFormFragment(
|
class BillingInfoFormFragment(
|
||||||
LoginRequiredMixin, FragmentMixin, SuccessMessageMixin, UpdateView
|
LoginRequiredMixin, FragmentMixin, SuccessMessageMixin, UpdateView
|
||||||
):
|
):
|
||||||
"""Update or create billing info"""
|
"""Update billing info"""
|
||||||
|
|
||||||
model = BillingInfo
|
model = BillingInfo
|
||||||
form_class = BillingInfoForm
|
form_class = BillingInfoForm
|
||||||
@@ -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:
|
||||||
@@ -218,15 +211,26 @@ class BillingInfoFormFragment(
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
kwargs = super().get_context_data(**kwargs)
|
kwargs = super().get_context_data(**kwargs)
|
||||||
|
kwargs["billing_infos_state"] = BillingInfoState.from_model(self.object)
|
||||||
kwargs["action"] = reverse("eboutic:billing_infos")
|
kwargs["action"] = reverse("eboutic:billing_infos")
|
||||||
if not self.object:
|
match BillingInfoState.from_model(self.object):
|
||||||
messages.warning(
|
case BillingInfoState.EMPTY:
|
||||||
self.request,
|
messages.warning(
|
||||||
_(
|
self.request,
|
||||||
"You must fill your billing infos "
|
_(
|
||||||
"if you want to pay with your credit card"
|
"You must fill your billing infos if you want to pay with your credit card"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
case BillingInfoState.MISSING_PHONE_NUMBER:
|
||||||
|
messages.warning(
|
||||||
|
self.request,
|
||||||
|
_(
|
||||||
|
"The Crédit Agricole changed its policy related to the billing "
|
||||||
|
+ "information that must be provided in order to pay with a credit card. "
|
||||||
|
+ "If you want to pay with your credit card, you must add a phone number "
|
||||||
|
+ "to the data you already provided.",
|
||||||
|
),
|
||||||
|
)
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
def get_success_url(self, **kwargs):
|
def get_success_url(self, **kwargs):
|
||||||
@@ -251,19 +255,10 @@ class EbouticCheckout(CanViewMixin, UseFragmentsMixin, DetailView):
|
|||||||
kwargs["customer_amount"] = None
|
kwargs["customer_amount"] = None
|
||||||
kwargs["billing_infos"] = {}
|
kwargs["billing_infos"] = {}
|
||||||
|
|
||||||
if self.object.is_expired:
|
with contextlib.suppress(BillingInfo.DoesNotExist):
|
||||||
messages.error(self.request, _("Basket expired"))
|
kwargs["billing_infos"] = json.dumps(
|
||||||
else:
|
dict(self.object.get_e_transaction_data())
|
||||||
timeout = self.object.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT
|
|
||||||
messages.warning(
|
|
||||||
self.request,
|
|
||||||
_("Basket available until %(until)s")
|
|
||||||
% {"until": localize(localtime(timeout).time())},
|
|
||||||
)
|
)
|
||||||
with contextlib.suppress(BillingInfo.DoesNotExist):
|
|
||||||
kwargs["billing_infos"] = json.dumps(
|
|
||||||
dict(self.object.get_e_transaction_data())
|
|
||||||
)
|
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
@@ -273,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()
|
||||||
@@ -298,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")
|
||||||
|
|
||||||
|
|||||||
+35
-139
@@ -1,18 +1,6 @@
|
|||||||
from datetime import timedelta
|
|
||||||
from itertools import groupby, islice
|
|
||||||
from operator import attrgetter
|
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
|
||||||
from django.db import transaction
|
|
||||||
from django.db.models import Count
|
|
||||||
from django.forms.models import ModelChoiceIterator, ModelChoiceIteratorValue
|
|
||||||
from django.utils.timezone import localdate, localtime
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from club.forms import ClubRoleChoiceField
|
|
||||||
from club.models import ClubRole, Membership
|
|
||||||
from club.widgets.ajax_select import AutoCompleteSelectMultipleClub
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from core.views.forms import SelectDateTime
|
from core.views.forms import SelectDateTime
|
||||||
from core.views.widgets.ajax_select import (
|
from core.views.widgets.ajax_select import (
|
||||||
@@ -91,20 +79,27 @@ class VoteForm(forms.Form):
|
|||||||
class RoleForm(forms.ModelForm):
|
class RoleForm(forms.ModelForm):
|
||||||
"""Form for creating a role."""
|
"""Form for creating a role."""
|
||||||
|
|
||||||
required_css_class = "required"
|
|
||||||
error_css_class = "error"
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Role
|
model = Role
|
||||||
fields = ["club_role", "title", "description", "max_choice"]
|
fields = ["title", "election", "description", "max_choice"]
|
||||||
field_classes = {"club_role": ClubRoleChoiceField}
|
widgets = {"election": AutoCompleteSelect}
|
||||||
|
|
||||||
def __init__(self, *args, election: Election, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
election_id = kwargs.pop("election_id", None)
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.instance.election = election
|
if election_id:
|
||||||
self.fields["club_role"].queryset = ClubRole.objects.filter(
|
self.fields["election"].queryset = Election.objects.filter(
|
||||||
is_board=True, club__in=election.clubs.all()
|
id=election_id
|
||||||
)
|
).all()
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
title = cleaned_data.get("title")
|
||||||
|
election = cleaned_data.get("election")
|
||||||
|
if Role.objects.filter(title=title, election=election).exists():
|
||||||
|
raise forms.ValidationError(
|
||||||
|
_("This role already exists for this election"), code="invalid"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ElectionListForm(forms.ModelForm):
|
class ElectionListForm(forms.ModelForm):
|
||||||
@@ -113,21 +108,21 @@ class ElectionListForm(forms.ModelForm):
|
|||||||
fields = ("title", "election")
|
fields = ("title", "election")
|
||||||
widgets = {"election": AutoCompleteSelect}
|
widgets = {"election": AutoCompleteSelect}
|
||||||
|
|
||||||
def __init__(self, *args, election: Election, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
election_id = kwargs.pop("election_id", None)
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.instance.election = election
|
if election_id:
|
||||||
|
self.fields["election"].queryset = Election.objects.filter(
|
||||||
|
id=election_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
|
||||||
class ElectionForm(forms.ModelForm):
|
class ElectionForm(forms.ModelForm):
|
||||||
required_css_class = "required"
|
|
||||||
error_css_class = "error"
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Election
|
model = Election
|
||||||
fields = [
|
fields = [
|
||||||
"title",
|
"title",
|
||||||
"description",
|
"description",
|
||||||
"clubs",
|
|
||||||
"archived",
|
"archived",
|
||||||
"start_candidature",
|
"start_candidature",
|
||||||
"end_candidature",
|
"end_candidature",
|
||||||
@@ -139,120 +134,21 @@ class ElectionForm(forms.ModelForm):
|
|||||||
"candidature_groups",
|
"candidature_groups",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
"clubs": AutoCompleteSelectMultipleClub,
|
|
||||||
"edit_groups": AutoCompleteSelectMultipleGroup,
|
"edit_groups": AutoCompleteSelectMultipleGroup,
|
||||||
"view_groups": AutoCompleteSelectMultipleGroup,
|
"view_groups": AutoCompleteSelectMultipleGroup,
|
||||||
"vote_groups": AutoCompleteSelectMultipleGroup,
|
"vote_groups": AutoCompleteSelectMultipleGroup,
|
||||||
"candidature_groups": AutoCompleteSelectMultipleGroup,
|
"candidature_groups": AutoCompleteSelectMultipleGroup,
|
||||||
"start_date": SelectDateTime,
|
|
||||||
"end_date": SelectDateTime,
|
|
||||||
"start_candidature": SelectDateTime,
|
|
||||||
"end_candidature": SelectDateTime,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
start_date = forms.DateTimeField(
|
||||||
class ElectionCreateForm(ElectionForm):
|
label=_("Start date"), widget=SelectDateTime, required=True
|
||||||
"""ElectionForm, but specifically for creation."""
|
)
|
||||||
|
end_date = forms.DateTimeField(
|
||||||
def __init__(self, *args, initial: dict | None = None, **kwargs):
|
label=_("End date"), widget=SelectDateTime, required=True
|
||||||
# propose sound default timestamps :
|
)
|
||||||
# start of candidatures at tomorrow 00h01, start of votes a week later.
|
start_candidature = forms.DateTimeField(
|
||||||
start = localtime().replace(hour=0, minute=1, second=0) + timedelta(days=1)
|
label=_("Start candidature"), widget=SelectDateTime, required=True
|
||||||
default_initial = {
|
)
|
||||||
"start_candidature": start,
|
end_candidature = forms.DateTimeField(
|
||||||
"end_candidature": start + timedelta(days=7, minutes=-2), # 23h59
|
label=_("End candidature"), widget=SelectDateTime, required=True
|
||||||
"start_date": start + timedelta(days=7), # 00h01
|
)
|
||||||
"end_date": start + timedelta(days=14, minutes=-2), # 23h59
|
|
||||||
"view_groups": [settings.SITH_GROUP_PUBLIC_ID],
|
|
||||||
"vote_groups": [settings.SITH_GROUP_SUBSCRIBERS_ID],
|
|
||||||
"candidature_groups": [settings.SITH_GROUP_SUBSCRIBERS_ID],
|
|
||||||
}
|
|
||||||
if initial:
|
|
||||||
default_initial.update(initial)
|
|
||||||
super().__init__(*args, initial=default_initial, **kwargs)
|
|
||||||
|
|
||||||
def save(self, commit=True): # noqa: FBT002
|
|
||||||
instance = super().save(commit=commit)
|
|
||||||
if commit:
|
|
||||||
ElectionList.objects.create(title="Candidat⸱e libre", election=instance)
|
|
||||||
return instance
|
|
||||||
|
|
||||||
|
|
||||||
class ElectionWinnerChoiceIterator(ModelChoiceIterator):
|
|
||||||
"""Iterate over the candidates that gathered enough votes"""
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
# for each role, yield only the N first candidates,
|
|
||||||
# where N is the election role max_choice
|
|
||||||
qs = (
|
|
||||||
self.queryset.annotate(nb_votes=Count("votes"))
|
|
||||||
.order_by("role__order", "-nb_votes")
|
|
||||||
.select_related("role", "user", "role__club_role", "role__club_role__club")
|
|
||||||
)
|
|
||||||
yield from (
|
|
||||||
(
|
|
||||||
f"{role.title} \u2013 {role.club_role.club.name}",
|
|
||||||
[self.choice(cand) for cand in islice(candidates, role.max_choice)],
|
|
||||||
)
|
|
||||||
for role, candidates in groupby(qs, key=attrgetter("role"))
|
|
||||||
)
|
|
||||||
|
|
||||||
def choice(self, obj: Candidature):
|
|
||||||
return (
|
|
||||||
ModelChoiceIteratorValue(self.field.prepare_value(obj), obj),
|
|
||||||
obj.user.get_full_name(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ElectionWinnerChoiceField(forms.ModelMultipleChoiceField):
|
|
||||||
"""Custom `ModelChoiceField` for `[ClubRole][club.models.ClubRole]`.
|
|
||||||
|
|
||||||
If only one club is involved, behave like the base `ModelChoiceField`.
|
|
||||||
If dealing with the roles of multiple clubs, group the roles
|
|
||||||
into a different `optgroup` for each club.
|
|
||||||
"""
|
|
||||||
|
|
||||||
iterator = ElectionWinnerChoiceIterator
|
|
||||||
widget = forms.CheckboxSelectMultiple
|
|
||||||
|
|
||||||
|
|
||||||
class ApplyElectionResultForm(forms.Form):
|
|
||||||
"""Form to select winners of an election, and automatically apply the results."""
|
|
||||||
|
|
||||||
candidates = ElectionWinnerChoiceField(Candidature.objects.none())
|
|
||||||
|
|
||||||
def __init__(self, *args, election: Election, **kwargs):
|
|
||||||
self.election = election
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
qs = Candidature.objects.filter(
|
|
||||||
role__election=election, role__club_role__isnull=False
|
|
||||||
)
|
|
||||||
# pass all candidates to the ModelChoiceField ;
|
|
||||||
# its inner choice iterator will take care of filtering only the winners.
|
|
||||||
self.fields["candidates"].queryset = qs
|
|
||||||
# By default, mark every candidate as selected.
|
|
||||||
# Election results are usually completely validated during the AG,
|
|
||||||
# so it makes more sense UX-wise to eventually unselect a candidate
|
|
||||||
# than to select everyone.
|
|
||||||
self.fields["candidates"].initial = qs.values_list("id", flat=True)
|
|
||||||
|
|
||||||
def save(self):
|
|
||||||
if self.errors:
|
|
||||||
return
|
|
||||||
candidates: list[Candidature] = list(self.cleaned_data["candidates"])
|
|
||||||
with transaction.atomic():
|
|
||||||
Membership.objects.filter(
|
|
||||||
role__in=[c.role.club_role for c in candidates],
|
|
||||||
end_date=None,
|
|
||||||
start_date__lt=self.election.end_date,
|
|
||||||
).update(end_date=localdate())
|
|
||||||
memberships = [
|
|
||||||
Membership(
|
|
||||||
user_id=c.user_id,
|
|
||||||
club_id=c.role.club_role.club_id,
|
|
||||||
role=c.role.club_role,
|
|
||||||
)
|
|
||||||
for c in candidates
|
|
||||||
]
|
|
||||||
Membership.objects.bulk_create(memberships)
|
|
||||||
Membership._add_club_groups(memberships)
|
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
# Generated by Django 5.2.14 on 2026-05-30 20:00
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("club", "0017_linktype_clublink"),
|
|
||||||
("election", "0005_alter_candidature_program_alter_candidature_user"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="election",
|
|
||||||
name="clubs",
|
|
||||||
field=models.ManyToManyField(
|
|
||||||
help_text="The club(s) this election is held for.",
|
|
||||||
related_name="elections",
|
|
||||||
to="club.club",
|
|
||||||
verbose_name="clubs",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="role",
|
|
||||||
name="club_role",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
blank=True,
|
|
||||||
help_text=(
|
|
||||||
"A club role. Filling this will allow automatic "
|
|
||||||
"completion of title and description, "
|
|
||||||
"and automatic assignation after the elections."
|
|
||||||
),
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="election_roles",
|
|
||||||
to="club.clubrole",
|
|
||||||
verbose_name="club role",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="role",
|
|
||||||
name="description",
|
|
||||||
field=models.TextField(blank=True, default="", verbose_name="description"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="role",
|
|
||||||
name="max_choice",
|
|
||||||
field=models.PositiveSmallIntegerField(
|
|
||||||
default=1, verbose_name="max choice"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name="role",
|
|
||||||
constraint=models.UniqueConstraint(
|
|
||||||
fields=("title", "election"),
|
|
||||||
name="title_election_unique_constraint",
|
|
||||||
violation_error_code="invalid",
|
|
||||||
violation_error_message="This role already exists for this election",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
+5
-46
@@ -5,7 +5,6 @@ from django.utils.functional import cached_property
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from ordered_model.models import OrderedModel
|
from ordered_model.models import OrderedModel
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
|
||||||
from core.models import Group, User
|
from core.models import Group, User
|
||||||
|
|
||||||
|
|
||||||
@@ -14,12 +13,6 @@ class Election(models.Model):
|
|||||||
|
|
||||||
title = models.CharField(_("title"), max_length=255)
|
title = models.CharField(_("title"), max_length=255)
|
||||||
description = models.TextField(_("description"), null=True, blank=True)
|
description = models.TextField(_("description"), null=True, blank=True)
|
||||||
clubs = models.ManyToManyField(
|
|
||||||
Club,
|
|
||||||
related_name="elections",
|
|
||||||
verbose_name=_("clubs"),
|
|
||||||
help_text=_("The club(s) this election is held for."),
|
|
||||||
)
|
|
||||||
start_candidature = models.DateTimeField(_("start candidature"), blank=False)
|
start_candidature = models.DateTimeField(_("start candidature"), blank=False)
|
||||||
end_candidature = models.DateTimeField(_("end candidature"), blank=False)
|
end_candidature = models.DateTimeField(_("end candidature"), blank=False)
|
||||||
start_date = models.DateTimeField(_("start date"), blank=False)
|
start_date = models.DateTimeField(_("start date"), blank=False)
|
||||||
@@ -101,18 +94,9 @@ class Election(models.Model):
|
|||||||
results[role.title] = role.results(total_vote)
|
results[role.title] = role.results(total_vote)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def results_applied(self) -> bool:
|
|
||||||
"""Returns True if one or more roles of this election have been applied."""
|
|
||||||
return Membership.objects.filter(
|
|
||||||
role__election_roles__election=self,
|
|
||||||
end_date=None,
|
|
||||||
start_date__gte=self.end_date,
|
|
||||||
).exists()
|
|
||||||
|
|
||||||
|
|
||||||
class Role(OrderedModel):
|
class Role(OrderedModel):
|
||||||
"""This class allows to create a new role available for a candidature."""
|
"""This class allows to create a new role avaliable for a candidature."""
|
||||||
|
|
||||||
election = models.ForeignKey(
|
election = models.ForeignKey(
|
||||||
Election,
|
Election,
|
||||||
@@ -121,42 +105,17 @@ class Role(OrderedModel):
|
|||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
)
|
)
|
||||||
title = models.CharField(_("title"), max_length=255)
|
title = models.CharField(_("title"), max_length=255)
|
||||||
description = models.TextField(_("description"), default="", blank=True)
|
description = models.TextField(_("description"), null=True, blank=True)
|
||||||
max_choice = models.PositiveSmallIntegerField(_("max choice"), default=1)
|
max_choice = models.IntegerField(_("max choice"), default=1)
|
||||||
club_role = models.ForeignKey(
|
|
||||||
ClubRole,
|
|
||||||
related_name="election_roles",
|
|
||||||
verbose_name=_("club role"),
|
|
||||||
help_text=_(
|
|
||||||
"A club role. Filling this will allow automatic "
|
|
||||||
"completion of title and description, "
|
|
||||||
"and automatic assignation after the elections."
|
|
||||||
),
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
order_with_respect_to = "election"
|
|
||||||
|
|
||||||
class Meta(OrderedModel.Meta):
|
|
||||||
constraints = [
|
|
||||||
models.UniqueConstraint(
|
|
||||||
fields=["title", "election"],
|
|
||||||
name="title_election_unique_constraint",
|
|
||||||
violation_error_message=_("This role already exists for this election"),
|
|
||||||
violation_error_code="invalid",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.title} - {self.election.title}"
|
return f"{self.title} - {self.election.title}"
|
||||||
|
|
||||||
def results(self, total_vote: int) -> dict[str, dict[str, int | float]]:
|
def results(self, total_vote: int) -> dict[str, dict[str, int | float]]:
|
||||||
if total_vote == 0:
|
if total_vote == 0:
|
||||||
candidates = self.candidatures.values_list("user__username", flat=True)
|
candidates = self.candidatures.values_list("user__username")
|
||||||
return {
|
return {
|
||||||
key: {"vote": 0, "percent": 0} for key in ["blank vote", *candidates]
|
key: {"vote": 0, "percent": 0} for key in ["blank_votes", *candidates]
|
||||||
}
|
}
|
||||||
total_vote *= self.max_choice
|
total_vote *= self.max_choice
|
||||||
results = {"total vote": total_vote}
|
results = {"total vote": total_vote}
|
||||||
|
|||||||
@@ -29,25 +29,13 @@
|
|||||||
{% trans %}Polls closed {% endtrans %}
|
{% trans %}Polls closed {% endtrans %}
|
||||||
{%- else %}
|
{%- else %}
|
||||||
{% trans %}Polls will open {% endtrans %}
|
{% trans %}Polls will open {% endtrans %}
|
||||||
<time datetime="{{ election.start_date }}">{{ election.start_date|localtime|date(DATETIME_FORMAT) }}</time>
|
<time datetime="{{ election.start_date }}">{{ election.start_date|localtime|date(DATETIME_FORMAT)}}</time>
|
||||||
{% trans %}at{% endtrans %}
|
{% trans %} at {% endtrans %}<time>{{ election.start_date|localtime|time(DATETIME_FORMAT)}}</time>
|
||||||
<time>{{ election.start_date|localtime|time(DATETIME_FORMAT) }}</time>
|
|
||||||
{% trans %}and will close {% endtrans %}
|
{% trans %}and will close {% endtrans %}
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
<time datetime="{{ election.end_date }}">{{ election.end_date|localtime|date(DATETIME_FORMAT) }}</time>
|
<time datetime="{{ election.end_date }}">{{ election.end_date|localtime|date(DATETIME_FORMAT)}}</time>
|
||||||
{% trans %}at{% endtrans %}
|
{% trans %} at {% endtrans %}<time>{{ election.end_date|localtime|time(DATETIME_FORMAT)}}</time>
|
||||||
<time>{{ election.end_date|localtime|time(DATETIME_FORMAT) }}</time>
|
|
||||||
</p>
|
</p>
|
||||||
{%- if election.is_vote_finished and user.can_edit(election) %}
|
|
||||||
<details class="accordion" name="apply-result">
|
|
||||||
<summary>{% trans %}Apply election result{% endtrans %}</summary>
|
|
||||||
<div
|
|
||||||
class="accordion-content aria-busy-grow"
|
|
||||||
hx-get="{{ url("election:apply_result", election_id=election.id) }}"
|
|
||||||
hx-trigger="toggle from:closest details once"
|
|
||||||
></div>
|
|
||||||
</details>
|
|
||||||
{% endif %}
|
|
||||||
{%- if user_has_voted %}
|
{%- if user_has_voted %}
|
||||||
<p class="election__elector-infos">
|
<p class="election__elector-infos">
|
||||||
{%- if election.is_vote_active %}
|
{%- if election.is_vote_active %}
|
||||||
@@ -59,27 +47,17 @@
|
|||||||
{%- endif %}
|
{%- endif %}
|
||||||
</section>
|
</section>
|
||||||
<section class="election_vote">
|
<section class="election_vote">
|
||||||
<form
|
<form action="{{ url('election:vote', election.id) }}" method="post" class="election__vote-form" name="vote-form" id="vote-form">
|
||||||
action="{{ url('election:vote', election.id) }}"
|
|
||||||
method="post"
|
|
||||||
class="election__vote-form"
|
|
||||||
name="vote-form"
|
|
||||||
id="vote-form"
|
|
||||||
>
|
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<table class="election_table">
|
<table class="election_table">
|
||||||
<thead class="lists">
|
<thead class="lists">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">
|
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">{% trans %}Blank vote{% endtrans %}</th>
|
||||||
{% trans %}Blank vote{% endtrans %}
|
|
||||||
</th>
|
|
||||||
{%- for election_list in election_lists %}
|
{%- for election_list in election_lists %}
|
||||||
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">
|
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">
|
||||||
<span>{{ election_list.title }}</span>
|
<span>{{ election_list.title }}</span>
|
||||||
{% if user.can_edit(election_list) and election.is_vote_editable -%}
|
{% if user.can_edit(election_list) and election.is_vote_editable -%}
|
||||||
<a href="{{ url('election:delete_list', list_id=election_list.id) }}">
|
<a href="{{ url('election:delete_list', list_id=election_list.id) }}"><i class="fa-regular fa-trash-can delete-action"></i></a>
|
||||||
<i class="fa-regular fa-trash-can delete-action"></i>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</th>
|
</th>
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
@@ -125,45 +103,22 @@
|
|||||||
<button disabled><i class="fa fa-arrow-down"></i></button>
|
<button disabled><i class="fa fa-arrow-down"></i></button>
|
||||||
<button disabled><i class="fa fa-caret-down"></i></button>
|
<button disabled><i class="fa fa-caret-down"></i></button>
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
<button
|
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=bottom');"><i class="fa fa-arrow-down"></i></button>
|
||||||
type="button"
|
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=down');"><i class="fa fa-caret-down"></i></button>
|
||||||
onclick="window.location.replace('?role={{ role.id }}&action=bottom');"
|
|
||||||
>
|
|
||||||
<i class="fa fa-arrow-down"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick="window.location.replace('?role={{ role.id }}&action=down');"
|
|
||||||
>
|
|
||||||
<i class="fa fa-caret-down"></i>
|
|
||||||
</button>
|
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
{%- if loop.first -%}
|
{%- if loop.first -%}
|
||||||
<button disabled><i class="fa fa-caret-up"></i></button>
|
<button disabled><i class="fa fa-caret-up"></i></button>
|
||||||
<button disabled><i class="fa fa-arrow-up"></i></button>
|
<button disabled><i class="fa fa-arrow-up"></i></button>
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
<button
|
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=up');"><i class="fa fa-caret-up"></i></button>
|
||||||
type="button"
|
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=top');"><i class="fa fa-arrow-up"></i></button>
|
||||||
onclick="window.location.replace('?role={{ role.id }}&action=up');"
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
class="fa fa-caret-up"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick="window.location.replace('?role={{ role.id }}&action=top');"
|
|
||||||
><i class="fa fa-arrow-up"></i>
|
|
||||||
</button>
|
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
</div>
|
</div>
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="role_candidates">
|
<tr class="role_candidates">
|
||||||
<td
|
<td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%">
|
||||||
class="list_per_role"
|
|
||||||
style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%"
|
|
||||||
>
|
|
||||||
{%- if role.max_choice == 1 and show_vote_buttons %}
|
{%- if role.max_choice == 1 and show_vote_buttons %}
|
||||||
<div class="radio-btn">
|
<div class="radio-btn">
|
||||||
{% set input_id = "blank_vote_" + role.id|string %}
|
{% set input_id = "blank_vote_" + role.id|string %}
|
||||||
@@ -176,46 +131,26 @@
|
|||||||
{%- if election.is_vote_finished %}
|
{%- if election.is_vote_finished %}
|
||||||
{%- set results = election_results[role.title]['blank vote'] %}
|
{%- set results = election_results[role.title]['blank vote'] %}
|
||||||
<div class="election__results">
|
<div class="election__results">
|
||||||
<strong>
|
<strong>{{ results.vote }} {% trans %}votes{% endtrans %} ({{ "%.2f" % results.percent }} %)</strong>
|
||||||
{{ results.vote }} {% trans %}votes{% endtrans %} ({{ "%.2f" % results.percent }} %)
|
|
||||||
</strong>
|
|
||||||
</div>
|
</div>
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
</td>
|
</td>
|
||||||
{%- for election_list in election_lists %}
|
{%- for election_list in election_lists %}
|
||||||
<td
|
<td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%">
|
||||||
class="list_per_role"
|
|
||||||
style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%"
|
|
||||||
>
|
|
||||||
<ul class="candidates">
|
<ul class="candidates">
|
||||||
{%- for candidature in election_list.candidatures.select_related("user", "user__profile_pict").filter(role=role) %}
|
{%- for candidature in election_list.candidatures.select_related("user", "user__profile_pict").filter(role=role) %}
|
||||||
<li class="candidate">
|
<li class="candidate">
|
||||||
{%- if show_vote_buttons %}
|
{%- if show_vote_buttons %}
|
||||||
{% set input_id = "candidature_" + candidature.id|string %}
|
{% set input_id = "candidature_" + candidature.id|string %}
|
||||||
<input
|
<input id="{{ input_id }}" type="{{ 'checkbox' if role.max_choice > 1 else 'radio' }}" {{ 'checked' if candidature.id|string in role_data else '' }} {{ 'disabled' if user_has_voted else '' }} name="{{ role.title }}" value="{{ candidature.id }}">
|
||||||
id="{{ input_id }}"
|
|
||||||
type="{{ 'checkbox' if role.max_choice > 1 else 'radio' }}"
|
|
||||||
{% if candidature.id|string in role_data %}checked{% endif %}
|
|
||||||
{% if user_has_voted %}disabled{% endif %}
|
|
||||||
name="{{ role.title }}"
|
|
||||||
value="{{ candidature.id }}"
|
|
||||||
>
|
|
||||||
<label for="{{ input_id }}">
|
<label for="{{ input_id }}">
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
<figure>
|
<figure>
|
||||||
{%- if user.can_view(candidature.user) %}
|
{%- if user.can_view(candidature.user) %}
|
||||||
{% if candidature.user.profile_pict %}
|
{% if candidature.user.profile_pict %}
|
||||||
<img
|
<img class="candidate__picture" src="{{ candidature.user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}">
|
||||||
class="candidate__picture"
|
|
||||||
src="{{ candidature.user.profile_pict.get_download_url() }}"
|
|
||||||
alt="{% trans %}Profile{% endtrans %}"
|
|
||||||
>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<img
|
<img class="candidate__picture" src="{{ static('core/img/unknown.jpg') }}" alt="{% trans %}Profile{% endtrans %}">
|
||||||
class="candidate__picture"
|
|
||||||
src="{{ static('core/img/unknown.jpg') }}"
|
|
||||||
alt="{% trans %}Profile{% endtrans %}"
|
|
||||||
>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
<figcaption class="candidate__details">
|
<figcaption class="candidate__details">
|
||||||
@@ -229,12 +164,8 @@
|
|||||||
{%- if user.can_edit(candidature) -%}
|
{%- if user.can_edit(candidature) -%}
|
||||||
{%- if election.is_vote_editable -%}
|
{%- if election.is_vote_editable -%}
|
||||||
<div class="edit_btns">
|
<div class="edit_btns">
|
||||||
<a href="{{ url('election:update_candidate', candidature_id=candidature.id) }}">
|
<a href="{{url('election:update_candidate', candidature_id=candidature.id)}}"><i class="fa-regular fa-pen-to-square edit-action"></i>️</a>
|
||||||
<i class="fa-regular fa-pen-to-square edit-action"></i>
|
<a href="{{url('election:delete_candidate', candidature_id=candidature.id)}}"><i class="fa-regular fa-trash-can delete-action"></i></a>
|
||||||
</a>
|
|
||||||
<a href="{{ url('election:delete_candidate', candidature_id=candidature.id) }}">
|
|
||||||
<i class="fa-regular fa-trash-can delete-action"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{{ super() -}}
|
{{ super() -}}
|
||||||
<style>
|
<style type="text/css">
|
||||||
small {
|
small {
|
||||||
font-size: smaller;
|
font-size: smaller;
|
||||||
}
|
}
|
||||||
@@ -20,9 +20,6 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h3>{% trans %}Current elections{% endtrans %}</h3>
|
<h3>{% trans %}Current elections{% endtrans %}</h3>
|
||||||
<a class="btn btn-blue" href="{{ url("election:create") }}">
|
|
||||||
<i class="fa fa-plus"></i>{% trans %}New election{% endtrans %}
|
|
||||||
</a>
|
|
||||||
{%- for election in object_list %}
|
{%- for election in object_list %}
|
||||||
<hr>
|
<hr>
|
||||||
<section>
|
<section>
|
||||||
@@ -35,7 +32,7 @@
|
|||||||
{% trans %} at {% endtrans %}<time>{{ election.start_candidature|localtime|time(DATETIME_FORMAT) }}</time>
|
{% trans %} at {% endtrans %}<time>{{ election.start_candidature|localtime|time(DATETIME_FORMAT) }}</time>
|
||||||
{% trans %}to{% endtrans %}
|
{% trans %}to{% endtrans %}
|
||||||
<time datetime="{{ election.end_candidature }}">{{ election.end_candidature|localtime|date(DATETIME_FORMAT) }}</time>
|
<time datetime="{{ election.end_candidature }}">{{ election.end_candidature|localtime|date(DATETIME_FORMAT) }}</time>
|
||||||
{% trans %} at {% endtrans %}<time>{{ election.end_candidature|localtime|time(DATETIME_FORMAT) }}</time>
|
{% trans %} at {% endtrans %}<time>{{ election.end_candidature|time(DATETIME_FORMAT) }}</time>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{% trans %}Polls open from{% endtrans %}
|
{% trans %}Polls open from{% endtrans %}
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
<div id="apply-election-result-fragment">
|
|
||||||
{% if not form.candidates.field.choices %}
|
|
||||||
<em>{% trans %}No result to apply{% endtrans %}</em>
|
|
||||||
<p>
|
|
||||||
{% trans trimmed %}
|
|
||||||
This may be because no role of this election
|
|
||||||
was linked to a club role.
|
|
||||||
{% endtrans %}
|
|
||||||
</p>
|
|
||||||
{% elif form.election.results_applied %}
|
|
||||||
<em>
|
|
||||||
{%- trans trimmed -%}
|
|
||||||
The results of this election have been applied
|
|
||||||
{%- endtrans -%}
|
|
||||||
</em>
|
|
||||||
<p>
|
|
||||||
{% for club in clubs %}
|
|
||||||
<a href="{{ url("club:club_members", club_id=club.id) }}" class="btn btn-blue">
|
|
||||||
<i class="fa fa-arrow-up-right-from-square"></i>
|
|
||||||
{% trans club=club.name %}{{ club }} members{% endtrans %}
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</p>
|
|
||||||
{% else %}
|
|
||||||
<div class="alert alert-yellow">
|
|
||||||
<div class="alert-main">
|
|
||||||
<strong class="alert-title">{% trans %}Warning{% endtrans %}</strong>
|
|
||||||
<p>
|
|
||||||
{%- trans trimmed -%}
|
|
||||||
Only election roles linked to a club role will be automatically applied.
|
|
||||||
{%- endtrans -%}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{%- trans trimmed -%}
|
|
||||||
Don't forget to manually apply the eventual remaining roles afterward.
|
|
||||||
{%- endtrans -%}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form
|
|
||||||
hx-post="{{ url("election:apply_result", election_id=form.election.id) }}"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
hx-target="#apply-election-result-fragment"
|
|
||||||
hx-disabled-elt="find input[type='submit']"
|
|
||||||
>
|
|
||||||
{% csrf_token %}
|
|
||||||
{{ form }}
|
|
||||||
<input type="submit" class="btn btn-blue">
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
{% extends "core/base.jinja" %}
|
|
||||||
|
|
||||||
{% block title %}
|
|
||||||
{% trans name=object_name %}Election role{% endtrans %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% if object %}
|
|
||||||
<h1>{% trans election=election %}Create role for election "{{ election }}"{% endtrans %}</h1>
|
|
||||||
{% else %}
|
|
||||||
<h1>{% trans election=election %}Edit role for election "{{ election }}"{% endtrans %}</h1>
|
|
||||||
{% endif %}
|
|
||||||
<form action="" method="post" x-data="{role: null, title: '', description: ''}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="form-group">
|
|
||||||
{{ form.club_role.label_tag() }}
|
|
||||||
{{ form.club_role.errors }}
|
|
||||||
{{ form.club_role|add_attr("x-model.fill=role,autofocus=true") }}
|
|
||||||
<button
|
|
||||||
class="btn btn-blue"
|
|
||||||
@click.prevent="title = roles[role]?.title ?? '';
|
|
||||||
description = roles[role]?.description ?? '';"
|
|
||||||
>
|
|
||||||
{% trans %}autofill form{% endtrans %}
|
|
||||||
</button>
|
|
||||||
<span class="helptext">{{ form.club_role.help_text }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
{{ form.title.label_tag() }}
|
|
||||||
{{ form.title.errors }}
|
|
||||||
{{ form.title|add_attr("x-model.fill=title") }}
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
{{ form.description.label_tag() }}
|
|
||||||
{{ form.description.errors }}
|
|
||||||
{{ form.description|add_attr("x-model.fill=description") }}
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
{{ form.max_choice.as_field_group() }}
|
|
||||||
</div>
|
|
||||||
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block script %}
|
|
||||||
<script>
|
|
||||||
const roles = {
|
|
||||||
{%- for role in form.club_role.field.queryset -%}
|
|
||||||
{{ role.id }}: { title: {{ role.name|tojson }}, description: {{ role.description|tojson }} },
|
|
||||||
{%- endfor -%}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -2,15 +2,13 @@ from datetime import timedelta
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.timezone import localtime, now
|
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 pytest_django.asserts import assertRedirects
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
from club.models import Club
|
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import Group, User
|
from core.models import Group, User
|
||||||
from election.models import Candidature, Election, ElectionList, Role, Vote
|
from election.models import Candidature, Election, ElectionList, Role, Vote
|
||||||
@@ -40,6 +38,7 @@ class TestElectionDetail(TestElection):
|
|||||||
reverse("election:detail", args=str(self.election.id))
|
reverse("election:detail", args=str(self.election.id))
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
assert "La roue tourne" in str(response.content)
|
||||||
|
|
||||||
|
|
||||||
class TestElectionUpdateView(TestElection):
|
class TestElectionUpdateView(TestElection):
|
||||||
@@ -214,42 +213,3 @@ def test_election_results():
|
|||||||
"total vote": 100,
|
"total vote": 100,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_create_election(client: Client):
|
|
||||||
user_group = baker.make(Group)
|
|
||||||
user = baker.make(
|
|
||||||
User,
|
|
||||||
user_permissions=[Permission.objects.get(codename="add_election")],
|
|
||||||
groups=[user_group],
|
|
||||||
)
|
|
||||||
club = baker.make(Club)
|
|
||||||
client.force_login(user)
|
|
||||||
url = reverse("election:create")
|
|
||||||
|
|
||||||
res = client.get(url)
|
|
||||||
assert res.status_code == 200
|
|
||||||
|
|
||||||
start = localtime().replace(hour=0, minute=1, second=0) + timedelta(days=1)
|
|
||||||
res = client.post(
|
|
||||||
url,
|
|
||||||
data={
|
|
||||||
"title": "foo",
|
|
||||||
"clubs": [club.id],
|
|
||||||
"view_groups": [user_group.id],
|
|
||||||
"start_candidature": start,
|
|
||||||
"end_candidature": start + timedelta(days=7, minutes=-2),
|
|
||||||
"start_date": start + timedelta(days=7),
|
|
||||||
"end_date": start + timedelta(days=14, minutes=-2),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
election = Election.objects.last()
|
|
||||||
assertRedirects(
|
|
||||||
res, reverse("election:detail", kwargs={"election_id": election.id})
|
|
||||||
)
|
|
||||||
assert election.title == "foo"
|
|
||||||
assert list(election.clubs.all()) == [club]
|
|
||||||
assert list(election.election_lists.values_list("title", flat=True)) == [
|
|
||||||
"Candidat⸱e libre"
|
|
||||||
]
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
import itertools
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
from django.test import TestCase
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.timezone import localdate, now
|
|
||||||
from model_bakery import baker, seq
|
|
||||||
from model_bakery.recipe import Recipe
|
|
||||||
from pytest_django.asserts import assertRedirects
|
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
|
||||||
from core.baker_recipes import subscriber_user
|
|
||||||
from core.models import Group, User
|
|
||||||
from election.models import Candidature, Election, ElectionList, Role, Vote
|
|
||||||
|
|
||||||
|
|
||||||
class TestApplyResult(TestCase):
|
|
||||||
@classmethod
|
|
||||||
def setUpTestData(cls):
|
|
||||||
# setup is a little bit complicated, but we have to make a whole
|
|
||||||
# election to test result application, including the election,
|
|
||||||
# the lists, the roles, the candidates and the votes.
|
|
||||||
cls.club = baker.make(Club)
|
|
||||||
cls.club_roles = baker.make(
|
|
||||||
ClubRole,
|
|
||||||
club=cls.club,
|
|
||||||
is_presidency=iter([True, False, False]),
|
|
||||||
is_board=True,
|
|
||||||
_quantity=3,
|
|
||||||
_bulk_create=True,
|
|
||||||
)
|
|
||||||
cls.election = baker.make(
|
|
||||||
Election,
|
|
||||||
clubs=[cls.club],
|
|
||||||
edit_groups=[baker.make(Group)],
|
|
||||||
end_date=now() - timedelta(minutes=1),
|
|
||||||
)
|
|
||||||
lists = baker.make(
|
|
||||||
ElectionList, election=cls.election, _quantity=2, _bulk_create=True
|
|
||||||
)
|
|
||||||
role_recipe = Recipe(Role, election=cls.election, title=seq("election role "))
|
|
||||||
roles = [
|
|
||||||
*role_recipe.make(
|
|
||||||
club_role=iter(cls.club_roles), _quantity=len(cls.club_roles)
|
|
||||||
),
|
|
||||||
role_recipe.make(),
|
|
||||||
]
|
|
||||||
roles[1].max_choice = 2
|
|
||||||
roles[1].save()
|
|
||||||
cls.candidatures = baker.make(
|
|
||||||
Candidature,
|
|
||||||
election_list=itertools.chain(
|
|
||||||
itertools.repeat(lists[0], len(roles)),
|
|
||||||
itertools.repeat(lists[1], len(roles)),
|
|
||||||
),
|
|
||||||
role=itertools.cycle(roles),
|
|
||||||
user=iter(
|
|
||||||
baker.make(
|
|
||||||
User, username=seq("user "), _quantity=len(lists) * len(roles)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
_quantity=len(lists) * len(roles),
|
|
||||||
_bulk_create=True,
|
|
||||||
)
|
|
||||||
votes = iter(
|
|
||||||
baker.make(
|
|
||||||
Vote,
|
|
||||||
role=itertools.cycle(roles),
|
|
||||||
_quantity=6 * len(roles),
|
|
||||||
_bulk_create=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
through = []
|
|
||||||
for cand in cls.candidatures:
|
|
||||||
nb_voices = 4 if cand.election_list_id == lists[0].id else 2
|
|
||||||
through.extend(
|
|
||||||
[
|
|
||||||
Vote.candidature.through(candidature=cand, vote=v)
|
|
||||||
for v in itertools.islice(votes, nb_voices)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
Vote.candidature.through.objects.bulk_create(through)
|
|
||||||
cls.election.voters.set(baker.make(User, _quantity=8, _bulk_create=True))
|
|
||||||
cls.url = reverse(
|
|
||||||
"election:apply_result", kwargs={"election_id": cls.election.id}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_election_result(self):
|
|
||||||
# we have made a complex setup, so testing the results is
|
|
||||||
# useful to be sure we didn't make mistake when generating data
|
|
||||||
assert self.election.results == {
|
|
||||||
"election role 1": {
|
|
||||||
"blank vote": {"percent": 25.0, "vote": 2},
|
|
||||||
"total vote": 8,
|
|
||||||
"user 1": {"percent": 50.0, "vote": 4},
|
|
||||||
"user 5": {"percent": 25.0, "vote": 2},
|
|
||||||
},
|
|
||||||
"election role 2": {
|
|
||||||
"blank vote": {"percent": 62.5, "vote": 10},
|
|
||||||
"total vote": 16,
|
|
||||||
"user 2": {"percent": 25.0, "vote": 4},
|
|
||||||
"user 6": {"percent": 12.5, "vote": 2},
|
|
||||||
},
|
|
||||||
"election role 3": {
|
|
||||||
"blank vote": {"percent": 25.0, "vote": 2},
|
|
||||||
"total vote": 8,
|
|
||||||
"user 3": {"percent": 50.0, "vote": 4},
|
|
||||||
"user 7": {"percent": 25.0, "vote": 2},
|
|
||||||
},
|
|
||||||
"election role 4": {
|
|
||||||
"blank vote": {"percent": 25.0, "vote": 2},
|
|
||||||
"total vote": 8,
|
|
||||||
"user 4": {"percent": 50.0, "vote": 4},
|
|
||||||
"user 8": {"percent": 25.0, "vote": 2},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_apply_result(self):
|
|
||||||
user = baker.make(
|
|
||||||
User, user_permissions=[Permission.objects.get(codename="add_membership")]
|
|
||||||
)
|
|
||||||
self.client.force_login(user)
|
|
||||||
response = self.client.get(self.url)
|
|
||||||
soup = BeautifulSoup(response.text, "lxml")
|
|
||||||
inputs = soup.find_all("input", attrs={"type": "checkbox"})
|
|
||||||
assert all("checked" in i.attrs for i in inputs)
|
|
||||||
ids = {int(i.attrs["value"]) for i in inputs}
|
|
||||||
assert ids == {
|
|
||||||
self.candidatures[0].id,
|
|
||||||
self.candidatures[1].id,
|
|
||||||
self.candidatures[2].id,
|
|
||||||
self.candidatures[5].id,
|
|
||||||
}
|
|
||||||
response = self.client.post(
|
|
||||||
self.url, data={"candidates": ids.difference({self.candidatures[5].id})}
|
|
||||||
)
|
|
||||||
assertRedirects(response, self.url)
|
|
||||||
for candidate in self.candidatures[0:3]:
|
|
||||||
assert Membership.objects.filter(
|
|
||||||
start_date=localdate(),
|
|
||||||
end_date=None,
|
|
||||||
user=candidate.user,
|
|
||||||
role=candidate.role.club_role,
|
|
||||||
).exists()
|
|
||||||
assert self.club.members_group.users.contains(candidate.user)
|
|
||||||
assert self.club.board_group.users.contains(candidate.user)
|
|
||||||
# candidatures[5] was unchecked, so it shouldn't receive a club role
|
|
||||||
assert not self.candidatures[5].user.memberships.exists()
|
|
||||||
|
|
||||||
# now that results are applied, it shouldn't be possible to replay the request
|
|
||||||
response = self.client.get(self.url)
|
|
||||||
assert "Les résultats de cette élection ont été appliqués" in response.text
|
|
||||||
response = self.client.post(self.url, data={"candidates": ids})
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_no_result_to_apply(self):
|
|
||||||
self.election.roles.update(club_role=None)
|
|
||||||
user = baker.make(
|
|
||||||
User, user_permissions=[Permission.objects.get(codename="add_membership")]
|
|
||||||
)
|
|
||||||
self.client.force_login(user)
|
|
||||||
response = self.client.get(self.url)
|
|
||||||
soup = BeautifulSoup(response.text, "lxml")
|
|
||||||
assert not soup.find("input", attrs={"type": "checkbox"})
|
|
||||||
assert "Pas de résultats à appliquer" in response.text
|
|
||||||
|
|
||||||
def test_access_denied(self):
|
|
||||||
user = subscriber_user.make()
|
|
||||||
self.client.force_login(user)
|
|
||||||
response = self.client.get(self.url)
|
|
||||||
assert response.status_code == 403
|
|
||||||
response = self.client.post(
|
|
||||||
self.url, data={"candidates": [self.candidatures[0].id]}
|
|
||||||
)
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_election_not_finished(self):
|
|
||||||
user = baker.make(
|
|
||||||
User, user_permissions=[Permission.objects.get(codename="add_membership")]
|
|
||||||
)
|
|
||||||
self.election.end_date = now() + timedelta(minutes=1)
|
|
||||||
self.election.save()
|
|
||||||
self.client.force_login(user)
|
|
||||||
response = self.client.get(self.url)
|
|
||||||
assert response.status_code == 403
|
|
||||||
response = self.client.post(
|
|
||||||
self.url, data={"candidates": [self.candidatures[0].id]}
|
|
||||||
)
|
|
||||||
assert response.status_code == 403
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
from django.test import TestCase
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.timezone import now
|
|
||||||
from model_bakery import baker
|
|
||||||
from pytest_django.asserts import assertRedirects
|
|
||||||
|
|
||||||
from club.models import Club, ClubRole
|
|
||||||
from core.baker_recipes import subscriber_user
|
|
||||||
from core.models import Group, User
|
|
||||||
from election.models import Election, Role
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestCreateRole(TestCase):
|
|
||||||
@classmethod
|
|
||||||
def setUpTestData(cls):
|
|
||||||
cls.club = baker.make(Club)
|
|
||||||
cls.edit_group = baker.make(Group)
|
|
||||||
cls.election = baker.make(
|
|
||||||
Election,
|
|
||||||
clubs=[cls.club],
|
|
||||||
edit_groups=[cls.edit_group],
|
|
||||||
view_groups=[Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)],
|
|
||||||
end_candidature=now() + timedelta(days=1),
|
|
||||||
)
|
|
||||||
cls.url = reverse(
|
|
||||||
"election:create_role", kwargs={"election_id": cls.election.id}
|
|
||||||
)
|
|
||||||
cls.election_url = reverse(
|
|
||||||
"election:detail", kwargs={"election_id": cls.election.id}
|
|
||||||
)
|
|
||||||
cls.permission = Permission.objects.get(codename="add_role")
|
|
||||||
|
|
||||||
def assert_role_creation_ok(self):
|
|
||||||
response = self.client.get(self.url)
|
|
||||||
assert response.status_code == 200
|
|
||||||
response = self.client.post(self.url, data={"title": "foo", "max_choice": 1})
|
|
||||||
assertRedirects(response, self.election_url)
|
|
||||||
roles = list(self.election.roles.all())
|
|
||||||
assert len(roles) == 1
|
|
||||||
assert roles[0].title == "foo"
|
|
||||||
|
|
||||||
def assert_role_creation_denied(self):
|
|
||||||
initial_role_count = self.election.roles.count()
|
|
||||||
response = self.client.get(self.url)
|
|
||||||
assert response.status_code == 403
|
|
||||||
response = self.client.post(self.url, data={"title": "foo", "max_choice": 1})
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert self.election.roles.count() == initial_role_count
|
|
||||||
|
|
||||||
def test_admin(self):
|
|
||||||
user = baker.make(User, user_permissions=[self.permission])
|
|
||||||
self.client.force_login(user)
|
|
||||||
self.assert_role_creation_ok()
|
|
||||||
|
|
||||||
def test_edit_group(self):
|
|
||||||
user = baker.make(User, groups=[self.edit_group])
|
|
||||||
self.client.force_login(user)
|
|
||||||
self.assert_role_creation_ok()
|
|
||||||
|
|
||||||
def test_role_linked_to_club_role(self):
|
|
||||||
user = baker.make(User, user_permissions=[self.permission])
|
|
||||||
self.client.force_login(user)
|
|
||||||
club_role = baker.make(ClubRole, is_board=True, club=self.club)
|
|
||||||
response = self.client.post(
|
|
||||||
self.url, data={"title": "foo", "max_choice": 1, "club_role": club_role.id}
|
|
||||||
)
|
|
||||||
assertRedirects(response, self.election_url)
|
|
||||||
roles = list(self.election.roles.all())
|
|
||||||
assert len(roles) == 1
|
|
||||||
assert roles[0].title == "foo"
|
|
||||||
assert roles[0].club_role == club_role
|
|
||||||
|
|
||||||
def test_permission_denied(self):
|
|
||||||
user = subscriber_user.make()
|
|
||||||
self.client.force_login(user)
|
|
||||||
self.assert_role_creation_denied()
|
|
||||||
|
|
||||||
def test_election_not_editable(self):
|
|
||||||
user = baker.make(User, user_permissions=[self.permission])
|
|
||||||
self.election.end_candidature = now() - timedelta(minutes=1)
|
|
||||||
self.election.save()
|
|
||||||
self.client.force_login(user)
|
|
||||||
self.assert_role_creation_denied()
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateRole(TestCreateRole):
|
|
||||||
@classmethod
|
|
||||||
def setUpTestData(cls):
|
|
||||||
# TestUpdateRole is just TestCreateRole, but with different parameters
|
|
||||||
cls.club = baker.make(Club)
|
|
||||||
cls.edit_group = baker.make(Group)
|
|
||||||
cls.election = baker.make(
|
|
||||||
Election,
|
|
||||||
clubs=[cls.club],
|
|
||||||
edit_groups=[cls.edit_group],
|
|
||||||
view_groups=[Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)],
|
|
||||||
end_candidature=now() + timedelta(days=1),
|
|
||||||
)
|
|
||||||
cls.role = baker.make(Role, election=cls.election)
|
|
||||||
cls.url = reverse("election:update_role", kwargs={"role_id": cls.role.id})
|
|
||||||
cls.election_url = reverse(
|
|
||||||
"election:detail", kwargs={"election_id": cls.election.id}
|
|
||||||
)
|
|
||||||
cls.permission = Permission.objects.get(codename="change_role")
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from election.views import (
|
from election.views import (
|
||||||
ApplyResultFragment,
|
|
||||||
CandidatureCreateView,
|
CandidatureCreateView,
|
||||||
CandidatureDeleteView,
|
CandidatureDeleteView,
|
||||||
CandidatureUpdateView,
|
CandidatureUpdateView,
|
||||||
@@ -57,9 +56,4 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
path("<int:election_id>/vote/", VoteFormView.as_view(), name="vote"),
|
path("<int:election_id>/vote/", VoteFormView.as_view(), name="vote"),
|
||||||
path("<int:election_id>/detail/", ElectionDetailView.as_view(), name="detail"),
|
path("<int:election_id>/detail/", ElectionDetailView.as_view(), name="detail"),
|
||||||
path(
|
|
||||||
"fragment/<int:election_id>/apply/",
|
|
||||||
ApplyResultFragment.as_view(),
|
|
||||||
name="apply_result",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|||||||
+65
-65
@@ -18,9 +18,7 @@ from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateVi
|
|||||||
|
|
||||||
from core.auth.mixins import CanEditMixin, CanViewMixin
|
from core.auth.mixins import CanEditMixin, CanViewMixin
|
||||||
from election.forms import (
|
from election.forms import (
|
||||||
ApplyElectionResultForm,
|
|
||||||
CandidateForm,
|
CandidateForm,
|
||||||
ElectionCreateForm,
|
|
||||||
ElectionForm,
|
ElectionForm,
|
||||||
ElectionListForm,
|
ElectionListForm,
|
||||||
RoleForm,
|
RoleForm,
|
||||||
@@ -210,7 +208,7 @@ class CandidatureCreateView(LoginRequiredMixin, CreateView):
|
|||||||
|
|
||||||
class ElectionCreateView(PermissionRequiredMixin, CreateView):
|
class ElectionCreateView(PermissionRequiredMixin, CreateView):
|
||||||
model = Election
|
model = Election
|
||||||
form_class = ElectionCreateForm
|
form_class = ElectionForm
|
||||||
template_name = "core/create.jinja"
|
template_name = "core/create.jinja"
|
||||||
permission_required = "election.add_election"
|
permission_required = "election.add_election"
|
||||||
|
|
||||||
@@ -221,7 +219,7 @@ class ElectionCreateView(PermissionRequiredMixin, CreateView):
|
|||||||
class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
|
class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
|
||||||
model = Role
|
model = Role
|
||||||
form_class = RoleForm
|
form_class = RoleForm
|
||||||
template_name = "election/role_form.jinja"
|
template_name = "core/create.jinja"
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def election(self):
|
def election(self):
|
||||||
@@ -230,17 +228,22 @@ class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
|
|||||||
def test_func(self):
|
def test_func(self):
|
||||||
if not self.election.is_vote_editable:
|
if not self.election.is_vote_editable:
|
||||||
return False
|
return False
|
||||||
user = self.request.user
|
if self.request.user.has_perm("election.add_role"):
|
||||||
return user.has_perm("election.add_role") or user.can_edit(self.election)
|
return True
|
||||||
|
return self.election.edit_groups.filter(
|
||||||
|
id__in=self.request.user.all_groups
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
def get_initial(self):
|
||||||
|
return {"election": self.election}
|
||||||
|
|
||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
return super().get_form_kwargs() | {"election": self.election}
|
return super().get_form_kwargs() | {"election_id": self.election.id}
|
||||||
|
|
||||||
def get_success_url(self, **kwargs):
|
def get_success_url(self, **kwargs):
|
||||||
return reverse("election:detail", kwargs={"election_id": self.election.id})
|
return reverse(
|
||||||
|
"election:detail", kwargs={"election_id": self.object.election_id}
|
||||||
def get_context_data(self, **kwargs):
|
)
|
||||||
return super().get_context_data(**kwargs) | {"election": self.election}
|
|
||||||
|
|
||||||
|
|
||||||
class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
|
class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
|
||||||
@@ -264,11 +267,16 @@ class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView
|
|||||||
)
|
)
|
||||||
return not groups.isdisjoint(self.request.user.all_groups.keys())
|
return not groups.isdisjoint(self.request.user.all_groups.keys())
|
||||||
|
|
||||||
|
def get_initial(self):
|
||||||
|
return {"election": self.election}
|
||||||
|
|
||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
return super().get_form_kwargs() | {"election": self.election}
|
return super().get_form_kwargs() | {"election_id": self.election.id}
|
||||||
|
|
||||||
def get_success_url(self, **kwargs):
|
def get_success_url(self, **kwargs):
|
||||||
return reverse("election:detail", kwargs={"election_id": self.election.id})
|
return reverse(
|
||||||
|
"election:detail", kwargs={"election_id": self.object.election_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Update view
|
# Update view
|
||||||
@@ -280,6 +288,18 @@ class ElectionUpdateView(CanEditMixin, UpdateView):
|
|||||||
template_name = "core/edit.jinja"
|
template_name = "core/edit.jinja"
|
||||||
pk_url_kwarg = "election_id"
|
pk_url_kwarg = "election_id"
|
||||||
|
|
||||||
|
def get_initial(self):
|
||||||
|
return {
|
||||||
|
"start_date": self.object.start_date.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"end_date": self.object.end_date.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"start_candidature": self.object.start_candidature.strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S"
|
||||||
|
),
|
||||||
|
"end_candidature": self.object.end_candidature.strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
def get_success_url(self, **kwargs):
|
def get_success_url(self, **kwargs):
|
||||||
return reverse_lazy("election:detail", kwargs={"election_id": self.object.id})
|
return reverse_lazy("election:detail", kwargs={"election_id": self.object.id})
|
||||||
|
|
||||||
@@ -304,30 +324,48 @@ class CandidatureUpdateView(LoginRequiredMixin, CanEditMixin, UpdateView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class RoleUpdateView(UserPassesTestMixin, UpdateView):
|
class RoleUpdateView(CanEditMixin, UpdateView):
|
||||||
model = Role
|
model = Role
|
||||||
form_class = RoleForm
|
form_class = RoleForm
|
||||||
template_name = "election/role_form.jinja"
|
template_name = "core/edit.jinja"
|
||||||
pk_url_kwarg = "role_id"
|
pk_url_kwarg = "role_id"
|
||||||
|
|
||||||
@cached_property
|
def dispatch(self, request, *arg, **kwargs):
|
||||||
def election(self):
|
self.object = self.get_object()
|
||||||
return self.get_object().election
|
if not self.object.election.is_vote_editable:
|
||||||
|
raise PermissionDenied
|
||||||
|
return super().dispatch(request, *arg, **kwargs)
|
||||||
|
|
||||||
def test_func(self):
|
def remove_fields(self):
|
||||||
if not self.election.is_vote_editable:
|
self.form.fields.pop("election", None)
|
||||||
return False
|
|
||||||
user = self.request.user
|
|
||||||
return user.has_perm("election.change_role") or user.can_edit(self.election)
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
return super().get_context_data(**kwargs) | {"election": self.election}
|
self.object = self.get_object()
|
||||||
|
self.form = self.get_form()
|
||||||
|
self.remove_fields()
|
||||||
|
return self.render_to_response(self.get_context_data(form=self.form))
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
self.object = self.get_object()
|
||||||
|
self.form = self.get_form()
|
||||||
|
self.remove_fields()
|
||||||
|
if (
|
||||||
|
request.user.is_authenticated
|
||||||
|
and request.user.can_edit(self.object)
|
||||||
|
and self.form.is_valid()
|
||||||
|
):
|
||||||
|
return super().form_valid(self.form)
|
||||||
|
return self.form_invalid(self.form)
|
||||||
|
|
||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
return super().get_form_kwargs() | {"election": self.election}
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs["election_id"] = self.object.election.id
|
||||||
|
return kwargs
|
||||||
|
|
||||||
def get_success_url(self, **kwargs):
|
def get_success_url(self, **kwargs):
|
||||||
return reverse("election:detail", kwargs={"election_id": self.election.id})
|
return reverse_lazy(
|
||||||
|
"election:detail", kwargs={"election_id": self.object.election.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Delete Views
|
# Delete Views
|
||||||
@@ -387,41 +425,3 @@ class ElectionListDeleteView(CanEditMixin, DeleteView):
|
|||||||
|
|
||||||
def get_success_url(self, **kwargs):
|
def get_success_url(self, **kwargs):
|
||||||
return reverse("election:detail", kwargs={"election_id": self.election.id})
|
return reverse("election:detail", kwargs={"election_id": self.election.id})
|
||||||
|
|
||||||
|
|
||||||
class ApplyResultFragment(LoginRequiredMixin, UserPassesTestMixin, FormView):
|
|
||||||
template_name = "election/fragments/apply_result.jinja"
|
|
||||||
form_class = ApplyElectionResultForm
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def election(self):
|
|
||||||
return get_object_or_404(Election, pk=self.kwargs["election_id"])
|
|
||||||
|
|
||||||
def test_func(self):
|
|
||||||
if not self.election.is_vote_finished:
|
|
||||||
return False
|
|
||||||
if self.request.user.has_perm("club.add_membership"):
|
|
||||||
return True
|
|
||||||
return self.election.edit_groups.filter(
|
|
||||||
id__in=self.request.user.all_groups
|
|
||||||
).exists()
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
if self.election.results_applied:
|
|
||||||
raise PermissionDenied
|
|
||||||
return super().post(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def get_form_kwargs(self):
|
|
||||||
return super().get_form_kwargs() | {"election": self.election}
|
|
||||||
|
|
||||||
def form_valid(self, form: ApplyElectionResultForm):
|
|
||||||
form.save()
|
|
||||||
return super().form_valid(form)
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
|
||||||
return super().get_context_data(**kwargs) | {"clubs": self.election.clubs.all()}
|
|
||||||
|
|
||||||
def get_success_url(self, **kwargs):
|
|
||||||
return reverse(
|
|
||||||
"election:apply_result", kwargs={"election_id": self.election.id}
|
|
||||||
)
|
|
||||||
|
|||||||
+162
-166
@@ -6,7 +6,7 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-06-05 13:39+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"
|
||||||
@@ -141,7 +236,8 @@ msgstr "vous devez spécifier au moins un utilisateur ou une adresse email"
|
|||||||
msgid "Begin date"
|
msgid "Begin date"
|
||||||
msgstr "Date de début"
|
msgstr "Date de début"
|
||||||
|
|
||||||
#: club/forms.py com/forms.py counter/forms.py subscription/forms.py
|
#: club/forms.py com/forms.py counter/forms.py election/forms.py
|
||||||
|
#: subscription/forms.py
|
||||||
msgid "End date"
|
msgid "End date"
|
||||||
msgstr "Date de fin"
|
msgstr "Date de fin"
|
||||||
|
|
||||||
@@ -260,7 +356,7 @@ msgstr ""
|
|||||||
"Si ce rôle est inactif, il ne pourra pas être attribué aux gens qui "
|
"Si ce rôle est inactif, il ne pourra pas être attribué aux gens qui "
|
||||||
"rejoignent le club."
|
"rejoignent le club."
|
||||||
|
|
||||||
#: club/models.py election/models.py
|
#: club/models.py
|
||||||
msgid "club role"
|
msgid "club role"
|
||||||
msgstr "rôle de club"
|
msgstr "rôle de club"
|
||||||
|
|
||||||
@@ -362,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"
|
||||||
@@ -591,7 +690,6 @@ msgstr ""
|
|||||||
#: counter/templates/counter/cash_register_summary.jinja
|
#: counter/templates/counter/cash_register_summary.jinja
|
||||||
#: counter/templates/counter/invoices_call.jinja
|
#: counter/templates/counter/invoices_call.jinja
|
||||||
#: counter/templates/counter/product_form.jinja
|
#: counter/templates/counter/product_form.jinja
|
||||||
#: election/templates/election/role_form.jinja
|
|
||||||
#: forum/templates/forum/reply.jinja
|
#: forum/templates/forum/reply.jinja
|
||||||
#: subscription/templates/subscription/fragments/creation_form_existing_user.jinja
|
#: subscription/templates/subscription/fragments/creation_form_existing_user.jinja
|
||||||
#: subscription/templates/subscription/fragments/creation_form_new_user.jinja
|
#: subscription/templates/subscription/fragments/creation_form_new_user.jinja
|
||||||
@@ -680,7 +778,6 @@ msgstr "Étiquette"
|
|||||||
#: core/templates/core/user_account_detail.jinja
|
#: core/templates/core/user_account_detail.jinja
|
||||||
#: core/templates/core/user_stats.jinja
|
#: core/templates/core/user_stats.jinja
|
||||||
#: counter/templates/counter/last_ops.jinja
|
#: counter/templates/counter/last_ops.jinja
|
||||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
|
||||||
msgid "Quantity"
|
msgid "Quantity"
|
||||||
msgstr "Quantité"
|
msgstr "Quantité"
|
||||||
|
|
||||||
@@ -964,7 +1061,7 @@ msgstr "rôle de club – membre"
|
|||||||
msgid "Benefit"
|
msgid "Benefit"
|
||||||
msgstr "Bénéfice"
|
msgstr "Bénéfice"
|
||||||
|
|
||||||
#: club/views.py eboutic/templates/eboutic/eboutic_checkout.jinja
|
#: club/views.py
|
||||||
msgid "Unit price"
|
msgid "Unit price"
|
||||||
msgstr "Prix unitaire"
|
msgstr "Prix unitaire"
|
||||||
|
|
||||||
@@ -976,7 +1073,7 @@ msgstr "Prix d'achat"
|
|||||||
msgid "Format: 16:9 | Resolution: 1920x1080"
|
msgid "Format: 16:9 | Resolution: 1920x1080"
|
||||||
msgstr "Format : 16:9 | Résolution : 1920x1080"
|
msgstr "Format : 16:9 | Résolution : 1920x1080"
|
||||||
|
|
||||||
#: com/forms.py subscription/forms.py
|
#: com/forms.py election/forms.py subscription/forms.py
|
||||||
msgid "Start date"
|
msgid "Start date"
|
||||||
msgstr "Date de début"
|
msgstr "Date de début"
|
||||||
|
|
||||||
@@ -2201,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"
|
||||||
|
|
||||||
@@ -3205,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"
|
||||||
@@ -3419,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"
|
||||||
@@ -3495,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"
|
||||||
@@ -3780,6 +3860,15 @@ msgstr "Confirmer (FIN)"
|
|||||||
msgid "Cancel (ANN)"
|
msgid "Cancel (ANN)"
|
||||||
msgstr "Annuler (ANN)"
|
msgstr "Annuler (ANN)"
|
||||||
|
|
||||||
|
#: counter/templates/counter/counter_click.jinja
|
||||||
|
#: counter/templates/counter/fragments/create_refill.jinja
|
||||||
|
#: counter/templates/counter/fragments/create_student_card.jinja
|
||||||
|
#: counter/templates/counter/invoices_call.jinja
|
||||||
|
#: sas/templates/sas/picture.jinja
|
||||||
|
#: subscription/templates/subscription/stats.jinja
|
||||||
|
msgid "Go"
|
||||||
|
msgstr "Valider"
|
||||||
|
|
||||||
#: counter/templates/counter/counter_click.jinja
|
#: counter/templates/counter/counter_click.jinja
|
||||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||||
msgid "Basket: "
|
msgid "Basket: "
|
||||||
@@ -3810,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"
|
||||||
@@ -3871,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"
|
||||||
@@ -3936,14 +4017,6 @@ msgstr ""
|
|||||||
msgid "New formula"
|
msgid "New formula"
|
||||||
msgstr "Nouvelle formule"
|
msgstr "Nouvelle formule"
|
||||||
|
|
||||||
#: counter/templates/counter/fragments/create_refill.jinja
|
|
||||||
#: counter/templates/counter/fragments/create_student_card.jinja
|
|
||||||
#: counter/templates/counter/invoices_call.jinja
|
|
||||||
#: sas/templates/sas/picture.jinja
|
|
||||||
#: subscription/templates/subscription/stats.jinja
|
|
||||||
msgid "Go"
|
|
||||||
msgstr "Valider"
|
|
||||||
|
|
||||||
#: counter/templates/counter/fragments/create_student_card.jinja
|
#: counter/templates/counter/fragments/create_student_card.jinja
|
||||||
msgid "No student card registered."
|
msgid "No student card registered."
|
||||||
msgstr "Aucune carte étudiante enregistrée."
|
msgstr "Aucune carte étudiante enregistrée."
|
||||||
@@ -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."
|
||||||
@@ -4381,18 +4462,6 @@ msgstr "Solde actuel : "
|
|||||||
msgid "Remaining account amount: "
|
msgid "Remaining account amount: "
|
||||||
msgstr "Solde restant : "
|
msgstr "Solde restant : "
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
|
||||||
msgid "I have read and I accept"
|
|
||||||
msgstr "J'ai lu et j'accepte"
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
|
||||||
msgid "the general terms and conditions"
|
|
||||||
msgstr "les conditions générales de vente"
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
|
||||||
msgid "of the student association of the UTBM"
|
|
||||||
msgstr "de l'Association des étudiants de l'UTBM"
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||||
msgid "Pay with credit card"
|
msgid "Pay with credit card"
|
||||||
msgstr "Payer avec une carte bancaire"
|
msgstr "Payer avec une carte bancaire"
|
||||||
@@ -4488,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"
|
||||||
@@ -4524,13 +4589,16 @@ msgstr ""
|
|||||||
"par carte bancaire"
|
"par carte bancaire"
|
||||||
|
|
||||||
#: eboutic/views.py
|
#: eboutic/views.py
|
||||||
msgid "Basket expired"
|
msgid ""
|
||||||
msgstr "Panier expiré"
|
"The Crédit Agricole changed its policy related to the billing information "
|
||||||
|
"that must be provided in order to pay with a credit card. If you want to pay "
|
||||||
#: eboutic/views.py
|
"with your credit card, you must add a phone number to the data you already "
|
||||||
#, python-format
|
"provided."
|
||||||
msgid "Basket available until %(until)s"
|
msgstr ""
|
||||||
msgstr "Panier disponible jusqu'à %(until)s"
|
"Le Crédit Agricole a changé sa politique relative aux informations à "
|
||||||
|
"fournir pour effectuer un paiement par carte bancaire. De ce fait, si vous "
|
||||||
|
"souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux "
|
||||||
|
"données que vous aviez déjà fourni."
|
||||||
|
|
||||||
#: eboutic/views.py
|
#: eboutic/views.py
|
||||||
msgid "You can't buy a refilling with sith money"
|
msgid "You can't buy a refilling with sith money"
|
||||||
@@ -4548,13 +4616,17 @@ msgstr "Utilisateur se présentant"
|
|||||||
msgid "Blank vote"
|
msgid "Blank vote"
|
||||||
msgstr "Vote blanc"
|
msgstr "Vote blanc"
|
||||||
|
|
||||||
#: election/models.py
|
#: election/forms.py
|
||||||
msgid "clubs"
|
msgid "This role already exists for this election"
|
||||||
msgstr "clubs"
|
msgstr "Ce rôle existe déjà pour cette élection"
|
||||||
|
|
||||||
#: election/models.py
|
#: election/forms.py
|
||||||
msgid "The club(s) this election is held for."
|
msgid "Start candidature"
|
||||||
msgstr "Le(s) club(s) pour lequel cette élection est tenue."
|
msgstr "Début des candidatures"
|
||||||
|
|
||||||
|
#: election/forms.py
|
||||||
|
msgid "End candidature"
|
||||||
|
msgstr "Fin des candidatures"
|
||||||
|
|
||||||
#: election/models.py
|
#: election/models.py
|
||||||
msgid "start candidature"
|
msgid "start candidature"
|
||||||
@@ -4592,18 +4664,6 @@ msgstr "élection"
|
|||||||
msgid "max choice"
|
msgid "max choice"
|
||||||
msgstr "nombre de choix maxi"
|
msgstr "nombre de choix maxi"
|
||||||
|
|
||||||
#: election/models.py
|
|
||||||
msgid ""
|
|
||||||
"A club role. Filling this will allow automatic completion of title and "
|
|
||||||
"description, and automatic assignation after the elections."
|
|
||||||
msgstr ""
|
|
||||||
"Un rôle de club. Remplir ce champ permet l'autocomplétion du titre et de la "
|
|
||||||
"description, et l'attribution automatique des rôles après les élections."
|
|
||||||
|
|
||||||
#: election/models.py
|
|
||||||
msgid "This role already exists for this election"
|
|
||||||
msgstr "Ce rôle existe déjà pour cette élection"
|
|
||||||
|
|
||||||
#: election/models.py
|
#: election/models.py
|
||||||
msgid "election list"
|
msgid "election list"
|
||||||
msgstr "liste électorale"
|
msgstr "liste électorale"
|
||||||
@@ -4640,17 +4700,15 @@ msgid "Polls will open "
|
|||||||
msgstr "Les votes ouvriront "
|
msgstr "Les votes ouvriront "
|
||||||
|
|
||||||
#: election/templates/election/election_detail.jinja
|
#: election/templates/election/election_detail.jinja
|
||||||
msgid "at"
|
#: election/templates/election/election_list.jinja
|
||||||
msgstr "à"
|
#: forum/templates/forum/macros.jinja
|
||||||
|
msgid " at "
|
||||||
|
msgstr " à "
|
||||||
|
|
||||||
#: election/templates/election/election_detail.jinja
|
#: election/templates/election/election_detail.jinja
|
||||||
msgid "and will close "
|
msgid "and will close "
|
||||||
msgstr "et fermeront"
|
msgstr "et fermeront"
|
||||||
|
|
||||||
#: election/templates/election/election_detail.jinja
|
|
||||||
msgid "Apply election result"
|
|
||||||
msgstr "Appliquer les résultats de l'élection"
|
|
||||||
|
|
||||||
#: election/templates/election/election_detail.jinja
|
#: election/templates/election/election_detail.jinja
|
||||||
msgid "You already have submitted your vote."
|
msgid "You already have submitted your vote."
|
||||||
msgstr "Vous avez déjà soumis votre vote."
|
msgstr "Vous avez déjà soumis votre vote."
|
||||||
@@ -4692,19 +4750,10 @@ msgstr "Liste des élections"
|
|||||||
msgid "Current elections"
|
msgid "Current elections"
|
||||||
msgstr "Élections actuelles"
|
msgstr "Élections actuelles"
|
||||||
|
|
||||||
#: election/templates/election/election_list.jinja
|
|
||||||
msgid "New election"
|
|
||||||
msgstr "Nouvelle élection"
|
|
||||||
|
|
||||||
#: election/templates/election/election_list.jinja
|
#: election/templates/election/election_list.jinja
|
||||||
msgid "Applications open from"
|
msgid "Applications open from"
|
||||||
msgstr "Candidatures ouvertes à partir du"
|
msgstr "Candidatures ouvertes à partir du"
|
||||||
|
|
||||||
#: election/templates/election/election_list.jinja
|
|
||||||
#: forum/templates/forum/macros.jinja
|
|
||||||
msgid " at "
|
|
||||||
msgstr " à "
|
|
||||||
|
|
||||||
#: election/templates/election/election_list.jinja
|
#: election/templates/election/election_list.jinja
|
||||||
msgid "to"
|
msgid "to"
|
||||||
msgstr "au"
|
msgstr "au"
|
||||||
@@ -4713,59 +4762,6 @@ msgstr "au"
|
|||||||
msgid "Polls open from"
|
msgid "Polls open from"
|
||||||
msgstr "Votes ouverts du"
|
msgstr "Votes ouverts du"
|
||||||
|
|
||||||
#: election/templates/election/fragments/apply_result.jinja
|
|
||||||
msgid "No result to apply"
|
|
||||||
msgstr "Pas de résultats à appliquer"
|
|
||||||
|
|
||||||
#: election/templates/election/fragments/apply_result.jinja
|
|
||||||
msgid "This may be because no role of this election was linked to a club role."
|
|
||||||
msgstr ""
|
|
||||||
"Ceci s'explique peut-être parce qu'aucun poste de cette élection n'était lié "
|
|
||||||
"à un rôle de club."
|
|
||||||
|
|
||||||
#: election/templates/election/fragments/apply_result.jinja
|
|
||||||
msgid "The results of this election have been applied"
|
|
||||||
msgstr "Les résultats de cette élection ont été appliqués"
|
|
||||||
|
|
||||||
#: election/templates/election/fragments/apply_result.jinja
|
|
||||||
#, python-format
|
|
||||||
msgid "%(club)s members"
|
|
||||||
msgstr "Membres %(club)s"
|
|
||||||
|
|
||||||
#: election/templates/election/fragments/apply_result.jinja
|
|
||||||
msgid "Warning"
|
|
||||||
msgstr "Attention"
|
|
||||||
|
|
||||||
#: election/templates/election/fragments/apply_result.jinja
|
|
||||||
msgid ""
|
|
||||||
"Only election roles linked to a club role will be automatically applied."
|
|
||||||
msgstr ""
|
|
||||||
"Seuls les postes de cette élection qui sont liés à un rôle de club seront "
|
|
||||||
"automatiquement appliqués."
|
|
||||||
|
|
||||||
#: election/templates/election/fragments/apply_result.jinja
|
|
||||||
msgid "Don't forget to manually apply the eventual remaining roles afterward."
|
|
||||||
msgstr ""
|
|
||||||
"N'oubliez pas après d'attribuer manuellement les éventuels postes restants."
|
|
||||||
|
|
||||||
#: election/templates/election/role_form.jinja
|
|
||||||
msgid "Election role"
|
|
||||||
msgstr "Rôle d'élection"
|
|
||||||
|
|
||||||
#: election/templates/election/role_form.jinja
|
|
||||||
#, python-format
|
|
||||||
msgid "Create role for election \"%(election)s\""
|
|
||||||
msgstr "Création d'un rôle pour l'élection « %(election)s »"
|
|
||||||
|
|
||||||
#: election/templates/election/role_form.jinja
|
|
||||||
#, python-format
|
|
||||||
msgid "Edit role for election \"%(election)s\""
|
|
||||||
msgstr "Modification d'un rôle pour l'élection « %(election)s »"
|
|
||||||
|
|
||||||
#: election/templates/election/role_form.jinja
|
|
||||||
msgid "autofill form"
|
|
||||||
msgstr "compléter le formulaire"
|
|
||||||
|
|
||||||
#: election/views.py
|
#: election/views.py
|
||||||
msgid "Form is invalid"
|
msgid "Form is invalid"
|
||||||
msgstr "Formulaire invalide"
|
msgstr "Formulaire invalide"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+840
-804
File diff suppressed because it is too large
Load Diff
+9
-9
@@ -24,18 +24,18 @@
|
|||||||
"#com:*": "./com/static/bundled/*"
|
"#com:*": "./com/static/bundled/*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.29.7",
|
"@babel/core": "^7.29.0",
|
||||||
"@babel/preset-env": "^7.29.7",
|
"@babel/preset-env": "^7.29.5",
|
||||||
"@biomejs/biome": "^2.4.16",
|
"@biomejs/biome": "^2.4.15",
|
||||||
"@hey-api/openapi-ts": "^0.98.1",
|
"@hey-api/openapi-ts": "^0.94.5",
|
||||||
"@types/alpinejs": "^3.13.11",
|
"@types/alpinejs": "^3.13.11",
|
||||||
"@types/alpinejs__sort": "^3.13.0",
|
"@types/alpinejs__sort": "^3.13.0",
|
||||||
"@types/cytoscape-cxtmenu": "^3.4.5",
|
"@types/cytoscape-cxtmenu": "^3.4.5",
|
||||||
"@types/cytoscape-klay": "^3.1.5",
|
"@types/cytoscape-klay": "^3.1.5",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"rollup-plugin-visualizer": "^7.0.1",
|
"rollup-plugin-visualizer": "^7.0.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^8.0.16"
|
"vite": "^8.0.13"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alpinejs/sort": "^3.15.12",
|
"@alpinejs/sort": "^3.15.12",
|
||||||
@@ -46,13 +46,13 @@
|
|||||||
"@fullcalendar/daygrid": "^6.1.20",
|
"@fullcalendar/daygrid": "^6.1.20",
|
||||||
"@fullcalendar/icalendar": "^6.1.20",
|
"@fullcalendar/icalendar": "^6.1.20",
|
||||||
"@fullcalendar/list": "^6.1.20",
|
"@fullcalendar/list": "^6.1.20",
|
||||||
"@sentry/browser": "^10.56.0",
|
"@sentry/browser": "^10.53.1",
|
||||||
"@zip.js/zip.js": "^2.8.26",
|
"@zip.js/zip.js": "^2.8.26",
|
||||||
"3d-force-graph": "^1.80.0",
|
"3d-force-graph": "^1.80.0",
|
||||||
"alpinejs": "^3.15.12",
|
"alpinejs": "^3.15.12",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"country-flag-emoji-polyfill": "^0.1.8",
|
"country-flag-emoji-polyfill": "^0.1.8",
|
||||||
"cytoscape": "^3.34.0",
|
"cytoscape": "^3.33.4",
|
||||||
"cytoscape-cxtmenu": "^3.5.0",
|
"cytoscape-cxtmenu": "^3.5.0",
|
||||||
"cytoscape-klay": "^3.1.4",
|
"cytoscape-klay": "^3.1.4",
|
||||||
"d3-force-3d": "^3.0.6",
|
"d3-force-3d": "^3.0.6",
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
"glob": "^13.0.6",
|
"glob": "^13.0.6",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"htmx.org": "^2.0.10",
|
"htmx.org": "^2.0.10",
|
||||||
"js-cookie": "^3.0.8",
|
"js-cookie": "^3.0.7",
|
||||||
"lit-html": "^3.3.3",
|
"lit-html": "^3.3.3",
|
||||||
"native-file-system-adapter": "^3.0.1",
|
"native-file-system-adapter": "^3.0.1",
|
||||||
"three": "^0.184.0",
|
"three": "^0.184.0",
|
||||||
|
|||||||
+10
-10
@@ -19,7 +19,7 @@ authors = [
|
|||||||
license = { text = "GPL-3.0-only" }
|
license = { text = "GPL-3.0-only" }
|
||||||
requires-python = "<4.0,>=3.12"
|
requires-python = "<4.0,>=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"django>=5.2.15,<6.0.0",
|
"django>=5.2.14,<6.0.0",
|
||||||
"django-ninja>=1.6.2,<2.0.0",
|
"django-ninja>=1.6.2,<2.0.0",
|
||||||
"django-ninja-extra>=0.31.4",
|
"django-ninja-extra>=0.31.4",
|
||||||
"Pillow>=12.2.0,<13.0.0",
|
"Pillow>=12.2.0,<13.0.0",
|
||||||
@@ -27,15 +27,15 @@ dependencies = [
|
|||||||
"django-jinja<3.0.0,>=2.11.0",
|
"django-jinja<3.0.0,>=2.11.0",
|
||||||
"cryptography>=48.0.0,<49.0.0",
|
"cryptography>=48.0.0,<49.0.0",
|
||||||
"django-phonenumber-field>=8.4.0,<9.0.0",
|
"django-phonenumber-field>=8.4.0,<9.0.0",
|
||||||
"phonenumbers>=9.0.32,<10.0.0",
|
"phonenumbers>=9.0.30,<10.0.0",
|
||||||
"reportlab>=4.5.1,<5.0.0",
|
"reportlab>=4.5.1,<5.0.0",
|
||||||
"django-haystack>=3.4.0,<4.0.0",
|
"django-haystack>=3.3.0,<4.0.0",
|
||||||
"xapian-haystack>=4.0.0,<5.0.0",
|
"xapian-haystack>=4.0.0,<5.0.0",
|
||||||
"libsass>=0.23.0,<1.0.0",
|
"libsass>=0.23.0,<1.0.0",
|
||||||
"django-ordered-model>=3.7.4,<4.0.0",
|
"django-ordered-model>=3.7.4,<4.0.0",
|
||||||
"django-simple-captcha>=0.6.3,<1.0.0",
|
"django-simple-captcha>=0.6.3,<1.0.0",
|
||||||
"python-dateutil>=2.9.0.post0,<3.0.0.0",
|
"python-dateutil>=2.9.0.post0,<3.0.0.0",
|
||||||
"sentry-sdk>=2.61.1,<3.0.0",
|
"sentry-sdk>=2.60.0,<3.0.0",
|
||||||
"jinja2>=3.1.6,<4.0.0",
|
"jinja2>=3.1.6,<4.0.0",
|
||||||
"django-countries>=8.2.0,<9.0.0",
|
"django-countries>=8.2.0,<9.0.0",
|
||||||
"dict2xml>=1.7.8,<2.0.0",
|
"dict2xml>=1.7.8,<2.0.0",
|
||||||
@@ -44,8 +44,8 @@ dependencies = [
|
|||||||
"django-honeypot>=1.3.0,<2",
|
"django-honeypot>=1.3.0,<2",
|
||||||
"pydantic-extra-types>=2.11.1,<3.0.0",
|
"pydantic-extra-types>=2.11.1,<3.0.0",
|
||||||
"ical>=12.0.0,<14.0.0",
|
"ical>=12.0.0,<14.0.0",
|
||||||
"redis[hiredis]>=3.4.0,<8.0.0",
|
"redis[hiredis]>=3.3.1,<8.0.0",
|
||||||
"environs[django]>=6.0.5,<16",
|
"environs[django]>=15.0.1,<16",
|
||||||
"requests>=2.34.2,<3.0.0",
|
"requests>=2.34.2,<3.0.0",
|
||||||
"honcho>=2.0.0",
|
"honcho>=2.0.0",
|
||||||
"psutil>=7.2.2,<8.0.0",
|
"psutil>=7.2.2,<8.0.0",
|
||||||
@@ -64,11 +64,11 @@ prod = [
|
|||||||
]
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"django-debug-toolbar>=6.3.0,<7",
|
"django-debug-toolbar>=6.3.0,<7",
|
||||||
"ipython>=9.14.1,<10.0.0",
|
"ipython>=9.13.0,<10.0.0",
|
||||||
"pre-commit>=4.6.0,<5.0.0",
|
"pre-commit>=4.6.0,<5.0.0",
|
||||||
"ruff>=0.15.16,<1.0.0",
|
"ruff>=0.15.13,<1.0.0",
|
||||||
"djhtml>=3.0.11,<4.0.0",
|
"djhtml>=3.0.11,<4.0.0",
|
||||||
"faker>=40.21.0,<41.0.0",
|
"faker>=40.18.0,<41.0.0",
|
||||||
"rjsmin>=1.2.5,<2.0.0",
|
"rjsmin>=1.2.5,<2.0.0",
|
||||||
]
|
]
|
||||||
tests = [
|
tests = [
|
||||||
@@ -84,7 +84,7 @@ docs = [
|
|||||||
"mkdocs>=1.6.1,<2.0.0",
|
"mkdocs>=1.6.1,<2.0.0",
|
||||||
"mkdocs-material>=9.7.6,<10.0.0",
|
"mkdocs-material>=9.7.6,<10.0.0",
|
||||||
"mkdocstrings>=1.0.4,<2.0.0",
|
"mkdocstrings>=1.0.4,<2.0.0",
|
||||||
"mkdocstrings-python>=2.0.4,<3.0.0",
|
"mkdocstrings-python>=2.0.3,<3.0.0",
|
||||||
"mkdocs-include-markdown-plugin>=7.3.0,<8.0.0",
|
"mkdocs-include-markdown-plugin>=7.3.0,<8.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+5
-16
@@ -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,8 +91,7 @@ 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
|
||||||
|
|
||||||
@@ -115,7 +112,6 @@ INSTALLED_APPS = (
|
|||||||
"django_jinja",
|
"django_jinja",
|
||||||
"ninja_extra",
|
"ninja_extra",
|
||||||
"haystack",
|
"haystack",
|
||||||
"django_countries",
|
|
||||||
"django_celery_results",
|
"django_celery_results",
|
||||||
"django_celery_beat",
|
"django_celery_beat",
|
||||||
"captcha",
|
"captcha",
|
||||||
@@ -142,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"
|
||||||
@@ -295,11 +291,7 @@ USE_TZ = True
|
|||||||
|
|
||||||
LOCALE_PATHS = [BASE_DIR / "locale"]
|
LOCALE_PATHS = [BASE_DIR / "locale"]
|
||||||
|
|
||||||
# for PhoneNumberField
|
|
||||||
PHONENUMBER_DEFAULT_REGION = "FR"
|
PHONENUMBER_DEFAULT_REGION = "FR"
|
||||||
# for CountryField
|
|
||||||
COUNTRIES_FIRST = ["FR", "CH", "DE"]
|
|
||||||
COUNTRIES_FIRST_BREAK = "───────────"
|
|
||||||
|
|
||||||
# Medias
|
# Medias
|
||||||
MEDIA_URL = "/data/"
|
MEDIA_URL = "/data/"
|
||||||
@@ -417,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")),
|
||||||
@@ -579,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(
|
||||||
|
|||||||
@@ -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/",
|
||||||
|
|||||||
@@ -282,14 +282,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.4.1"
|
version = "8.4.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
|
{ url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -349,86 +349,86 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coverage"
|
name = "coverage"
|
||||||
version = "7.14.1"
|
version = "7.14.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" },
|
{ url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" },
|
{ url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" },
|
{ url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" },
|
{ url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" },
|
{ url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" },
|
{ url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" },
|
{ url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" },
|
{ url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" },
|
{ url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" },
|
{ url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" },
|
{ url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" },
|
{ url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" },
|
{ url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" },
|
{ url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" },
|
{ url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" },
|
{ url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" },
|
{ url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" },
|
{ url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" },
|
{ url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" },
|
{ url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" },
|
{ url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" },
|
{ url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" },
|
{ url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" },
|
{ url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" },
|
{ url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" },
|
{ url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" },
|
{ url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" },
|
{ url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" },
|
{ url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" },
|
{ url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" },
|
{ url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" },
|
{ url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" },
|
{ url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" },
|
{ url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" },
|
{ url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" },
|
{ url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" },
|
{ url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" },
|
{ url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" },
|
{ url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" },
|
{ url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" },
|
{ url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" },
|
{ url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" },
|
{ url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" },
|
{ url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" },
|
{ url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" },
|
{ url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" },
|
{ url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" },
|
{ url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" },
|
{ url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" },
|
{ url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" },
|
{ url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" },
|
{ url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" },
|
{ url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" },
|
{ url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" },
|
{ url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" },
|
{ url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" },
|
{ url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" },
|
{ url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" },
|
{ url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" },
|
{ url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -513,11 +513,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "distlib"
|
name = "distlib"
|
||||||
version = "0.4.1"
|
version = "0.4.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/86/b2/d6fc3f2347f43dada79e5ff118493e8109c98400a0e29a1d5264a3aa479b/distlib-0.4.1.tar.gz", hash = "sha256:c3804d0d2d4b5fcd44036eb860cb6660485fcdf5c2aba53dc324d805837ea65b", size = 610526, upload-time = "2026-06-02T11:17:40.691Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/18/3497c4fa83a76dcb154923fd2075522e8dd6995ecee4093c00ae18160046/distlib-0.4.1-py2.py3-none-any.whl", hash = "sha256:9c2c552c68cbadc619f2d0ed3a69e27c351a3f4c9baa9ffb7df9e9cdc3d19a97", size = 469216, upload-time = "2026-06-02T11:17:38.779Z" },
|
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -543,16 +543,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django"
|
name = "django"
|
||||||
version = "5.2.15"
|
version = "5.2.14"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asgiref" },
|
{ name = "asgiref" },
|
||||||
{ name = "sqlparse" },
|
{ name = "sqlparse" },
|
||||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/2b/e3/31722f7284c9f43333daff9aee9184678e4487adcb5506af0db8cea09ce1/django-5.2.15.tar.gz", hash = "sha256:5154a9bf84ac01dde011e367f355c07dbb329532e06810dcf3ef2af269e236e7", size = 10873669, upload-time = "2026-06-03T13:03:35.892Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/65/95/95f7faa0950867afaa0bef2460c6263afd6a2c78cc9434046ed28160b015/django-5.2.14.tar.gz", hash = "sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2", size = 10895118, upload-time = "2026-05-05T13:57:31.104Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/b5/38140b1643c00d5c46ce69c78e6980fd285aee223100319631bedee4f5e7/django-5.2.15-py3-none-any.whl", hash = "sha256:0eb4a9bb1853a35b0286dbc6d916bd352c8c2687195a7f2d6f80cefd840e4970", size = 8311957, upload-time = "2026-06-03T13:03:31.329Z" },
|
{ url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -622,13 +622,13 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-haystack"
|
name = "django-haystack"
|
||||||
version = "3.4.0"
|
version = "3.3.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "django" },
|
{ name = "django" },
|
||||||
{ name = "packaging" },
|
{ name = "packaging" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/08/00/2fee5c12b650bbc643f0b65ead96b6f7533f5fa76573449f814d820dda6f/django_haystack-3.4.0.tar.gz", hash = "sha256:1226a7c9ce13e1e7ead8ac83f6e87bfef4996f146ed24971fde7896115b1c530", size = 454662, upload-time = "2026-06-04T19:12:33.644Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/b4/09/623634ca5f8b9fe99d07d284491724eb1b0704022e942a1fe815c1d13a02/django_haystack-3.3.0.tar.gz", hash = "sha256:e3ceed6b8000625da14d409eb4dac69894905e2ac8ac18f9bfdb59323ca02eab", size = 467287, upload-time = "2024-06-04T15:09:58.707Z" }
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-honeypot"
|
name = "django-honeypot"
|
||||||
@@ -789,23 +789,23 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "faker"
|
name = "faker"
|
||||||
version = "40.21.0"
|
version = "40.18.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/6f/d7b251fb31de7dce0e482680bf7ca876aa0043f475c04aeefa1459ea80d4/faker-40.21.0.tar.gz", hash = "sha256:2fdee1b650a723a54432db9c6dfe17cfa29d1adc8bd60520444a07698524ba4d", size = 1970295, upload-time = "2026-06-02T17:53:46.27Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/18/06/70886e82d8f1d2b73454f3a7c1b7405300128df22e70d85a828951366932/faker-40.18.0.tar.gz", hash = "sha256:2207575c0e8f90e6ccd6dbef764de875c614d16d3db4eee9712d9a00087f2e70", size = 1968243, upload-time = "2026-05-14T16:43:04.834Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/77/6adb5a9dcd028f687f81fc9f789591f9572cb5a46454337122add004e134/faker-40.21.0-py3-none-any.whl", hash = "sha256:cb6601b2ae8e128895dc96814d271eab6b930a2d2d7932c6f9ff26785c24ee18", size = 2008808, upload-time = "2026-06-02T17:53:44.346Z" },
|
{ url = "https://files.pythonhosted.org/packages/84/0b/5c0b2d3a4b7a715f1835dd3f963bfbe841a02ae5cad1df8ee0325dfad235/faker-40.18.0-py3-none-any.whl", hash = "sha256:61a6b94b74605ddb090a065deb197a1c585ae7a874c094cf6693671d271e6083", size = 2006355, upload-time = "2026-05-14T16:43:02.489Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filelock"
|
name = "filelock"
|
||||||
version = "3.29.1"
|
version = "3.29.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/f9/f38573ed5844586db374d085911740a501ccfa373b455fc9413f09f85237/filelock-3.29.1.tar.gz", hash = "sha256:d97e6b1b9757569626c58caa07dc4beb1613f4a2938b1e8cc81afca398906c9e", size = 59335, upload-time = "2026-06-03T15:19:04.053Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/a0/614c5fe402fd88951df45f4dda2fa3b4e17a99ecd92340771929169b3b95/filelock-3.29.1-py3-none-any.whl", hash = "sha256:85199dfd706869641b72b2e8955d5416a4b2b7dc4b0e8e6d97b4cc1299a6983b", size = 40750, upload-time = "2026-06-03T15:19:02.959Z" },
|
{ url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -843,66 +843,62 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hiredis"
|
name = "hiredis"
|
||||||
version = "3.4.0"
|
version = "3.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/e2/1654d65851f39fd94e91a77a5655d09d4b64901fdc594020d8348db697b2/hiredis-3.4.0.tar.gz", hash = "sha256:da19331354433af6a2c54c21f2d70ba084933c0d7d2c43578ec5c5b446674ad5", size = 137169, upload-time = "2026-06-03T16:23:46.226Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/97/d6/9bef6dc3052c168c93fbf7e6c0f2b12c45f0f741a2d30fd919096774343a/hiredis-3.3.1.tar.gz", hash = "sha256:da6f0302360e99d32bc2869772692797ebadd536e1b826d0103c72ba49d38698", size = 89101, upload-time = "2026-03-16T15:21:08.092Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/84/f74deb132d238a0d5a3eb1618bf7558c65230b279421f909a9753231c516/hiredis-3.4.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:9e88048a66dfffec7a3f578f2a2a0fd907c75b5bd85b3c9184f76f0149ea399f", size = 138679, upload-time = "2026-06-03T16:22:17.598Z" },
|
{ url = "https://files.pythonhosted.org/packages/b3/1d/1a7d925d886211948ab9cca44221b1d9dd4d3481d015511e98794e37d369/hiredis-3.3.1-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:60543f3b068b16a86e99ed96b7fdae71cdc1d8abdfe9b3f82032a555e52ece7e", size = 82023, upload-time = "2026-03-16T15:19:34.157Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a2/13/399fe51d399b8d4f5717aa68cb1dafcb8c244b19b1b9b0afaaa526c1be94/hiredis-3.4.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:8b3f1d03046765c0a83558bf1756811101e3947649c7ca22a71d9dc3c92929d1", size = 74657, upload-time = "2026-06-03T16:22:18.819Z" },
|
{ url = "https://files.pythonhosted.org/packages/13/2f/a6017fe1db47cd63a4aefc0dd21dd4dcb0c4e857bfbcfaa27329745f24a3/hiredis-3.3.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:2611bfaaadc5e8d43fb7967f9bbf1110c8beaa83aee2f2d812c76f11cfb56c6a", size = 46215, upload-time = "2026-03-16T15:19:35.068Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/cf/6a0bcf454b1642997c4dd007bd89beada43f38b22781afdf475060e427ac/hiredis-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:24751054bb11353016d242d09a4a902ecf8f25e3b56fe396cccb6f056fdda016", size = 70115, upload-time = "2026-06-03T16:22:19.649Z" },
|
{ url = "https://files.pythonhosted.org/packages/77/4b/35a71d088c6934e162aa81c7e289fa3110a3aca84ab695d88dbd488c74a2/hiredis-3.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e3754ce60e1b11b0afad9a053481ff184d2ee24bea47099107156d1b84a84aa", size = 41861, upload-time = "2026-03-16T15:19:36.32Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/99/62340215f80e59680c79ae5080c5422311da105870c57bbefc5d87487025/hiredis-3.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:258f820cdd6ee6be39ae6a8ea94a76b8856d34113de6604f63bc81327ef06240", size = 306481, upload-time = "2026-06-03T16:22:20.608Z" },
|
{ url = "https://files.pythonhosted.org/packages/1f/54/904bc723a95926977764fefd6f0d46067579bac38fffc32b806f3f2c05c0/hiredis-3.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e89dabf436ee79b358fd970dcbed6333a36d91db73f27069ca24a02fb138a404", size = 170196, upload-time = "2026-03-16T15:19:37.274Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/be/97f349e5bb0dcab0ef28b15523443d9bbe81f8ccbd3dadff56594dfa82fe/hiredis-3.4.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3774461209688790734b5db8934400a4456493fc1a172fb5298cc5d72201aceb", size = 339560, upload-time = "2026-06-03T16:22:21.861Z" },
|
{ url = "https://files.pythonhosted.org/packages/1d/01/4e840cd4cb53c28578234708b08fb9ec9e41c2880acc0e269a7264e1b3af/hiredis-3.3.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4f7e242eab698ad0be5a4b2ec616fa856569c57455cc67c625fd567726290e5f", size = 181808, upload-time = "2026-03-16T15:19:38.637Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/3f/eb6a9632bcc13a3fbefce5de90090052fb1ae1cd3d57faf687f20149d592/hiredis-3.4.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccdb63363c82ea9cea2d48126bc8e9241437b8b3b36413e967647a17add59643", size = 351549, upload-time = "2026-06-03T16:22:22.969Z" },
|
{ url = "https://files.pythonhosted.org/packages/87/0d/fc845f06f8203ab76c401d4d2b97f9fb768e644b053a40f441f7dcc71f2d/hiredis-3.3.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53148a4e21057541b6d8e493b2ea1b500037ddf34433c391970036f3cbce00e3", size = 180577, upload-time = "2026-03-16T15:19:39.749Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/8c/440369f727dcb856f3eeda238d6e67781b180feaa831bd28997d8af10c3b/hiredis-3.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:452cff764acb30c106d1e33f1bdf03fa9d4a9b0a9c995d722d4d39c998b40582", size = 313066, upload-time = "2026-06-03T16:22:23.987Z" },
|
{ url = "https://files.pythonhosted.org/packages/52/3a/859afe2620666bf6d58eb977870c47d98af4999d473b50528b323918f3f7/hiredis-3.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25132902d3eff38781e0d54f27a0942ec849e3c07dbdce83c4d92b7e43c8dce", size = 172507, upload-time = "2026-03-16T15:19:40.87Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/d1/3d76c4d5c46cd2e7b38641f7c8b325e0cab7d49d565ea573256eb3837d0c/hiredis-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fb0a139cd52535f3e5a532816b5c36b3aea95817410fbf28ca4a676026347a5", size = 300827, upload-time = "2026-06-03T16:22:25.287Z" },
|
{ url = "https://files.pythonhosted.org/packages/60/a8/004349708ad8bf0d188d46049f846d3fe2d4a7a8d0d5a6a8ba024017d8b3/hiredis-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3fb6573efa15a29c12c0c0f7170b14e7c1347fe4bb39b6a15b779f46015cc929", size = 166339, upload-time = "2026-03-16T15:19:41.912Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/bc/d112dd9704ae47243a515fb021ec4d0b5a1b8d83a7a3eff3284c0248412d/hiredis-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:163d8c43e2706d23490532ea0de8736fc1493cfa52f0ee65f85b0f074f2fe017", size = 331284, upload-time = "2026-06-03T16:22:26.385Z" },
|
{ url = "https://files.pythonhosted.org/packages/c3/fb/bfc6df29381830c99bfd9e97ed3b6d75d9303866a28c23d51ab8c50f63e3/hiredis-3.3.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:487658e1db83c1ee9fbbac6a43039ea76957767a5987ffb16b590613f9e68297", size = 176766, upload-time = "2026-03-16T15:19:42.981Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e9/7b/8a4dc0a15e4658c81a9e79b2c167fbfbf750e0c1c7ef13e00e69d4273ced/hiredis-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4b8f52844cd260d7805eca55c834e3e06b4c0d5b53a4178143b92242c2517c0d", size = 332962, upload-time = "2026-06-03T16:22:27.392Z" },
|
{ url = "https://files.pythonhosted.org/packages/53/e7/f54aaad4559a413ec8b1043a89567a5a1f898426e4091b9af5e0f2120371/hiredis-3.3.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a1d190790ee39b8b7adeeb10fc4090dc4859eb4e75ed27bd8108710eef18f358", size = 170313, upload-time = "2026-03-16T15:19:44.082Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/52/d3d0bb234de8deb4cbd432cdc63d001a6cad1f9c05fe07d2fa652f8cf412/hiredis-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03374d663b0e025e4039757ef5fad02e3ff714f7a01e5b34c88de2a9c91359dc", size = 311698, upload-time = "2026-06-03T16:22:28.442Z" },
|
{ url = "https://files.pythonhosted.org/packages/60/51/b80394db4c74d4cba342fa4208f690a2739c16f1125c2a62ba1701b8e2b7/hiredis-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a42c7becd4c9ec4ab5769c754eb61112777bdc6e1c1525e2077389e193b5f5aa", size = 167964, upload-time = "2026-03-16T15:19:45.237Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/5b/54a052eccaf901703b57d7c28509e74341fa0da08d770f485345397ea1e5/hiredis-3.4.0-cp312-cp312-win32.whl", hash = "sha256:696e0a2118e1df5ccacf8ecf8abe528cf0c4f1f1d867f64c34579bef77778cdb", size = 38921, upload-time = "2026-06-03T16:22:29.39Z" },
|
{ url = "https://files.pythonhosted.org/packages/47/ef/5e438d1e058be57cdc1bafc1b1ec8ab43cc890c61447e88f8b878a0e32c3/hiredis-3.3.1-cp312-cp312-win32.whl", hash = "sha256:17ec8b524055a88b80d76c177dbbbe475a25c17c5bf4b67bdbdbd0629bcae838", size = 20532, upload-time = "2026-03-16T15:19:46.233Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/64/6508236eda66765fbe873d1d0a0722e38059302e96dc9915b162ff17b35a/hiredis-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:ee6b4beb79a71df67af15a8451366babc2687fcac674d5c6eacec4197e4ce8c1", size = 40090, upload-time = "2026-06-03T16:22:30.204Z" },
|
{ url = "https://files.pythonhosted.org/packages/e9/c6/39994b9c5646e7bf7d5e92170c07fd5f224ae9f34d95ff202f31845eb94b/hiredis-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:0fac4af8515e6cca74fc701169ae4dc9a71a90e9319c9d21006ec9454b43aa2f", size = 22381, upload-time = "2026-03-16T15:19:47.082Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/1c/7333aba1b4b7cef2591b244140aec0f1aad903397bbaa31c1858722b2fe4/hiredis-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:14524fdc751e3960d78d848872576b5442b40baae3cac14fbab1ba7ac523891f", size = 36875, upload-time = "2026-06-03T16:22:31.087Z" },
|
{ url = "https://files.pythonhosted.org/packages/d8/4b/c7f4d6d6643622f296395269e24b02c69d4ac72822f052b8cae16fa3af03/hiredis-3.3.1-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:afe3c3863f16704fb5d7c2c6ff56aaf9e054f6d269f7b4c9074c5476178d1aba", size = 82027, upload-time = "2026-03-16T15:19:48.002Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/e5/9e47dda8f1d55e77293c6cdf4169182b7f2f55b56913d1fb16a0ddf63a3d/hiredis-3.4.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:4f0e3536eea76c03435d411099d165850bc3c9d873efe62843b995027135a763", size = 138688, upload-time = "2026-06-03T16:22:31.825Z" },
|
{ url = "https://files.pythonhosted.org/packages/9b/45/198be960a7443d6eb5045751e929480929c0defbca316ce1a47d15187330/hiredis-3.3.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:f19ee7dc1ef8a6497570d91fa4057ba910ad98297a50b8c44ff37589f7c89d17", size = 46220, upload-time = "2026-03-16T15:19:48.953Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/07/039bcf7ce8262ed66db736349c121486874826248ccd70c98c2f830ec9da/hiredis-3.4.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:82860f050aabd08c046f304eb57c105bb3d5a7370f79a4a0b74d2b771767cc13", size = 74666, upload-time = "2026-06-03T16:22:32.758Z" },
|
{ url = "https://files.pythonhosted.org/packages/6a/a4/6ab925177f289830008dbe1488a9858675e2e234f48c9c1653bd4d0eaddc/hiredis-3.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:09f5e510f637f2c72d2a79fb3ad05f7b6211e057e367ca5c4f97bb3d8c9d71f4", size = 41858, upload-time = "2026-03-16T15:19:49.939Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/6d/692c50d846a0a36578e9ef0c62c6193ce01a48f353f6961de9de88a30b37/hiredis-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:74bcfb26189939daba2a0eb4bad05a6a30773bb2461f3d9967b8ced224bd0de9", size = 70119, upload-time = "2026-06-03T16:22:33.692Z" },
|
{ url = "https://files.pythonhosted.org/packages/fe/c8/a0ddbb9e9c27fcb0022f7b7e93abc75727cb634c6a5273ca5171033dac78/hiredis-3.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b46e96b50dad03495447860510daebd2c96fd44ed25ba8ccb03e9f89eaa9d34", size = 170095, upload-time = "2026-03-16T15:19:51.216Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/28/5d/c8b9ca711b4d6b7637eae744d6b45ea47f6bded61bac0232bb42ed8c583e/hiredis-3.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d95b602ab022f3505288ce51feaa48c072a62e57da55d6a7a38ecb8c5ad67d81", size = 306364, upload-time = "2026-06-03T16:22:34.62Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/06/618d509cc454912028f71995f3dd6eb54606f0aa8163ff79c5b7ec1f2bda/hiredis-3.3.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b4fe7f38aa8956fcc1cea270e62601e0e11066aff78e384be70fd283d30293b6", size = 181745, upload-time = "2026-03-16T15:19:52.72Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/7e/e940eea3c2ee1aa5947f2e6224f03a1dfd38a5813307259a25f580411820/hiredis-3.4.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de3e2297a182253dfa4400883a9a4fb46d44946aed3157ea2da873b93e2525c4", size = 339454, upload-time = "2026-06-03T16:22:35.87Z" },
|
{ url = "https://files.pythonhosted.org/packages/06/14/75b2deb62a61fc75a41ce1a6a781fe239133bbc88fef404d32a148ad152a/hiredis-3.3.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b96da7e365d6488d2a75266a662cbe3cc14b28c23dd9b0c9aa04b5bc5c20192", size = 180465, upload-time = "2026-03-16T15:19:53.847Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/ea/b8147da5c270a2a5b85090c97d0ff7e2fae6e7c5f7749f8c3c2decadd3ac/hiredis-3.4.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:454236d2a5bd917daf38914ce363e71aeef41240e6800f4799e04ee82689bfd2", size = 351457, upload-time = "2026-06-03T16:22:36.95Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/8c/8e03dcbfde8e2ca3f880fce06ad0877b3f098ed5fdfb17cf3b821a32323a/hiredis-3.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52d5641027d6731bc7b5e7d126a5158a99784a9f8c6de3d97ca89aca4969e9f8", size = 172419, upload-time = "2026-03-16T15:19:54.959Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/b5/ff8fe4f812348f09d2943b109cb64c5301af4f601e1cf026518e93a72fff/hiredis-3.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:35ab3653569b9867b8d8a3b4c0684a20dc769fe45d4666bedfe9a3391a61b30b", size = 312970, upload-time = "2026-06-03T16:22:38.004Z" },
|
{ url = "https://files.pythonhosted.org/packages/03/05/843005d68403a3805309075efc6638360a3ababa6cb4545163bf80c8e7f7/hiredis-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eddeb9a153795cf6e615f9f3cef66a1d573ff3b6ee16df2b10d1d1c2f2baeaa8", size = 166398, upload-time = "2026-03-16T15:19:56.36Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/2a/c90dff527cb2521ee1687e9e30bdf1156f2f4acfd47833b44dc52fec3ec6/hiredis-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:afff0876dafad6d3bb446c907da2836954876243f6bb9d5e44915d175e424aa4", size = 300850, upload-time = "2026-06-03T16:22:39.146Z" },
|
{ url = "https://files.pythonhosted.org/packages/f5/23/abe2476244fd792f5108009ec0ae666eaa5b2165ca19f2e86638d8324ac9/hiredis-3.3.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:011a9071c3df4885cac7f58a2623feac6c8e2ad30e6ba93c55195af05ce61ff5", size = 176844, upload-time = "2026-03-16T15:19:57.462Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/0b/c48e93a1e524198b10ccc26d770368547c0c29d126a992fd4b4aa533f1ac/hiredis-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d5c33eb2da5c9ccd281c396e1c618cfe6a91eb841e957f17d2fa520383b3111d", size = 331430, upload-time = "2026-06-03T16:22:40.248Z" },
|
{ url = "https://files.pythonhosted.org/packages/c6/47/e1cdccc559b98e548bcff0868c3938d375663418c0adca465895ee1f72e7/hiredis-3.3.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:264ee7e9cb6c30dc78da4ecf71d74cf14ca122817c665d838eda8b4384bce1b0", size = 170366, upload-time = "2026-03-16T15:19:58.548Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/12/ed5bdc482d5c98930ffa264dd707dfb04b83118b2f7f760760c5dfbe6782/hiredis-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:04e54fc3bcecf8c7cb2846947b84baf7ce1507caba641bd23590c52fefade865", size = 333021, upload-time = "2026-06-03T16:22:41.363Z" },
|
{ url = "https://files.pythonhosted.org/packages/a2/e1/fda8325f51d06877e8e92500b15d4aff3855b4c3c91dbd9636a82e4591f2/hiredis-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d1434d0bcc1b3ef048bae53f26456405c08aeed9827e65b24094f5f3a6793f1", size = 168023, upload-time = "2026-03-16T15:19:59.727Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e6/42/d4a2e7be82f2b2db7b67ec622806ba099d8fe09d218568f71197922cbe79/hiredis-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5f1ddfe6429f9adc0a8d705afbcd40530fddeafa919873ffbb11f59eda44dbb9", size = 311747, upload-time = "2026-06-03T16:22:42.374Z" },
|
{ url = "https://files.pythonhosted.org/packages/cd/21/2839d1625095989c116470e2b6841bbe1a2a5509585e82a4f3f5cd47f511/hiredis-3.3.1-cp313-cp313-win32.whl", hash = "sha256:f915a34fb742e23d0d61573349aa45d6f74037fde9d58a9f340435eff8d62736", size = 20535, upload-time = "2026-03-16T15:20:00.938Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/33/b5ac3420bd803ca9affd68a4a2a6111812bd26bfb9d6b41a721e009d79d9/hiredis-3.4.0-cp313-cp313-win32.whl", hash = "sha256:165e6405b48f9bd66ddb4ad52ce28b0c0041a0308654d7a0cb4357a1939134dc", size = 38921, upload-time = "2026-06-03T16:22:43.513Z" },
|
{ url = "https://files.pythonhosted.org/packages/84/f9/534c2a89b24445a9a9623beb4697fd72b8c8f16286f6f3bda012c7af004a/hiredis-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:d8e56e0d1fe607bfff422633f313aec9191c3859ab99d11ff097e3e6e068000c", size = 22383, upload-time = "2026-03-16T15:20:01.865Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/e7/76e68122b1cf680b93b951a82953fff5b5883dc08ec93f63677eb3653591/hiredis-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:306aae11a52e495aaf0a14e3efcd7b51029e632c74b847bc03159e1e1f6db591", size = 40095, upload-time = "2026-06-03T16:22:44.296Z" },
|
{ url = "https://files.pythonhosted.org/packages/03/72/0450d6b449da58120c5497346eb707738f8f67b9e60c28a8ef90133fc81f/hiredis-3.3.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:439f9a5cc8f9519ce208a24cdebfa0440fef26aa682a40ba2c92acb10a53f5e0", size = 82112, upload-time = "2026-03-16T15:20:02.865Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/20/05/9313dc27ed159512dc22b4ecf8a62a84d0aa5fbd500ffdad955b361cb2a8/hiredis-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:975a8e75a10425442037dd9c7abbaae31941c34328d9f01b1ca42d9db44ac31d", size = 36884, upload-time = "2026-06-03T16:22:45.134Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/c0/0be33a29bcd463e6cbb0282515dd4d0cdfe33c30c7afc6d4d8c460e23266/hiredis-3.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3724f0e58c6ff76fd683429945491de71324ab1bc0ad943a8d68cb0932d24075", size = 46238, upload-time = "2026-03-16T15:20:03.896Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ab/ea/cbc922aeaa5af11f1c1235d8b2b04ff8cdf6e3e95c785a500521f32d8d70/hiredis-3.4.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d3a12ae5685e9621a988af07b5af0ad685c7d19d6a7246ac852e35060178cff4", size = 138762, upload-time = "2026-06-03T16:22:45.927Z" },
|
{ url = "https://files.pythonhosted.org/packages/62/f2/f999854bfaf3bcbee0f797f24706c182ecfaca825f6a582f6281a6aa97e0/hiredis-3.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29fe35e3c6fe03204e75c86514f452591957a1e06b05d86e10d795455b71c355", size = 41891, upload-time = "2026-03-16T15:20:04.939Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/e9/e004067ffad9f707174cde04d117c985d5f22dd4d9409f0983892738cb44/hiredis-3.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a70df45cf167b5af99b9fe3e2044716919e30580a869dfa766f2a6467c0c320", size = 74696, upload-time = "2026-06-03T16:22:46.924Z" },
|
{ url = "https://files.pythonhosted.org/packages/f2/c8/cd9ab90fec3a301d864d8ab6167aea387add8e2287969d89cbcd45d6b0e0/hiredis-3.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d42f3a13290f89191568fc113d95a3d2c8759cdd8c3672f021d8b7436f909e75", size = 170485, upload-time = "2026-03-16T15:20:06.284Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/d1/5fe5b6d05e59116d78f9d228d9cc0022efbb84d234333c5fbe6a0c6e13fe/hiredis-3.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0a68b0e48509e6e66f4c212e53d98f29178addf83b0701a71bf0fce792954419", size = 70163, upload-time = "2026-06-03T16:22:47.798Z" },
|
{ url = "https://files.pythonhosted.org/packages/ac/9a/1ddf9ea236a292963146cbaf6722abeb9d503ca47d821267bb8b3b81c4f7/hiredis-3.3.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2afc675b831f7552da41116fffffca4340f387dc03f56d6ec0c7895ab0b59a10", size = 182030, upload-time = "2026-03-16T15:20:07.857Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/db/93/c86f0a7ae2cd10b72e30476f87aafd1af22992e080feb4b5d2ec1cbdf4e4/hiredis-3.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a45822bc8487da8151fe67c788de74b834582b1d510c67b888fcda64bf6ba4bb", size = 306631, upload-time = "2026-06-03T16:22:48.671Z" },
|
{ url = "https://files.pythonhosted.org/packages/d4/b8/e070a1dbf8a1bbb8814baa0b00836fbe3f10c7af8e11f942cc739c64e062/hiredis-3.3.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4106201cd052d9eabe3cb7b5a24b0fe37307792bda4fcb3cf6ddd72f697828e8", size = 180543, upload-time = "2026-03-16T15:20:09.096Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/10/3746b028d9c43fab1fa4126fe69c6967df89ab9819140092930322b0550c/hiredis-3.4.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b82cab9ad7a1574ab273a78942f780c1b1496101eb342b630c46c3e918ca21b", size = 339758, upload-time = "2026-06-03T16:22:49.662Z" },
|
{ url = "https://files.pythonhosted.org/packages/0d/bb/b5f4f98e44626e2446cd8a52ce6cb1fc1c99786b6e2db3bf09cea97b90cd/hiredis-3.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8887bf0f31e4b550bd988c8863b527b6587d200653e9375cd91eea2b944b7424", size = 172356, upload-time = "2026-03-16T15:20:10.245Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/f3/c6fb383854237891039a4d94d3e66dc5eec8a2993fed6020c983d63c5393/hiredis-3.4.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db13f8039ad8229f77f0e242be14e53bd67e8f3aadeb16f3af30944287cca092", size = 351360, upload-time = "2026-06-03T16:22:50.779Z" },
|
{ url = "https://files.pythonhosted.org/packages/ef/93/73a77b54ba94e82f76d02563c588d8a062513062675f483a033a43015f2c/hiredis-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ac7697365dbe45109273b34227fee6826b276ead9a4a007e0877e1d3f0fcf21", size = 166433, upload-time = "2026-03-16T15:20:11.789Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/b7/32110aa458690722a1069c7349b8ebe374a6ba0bdf9ef8925a9f37a74978/hiredis-3.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54b6267918c66d8ba4a3cf519db1235a4bd56d2a0969ca5b2ae3c6b6b7d9ed79", size = 313070, upload-time = "2026-06-03T16:22:51.966Z" },
|
{ url = "https://files.pythonhosted.org/packages/f3/c2/1b2dcbe5dc53a46a8cb05bed67d190a7e30bad2ad1f727ebe154dfeededd/hiredis-3.3.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2b6da6e07359107c653a809b3cff2d9ccaeedbafe33c6f16434aef6f53ce4a2b", size = 177220, upload-time = "2026-03-16T15:20:12.991Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/23/bccfa0fb7b1b529cff35c8725cfd99a2d18fa4123f52f52bf03e84210855/hiredis-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:88396e6a24b80c86f4dc180964d9cc467ba3aa3c886af6532fe077c5a5dc0c3c", size = 300927, upload-time = "2026-06-03T16:22:53.085Z" },
|
{ url = "https://files.pythonhosted.org/packages/02/09/f4314cf096552568b5ea785ceb60c424771f4d35a76c410ad39d258f74bc/hiredis-3.3.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:ce334915f5d31048f76a42c607bf26687cf045eb1bc852b7340f09729c6a64fc", size = 170475, upload-time = "2026-03-16T15:20:14.519Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/0f/e1e2295ee863efc7ce8c88ec10bcc4b1504352373998cb493f10e900dbe5/hiredis-3.4.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:73dd607b47863633d8070f1eb3bab1b3b097ee747783fe69c0dd0f93ec673d8b", size = 331764, upload-time = "2026-06-03T16:22:54.194Z" },
|
{ url = "https://files.pythonhosted.org/packages/b1/2e/3f56e438efc8fc27ed4a3dbad58c0280061466473ec35d8f86c90c841a84/hiredis-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee11fd431f83d8a5b29d370b9d79a814d3218d30113bdcd44657e9bdf715fc92", size = 167913, upload-time = "2026-03-16T15:20:15.672Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/df/11b1de2ac85dfd7a8713d72a6ed7ac0f1a6e28d906bd362e0df3a27f5c86/hiredis-3.4.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e6e8d5fa63ec2a0738d188488e828818cbe4cb4d37c0c706836cf3888d82c53d", size = 333144, upload-time = "2026-06-03T16:22:55.277Z" },
|
{ url = "https://files.pythonhosted.org/packages/56/34/053e5ee91d6dc478faac661996d1fd4886c5acb7a1b5ac30e7d3c794bb51/hiredis-3.3.1-cp314-cp314-win32.whl", hash = "sha256:e0356561b4a97c83b9ee3de657a41b8d1a1781226853adaf47b550bb988fda6f", size = 21167, upload-time = "2026-03-16T15:20:17.013Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/10/4b104565c936d51b4b02597352ec068937c9d6a73a3c4c9609c08ae3923e/hiredis-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d77901d058923a09ed25063ea6fb2842c153bbe75060a46e3949e73ad12ce352", size = 311593, upload-time = "2026-06-03T16:22:56.573Z" },
|
{ url = "https://files.pythonhosted.org/packages/ea/33/06776c641d17881a9031e337e81b3b934c38c2adbb83c85062d6b5f83b72/hiredis-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:80aba5f85d6227faee628ae28d1c3b69c661806a0636548ac56c68782606454f", size = 23000, upload-time = "2026-03-16T15:20:17.966Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/ae/c9eda3c116bef50fcf0dc7e44379e3577f3627caca4ffd7af04675b02d98/hiredis-3.4.0-cp314-cp314-win32.whl", hash = "sha256:05384fcfe5851b5af868bf24265c14ab86f38562679f9c6f712895b67a98163c", size = 39662, upload-time = "2026-06-03T16:22:57.683Z" },
|
{ url = "https://files.pythonhosted.org/packages/dd/5a/94f9a505b2ff5376d4a05fb279b69d89bafa7219dd33f6944026e3e56f80/hiredis-3.3.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:907f7b5501a534030738f0f27459a612d2266fd0507b007bb8f3e6de08167920", size = 83039, upload-time = "2026-03-16T15:20:19.316Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/c8/cedb336a0386a97271761ace460a362cb2433c6cdf1d1ba760ad99225734/hiredis-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:53233656e4fecf9f8ec654f1f4c5d445bf1c2957d7f63ffdedbba2682c9d1584", size = 40682, upload-time = "2026-06-03T16:22:58.526Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/ae/d3752a8f03a1fca43d402389d2a2d234d3db54c4d1f07f26c1041ca3c5de/hiredis-3.3.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:de94b409f49eb6a588ebdd5872e826caec417cd77c17af0fb94f2128427f1a2a", size = 46703, upload-time = "2026-03-16T15:20:20.401Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/ea/3a05247ce4e2afe56f59d24b73ba38e37f2b324dba8290beba56fbd9fd1f/hiredis-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3348ba4e101f3a96c927447ff2edcb3e0026dc6df375ba117485a43edcbb6980", size = 37541, upload-time = "2026-06-03T16:22:59.307Z" },
|
{ url = "https://files.pythonhosted.org/packages/9f/76/e32c868a2fa23cd82bacaffd38649d938173244a0e717ec1c0c76874dbdd/hiredis-3.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79cd03e7ff550c17758a7520bf437c156d3d4c8bb74214deeafa69cda49c85a4", size = 42379, upload-time = "2026-03-16T15:20:21.705Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/14/caeaa1be1205ebdc1cf6760c5f6882afbdb3b82a6bdf0559d01205b1c857/hiredis-3.4.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:3159c54fe560aa30bf1ab76e65c4c23dc45ad79d7cf4aecc25ec9942f5ea4cea", size = 139787, upload-time = "2026-06-03T16:23:00.139Z" },
|
{ url = "https://files.pythonhosted.org/packages/c9/f6/d687d36a74ce6cf448826cf2e8edfc1eb37cc965308f74eb696aa97c69df/hiredis-3.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ffa7ba2e2da1f806f3181b9730b3e87ba9dbfec884806725d4584055ba3faa6", size = 180311, upload-time = "2026-03-16T15:20:23.037Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/85/8f52b485b9d835e0f8da063a635290d916a6f5ab60c18db5411ecea344d1/hiredis-3.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:be4a41496a0a48c3abf57ef1bbeb11980060ce9c7a1dd8b92caa028a813a9c59", size = 75136, upload-time = "2026-06-03T16:23:01.705Z" },
|
{ url = "https://files.pythonhosted.org/packages/db/ac/f520dc0066a62a15aa920c7dd0a2028c213f4862d5f901409ae92ee5d785/hiredis-3.3.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ee37fe8cf081b72dea72f96a0ee604f492ec02252eb77dc26ff6eec3f997b580", size = 190488, upload-time = "2026-03-16T15:20:24.357Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/09/ee568562f36f481395d5cea3ab75fd9350cd77d98d55ee5f9b395f3fc358/hiredis-3.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2f9a9a591b3eaade523f3e778dfcd8684965ee6e954ae25cd2fd6d8c75e881d", size = 70772, upload-time = "2026-06-03T16:23:02.765Z" },
|
{ url = "https://files.pythonhosted.org/packages/4d/f5/ae10fff82d0f291e90c41bf10a5d6543a96aae00cccede01bf2b6f7e178d/hiredis-3.3.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9bfdeff778d3f7ff449ca5922ab773899e7d31e26a576028b06a5e9cf0ed8c34", size = 189210, upload-time = "2026-03-16T15:20:25.51Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/0d/3cb03fbbe72f86541f42ee49dba95ff428c87908815152970fbf24bdcf4c/hiredis-3.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c2852eaa26c0a73be4a30118cd5ad6a77c095d224ccb5ac38e40cb865747d22", size = 315571, upload-time = "2026-06-03T16:23:03.826Z" },
|
{ url = "https://files.pythonhosted.org/packages/0f/8f/5be4344e542aa8d349a03d05486c59d9ca26f69c749d11e114bf34b84d50/hiredis-3.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:027ce4fabfeff5af5b9869d5524770877f9061d118bc36b85703ae3faf5aad8e", size = 180971, upload-time = "2026-03-16T15:20:26.631Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/fc/c8667282e41153bc20930aeba8ba0dff989cbaa9eb7594f8bcac02558dea/hiredis-3.4.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18ff3d9b23ebe6c8248c3debca2402ad209d60c48495e7ed76407c2fe54cb9b4", size = 348131, upload-time = "2026-06-03T16:23:05.077Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/a2/29e230226ec2a31f13f8a832fbafe366e263f3b090553ebe49bb4581a7bd/hiredis-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:dcea8c3f53674ae68e44b12e853b844a1d315250ca6677b11ec0c06aff85e86c", size = 175314, upload-time = "2026-03-16T15:20:27.848Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/13/5431ace8330904b2b9d9ce5425c13b7a8fa2b443ff272a92f248c07e6400/hiredis-3.4.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:94f83352295bf3d332678689ecd4ce190a4d233a20ad2f432724efd3ce03e49a", size = 359915, upload-time = "2026-06-03T16:23:06.293Z" },
|
{ url = "https://files.pythonhosted.org/packages/89/2e/bf241707ad86b9f3ebfbc7ab89e19d5ec243ff92ca77644a383622e8740b/hiredis-3.3.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0b5ff2f643f4b452b0597b7fe6aa35d398cb31d8806801acfafb1558610ea2aa", size = 185652, upload-time = "2026-03-16T15:20:29.364Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/be/57/30dab05cf2a70905e5d2807edd4afa30a4747599070faf80f18e61375e11/hiredis-3.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:393d5e7c8c67cdddf7109a8e925d885e788f3f43e5b1043f84390df40c59944b", size = 321426, upload-time = "2026-06-03T16:23:07.447Z" },
|
{ url = "https://files.pythonhosted.org/packages/d0/c1/b39170d8bcccd01febd45af4ac6b43ff38e134a868e2ec167a82a036fb35/hiredis-3.3.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3586c8a5f56d34b9dddaaa9e76905f31933cac267251006adf86ec0eef7d0400", size = 179033, upload-time = "2026-03-16T15:20:30.549Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/6f/0a6e030d96d927000735b39aa8b8fef03b43fafdf4a79c80755be351a0f5/hiredis-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7e7ab4c1c8c4d365b02d9e82cdf25b01a065edf2ededd7b5acb043201ff80203", size = 309862, upload-time = "2026-06-03T16:23:08.672Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/3a/4fe39a169115434f911abff08ff485b9b6201c168500e112b3f6a8110c0a/hiredis-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a110d19881ca78a88583d3b07231e7c6864864f5f1f3491b638863ea45fa8708", size = 176126, upload-time = "2026-03-16T15:20:31.958Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/11/48/26b2771d2b2403124c1f97c2a6d45df0ba3fa59f0c2d4d244e90543722fb/hiredis-3.4.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:cfe23f8dcf2c0f4e03d107ff68a9ee9707f9d76abeddbe59633e5de1564a650c", size = 339568, upload-time = "2026-06-03T16:23:09.755Z" },
|
{ url = "https://files.pythonhosted.org/packages/44/99/c1d0b0bc4f9e9150e24beb0dca2e186e32d5e749d0022e0d26453749ed51/hiredis-3.3.1-cp314-cp314t-win32.whl", hash = "sha256:98fd5b39410e9d69e10e90d0330e35650becaa5dd2548f509b9598f1f3c6124d", size = 22028, upload-time = "2026-03-16T15:20:33.33Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/b1/01c18f676d5dea65e894c01ffae8da2f15df1fceed1c69b16877ba57be60/hiredis-3.4.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a7e76904148c229549db7240a4f9963deb8bb328c0c0844fc9f2320aca05b530", size = 341424, upload-time = "2026-06-03T16:23:10.964Z" },
|
{ url = "https://files.pythonhosted.org/packages/35/d6/191e6741addc97bcf5e755661f8c82f0fd0aa35f07ece56e858da689b57e/hiredis-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ab1f646ff531d70bfd25f01e60708dfa3d105eb458b7dedd9fe9a443039fd809", size = 23811, upload-time = "2026-03-16T15:20:34.292Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/58/ab3a5672e506f282e1dd6dfb1c0c3f7e17f02398280c2a2994f8d7b478ba/hiredis-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:92b570225f6097430615a82543c3eb7974ca354738a6cef38053138f7d983151", size = 320386, upload-time = "2026-06-03T16:23:12.174Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/af/3f26324cca720f56ace408883c1c7311ce71b571e82e6434515f7ba4eb59/hiredis-3.4.0-cp314-cp314t-win32.whl", hash = "sha256:decc176d86127c620b5d280b3fe5f97a788be58ca945971f3852c3bf54f4d5ad", size = 40516, upload-time = "2026-06-03T16:23:13.179Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/18/e011a424a9608ff152ebeb7bbae2be3163e5716e92cf75baddcb5a8fc312/hiredis-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:05c852c58fec65d4c9fb861372dd7391d8b2ce96c960ba8714145f8cd85cd0ec", size = 41453, upload-time = "2026-06-03T16:23:14.091Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/5f/829287555ce7286be8d6c87c69f93aa1f38fe67c46740806416142231cf3/hiredis-3.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7ff29c9f5d3c91fda948c2fde58f457b3244550781d3bc0891b1b9d93c10f47f", size = 37968, upload-time = "2026-06-03T16:23:14.948Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -936,7 +932,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ical"
|
name = "ical"
|
||||||
version = "13.2.5"
|
version = "13.2.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"python_full_version >= '3.13'",
|
"python_full_version >= '3.13'",
|
||||||
@@ -946,9 +942,9 @@ dependencies = [
|
|||||||
{ name = "python-dateutil", marker = "python_full_version >= '3.13'" },
|
{ name = "python-dateutil", marker = "python_full_version >= '3.13'" },
|
||||||
{ name = "tzdata", marker = "python_full_version >= '3.13'" },
|
{ name = "tzdata", marker = "python_full_version >= '3.13'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/1c/dc7378c5ba63e8b0f2987bd827a1e40bd27196d37a7082be0593551e8ed8/ical-13.2.5.tar.gz", hash = "sha256:1cc3116e6f522eeeaa694b3e20b58dbfbe4b281954a8d3d28892cfb61f03fd40", size = 131405, upload-time = "2026-05-24T16:51:56.017Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/5c/da/810acf4d830cfe6a7fc05da04d993106c832dc70d3de81567706d704e877/ical-13.2.2.tar.gz", hash = "sha256:160e4f33903d2a5b4e4c4304b2bf31f4b9050f86ff7c55dc937a998ac021f271", size = 130219, upload-time = "2026-03-16T04:22:45.204Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/69/08/b377a57bcaaf7f63c53137e59655bf33957b49df90394a2e0d0d554a049f/ical-13.2.5-py3-none-any.whl", hash = "sha256:410c195c59244c59bd6d1ffd6b27fb96ab29cf063b7359b531ee5de055f32486", size = 128448, upload-time = "2026-05-24T16:51:54.233Z" },
|
{ url = "https://files.pythonhosted.org/packages/8d/7c/50e9a34bc42dc0523b833cd0de090c1aa1ee98e9b40c327542cbf51c53f5/ical-13.2.2-py3-none-any.whl", hash = "sha256:a00ba88d9154cd9bf3609249f3bef65a90eb462c19137e3e546fdd237b89ea2d", size = 128235, upload-time = "2026-03-16T04:22:42.979Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -962,11 +958,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.18"
|
version = "3.15"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -998,7 +994,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipython"
|
name = "ipython"
|
||||||
version = "9.14.1"
|
version = "9.13.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
@@ -1008,14 +1004,14 @@ dependencies = [
|
|||||||
{ name = "matplotlib-inline" },
|
{ name = "matplotlib-inline" },
|
||||||
{ name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
{ name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
{ name = "prompt-toolkit" },
|
{ name = "prompt-toolkit" },
|
||||||
{ name = "psutil", marker = "sys_platform != 'emscripten'" },
|
{ name = "psutil" },
|
||||||
{ name = "pygments" },
|
{ name = "pygments" },
|
||||||
{ name = "stack-data" },
|
{ name = "stack-data" },
|
||||||
{ name = "traitlets" },
|
{ name = "traitlets" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e2/23/3a27530575643c8bb7bfc757a28e2e7ef80092afbf59a2bc5716320b6602/ipython-9.14.1.tar.gz", hash = "sha256:f913bf74df06d458e46ced84ca506c23797590d594b236fe60b14df213291e7b", size = 4433457, upload-time = "2026-06-05T08:12:34.921Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/87cda5842cf5c31837c06ddb588e11c3c35d8ece89b7a0108c06b8c9b00a/ipython-9.13.0.tar.gz", hash = "sha256:7e834b6afc99f020e3f05966ced34792f40267d64cb1ea9043886dab0dde5967", size = 4430549, upload-time = "2026-04-24T12:24:55.221Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/22/58818a63eaf8982b67632b1bc20585c811611b15a8da19d6012323dc76a5/ipython-9.14.1-py3-none-any.whl", hash = "sha256:5d4a9ecaa3b10e6e5f269dd0948bdb58ca9cb851899cd23e07c320d3eb11613c", size = 627770, upload-time = "2026-06-05T08:12:33.045Z" },
|
{ url = "https://files.pythonhosted.org/packages/b9/86/3060e8029b7cc505cce9a0137431dda81d0a3fde93a8f0f50ee0bf37a795/ipython-9.13.0-py3-none-any.whl", hash = "sha256:57f9d4639e20818d328d287c7b549af3d05f12486ea8f2e7f73e52a36ec4d201", size = 627274, upload-time = "2026-04-24T12:24:53.038Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1393,16 +1389,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mkdocstrings-python"
|
name = "mkdocstrings-python"
|
||||||
version = "2.0.4"
|
version = "2.0.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "griffelib" },
|
{ name = "griffelib" },
|
||||||
{ name = "mkdocs-autorefs" },
|
{ name = "mkdocs-autorefs" },
|
||||||
{ name = "mkdocstrings" },
|
{ name = "mkdocstrings" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/b4/5fed370d8ebd96e4e399460a7146ae989263f16588b05a6facd6dbd51e60/mkdocstrings_python-2.0.4.tar.gz", hash = "sha256:58c73c5d358e64e9b1673447663f4a2f8a8941e392e225fc0a0c893758cc452f", size = 199219, upload-time = "2026-06-05T08:13:01.819Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/e3/00ec594aef5f55522e6d373bc2ac53e53a8f5e9ae32f2d6854b0de4270f3/mkdocstrings_python-2.0.4-py3-none-any.whl", hash = "sha256:fd87c173e1e719a85997b6d4f852cdc55f36710e0ed08da3a7bd9abe79c9db00", size = 104790, upload-time = "2026-06-05T08:13:00.393Z" },
|
{ url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1476,11 +1472,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phonenumbers"
|
name = "phonenumbers"
|
||||||
version = "9.0.32"
|
version = "9.0.30"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/11/ba7611cadc8c99b797416d0dada535c02890dc21b759dfef60a3751d2e20/phonenumbers-9.0.32.tar.gz", hash = "sha256:108ad0237202d2f6cf4b342fac411f22808d85187c3a366152a2af7ed3202a8e", size = 2306598, upload-time = "2026-06-05T05:48:38.909Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/a6/f1/249f843f4107c6a6ed17e5ece17620d75e532c2a355106e26d889a0c72c7/phonenumbers-9.0.30.tar.gz", hash = "sha256:d42d232ccde69c1af1bb5916a7e46f4edbcc72975b02759830f4ea1fba7b00c9", size = 2306521, upload-time = "2026-05-07T10:20:38.884Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/e9/1ca526105e792b7ed752fd0ff5732ab01185f2deb719ae6f30183fb21143/phonenumbers-9.0.32-py2.py3-none-any.whl", hash = "sha256:fbcd40d3b11920ee77b1d34a54c3b64c6648a90b8e37222eb62e7fafb87db3e7", size = 2595438, upload-time = "2026-06-05T05:48:36.066Z" },
|
{ url = "https://files.pythonhosted.org/packages/83/22/e4442aabea04daf16fda50d89bce2ff585e44f204089986b2cc6679cae10/phonenumbers-9.0.30-py2.py3-none-any.whl", hash = "sha256:e0890d4cda206ef6ac18ef07e8f3ab225c31c7edce237ac870b4729d4c1d2520", size = 2595222, upload-time = "2026-05-07T10:20:35.387Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1554,11 +1550,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "platformdirs"
|
name = "platformdirs"
|
||||||
version = "4.10.0"
|
version = "4.9.6"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
|
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1867,15 +1863,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-discovery"
|
name = "python-discovery"
|
||||||
version = "1.4.0"
|
version = "1.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "filelock" },
|
{ name = "filelock" },
|
||||||
{ name = "platformdirs" },
|
{ name = "platformdirs" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/12/38c1a0b1e64806780c9563e3fc9f6e472251839662587cfbe9bfaf2ae10a/python_discovery-1.4.0.tar.gz", hash = "sha256:eb8bc7daad3c226c147e45bb4e970a1feb1bf4048ee178e6db59e197b8010ce3", size = 68455, upload-time = "2026-05-28T01:15:37.639Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl", hash = "sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da", size = 33217, upload-time = "2026-05-28T01:15:36.573Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2036,40 +2032,40 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.16"
|
version = "0.15.13"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" },
|
{ url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" },
|
{ url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" },
|
{ url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" },
|
{ url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" },
|
{ url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" },
|
{ url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" },
|
{ url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" },
|
{ url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" },
|
{ url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" },
|
{ url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" },
|
{ url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" },
|
{ url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" },
|
{ url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" },
|
{ url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sentry-sdk"
|
name = "sentry-sdk"
|
||||||
version = "2.61.1"
|
version = "2.60.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "certifi" },
|
{ name = "certifi" },
|
||||||
{ name = "urllib3" },
|
{ name = "urllib3" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/63/3b/4bc6b348bbd331daa14d4babe9f2b99bc854f4da41560eefb9488d78481d/sentry_sdk-2.61.1.tar.gz", hash = "sha256:9c6adccb3feefa9ba032c8d295ca477575c2f11896046a2b0ad686c47c4af555", size = 459429, upload-time = "2026-06-01T07:24:18.875Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/54/a2/2e6c090db384cc515069f4f85542bd5baf6786852073020ea73d4a76d3ea/sentry_sdk-2.60.0.tar.gz", hash = "sha256:0bd25e54e78ca02d0be512529fa644bbbf9e8470d7b26371294012d4ca93c978", size = 452946, upload-time = "2026-05-13T13:34:52.516Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/54/c9218db183846e08efaf68534889ef42e499dde432778881104a42f7071b/sentry_sdk-2.61.1-py3-none-any.whl", hash = "sha256:fa36eaf4b8ad708f718500d4bdcc1532637526a22beb874d88cbc0a46458b5ae", size = 483735, upload-time = "2026-06-01T07:24:17.027Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/41/f2b800b7f12a05dd48c2a6280d4dd812d1425fc66ed3fe3fd99420c41d1a/sentry_sdk-2.60.0-py3-none-any.whl", hash = "sha256:28a536c03291c8bcb363cf35c611b32738ec118ff64d8d6383b096448ac4c803", size = 475616, upload-time = "2026-05-13T13:34:50.259Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2095,7 +2091,7 @@ dependencies = [
|
|||||||
{ name = "environs", extra = ["django"] },
|
{ name = "environs", extra = ["django"] },
|
||||||
{ name = "honcho" },
|
{ name = "honcho" },
|
||||||
{ name = "ical", version = "12.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
|
{ name = "ical", version = "12.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
|
||||||
{ name = "ical", version = "13.2.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
|
{ name = "ical", version = "13.2.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
{ name = "libsass" },
|
{ name = "libsass" },
|
||||||
{ name = "mistune" },
|
{ name = "mistune" },
|
||||||
@@ -2148,11 +2144,11 @@ requires-dist = [
|
|||||||
{ name = "celery", extras = ["redis"], specifier = ">=5.6.3,<8" },
|
{ name = "celery", extras = ["redis"], specifier = ">=5.6.3,<8" },
|
||||||
{ name = "cryptography", specifier = ">=48.0.0,<49.0.0" },
|
{ name = "cryptography", specifier = ">=48.0.0,<49.0.0" },
|
||||||
{ name = "dict2xml", specifier = ">=1.7.8,<2.0.0" },
|
{ name = "dict2xml", specifier = ">=1.7.8,<2.0.0" },
|
||||||
{ name = "django", specifier = ">=5.2.15,<6.0.0" },
|
{ name = "django", specifier = ">=5.2.14,<6.0.0" },
|
||||||
{ name = "django-celery-beat", specifier = ">=2.9.0" },
|
{ name = "django-celery-beat", specifier = ">=2.9.0" },
|
||||||
{ name = "django-celery-results", specifier = ">=2.6.0" },
|
{ name = "django-celery-results", specifier = ">=2.6.0" },
|
||||||
{ name = "django-countries", specifier = ">=8.2.0,<9.0.0" },
|
{ name = "django-countries", specifier = ">=8.2.0,<9.0.0" },
|
||||||
{ name = "django-haystack", specifier = ">=3.4.0,<4.0.0" },
|
{ name = "django-haystack", specifier = ">=3.3.0,<4.0.0" },
|
||||||
{ name = "django-honeypot", specifier = ">=1.3.0,<2" },
|
{ name = "django-honeypot", specifier = ">=1.3.0,<2" },
|
||||||
{ name = "django-jinja", specifier = ">=2.11.0,<3.0.0" },
|
{ name = "django-jinja", specifier = ">=2.11.0,<3.0.0" },
|
||||||
{ name = "django-ninja", specifier = ">=1.6.2,<2.0.0" },
|
{ name = "django-ninja", specifier = ">=1.6.2,<2.0.0" },
|
||||||
@@ -2160,21 +2156,21 @@ requires-dist = [
|
|||||||
{ name = "django-ordered-model", specifier = ">=3.7.4,<4.0.0" },
|
{ name = "django-ordered-model", specifier = ">=3.7.4,<4.0.0" },
|
||||||
{ name = "django-phonenumber-field", specifier = ">=8.4.0,<9.0.0" },
|
{ name = "django-phonenumber-field", specifier = ">=8.4.0,<9.0.0" },
|
||||||
{ name = "django-simple-captcha", specifier = ">=0.6.3,<1.0.0" },
|
{ name = "django-simple-captcha", specifier = ">=0.6.3,<1.0.0" },
|
||||||
{ name = "environs", extras = ["django"], specifier = ">=6.0.5,<16" },
|
{ name = "environs", extras = ["django"], specifier = ">=15.0.1,<16" },
|
||||||
{ name = "honcho", specifier = ">=2.0.0" },
|
{ name = "honcho", specifier = ">=2.0.0" },
|
||||||
{ name = "ical", specifier = ">=12.0.0,<14.0.0" },
|
{ name = "ical", specifier = ">=12.0.0,<14.0.0" },
|
||||||
{ name = "jinja2", specifier = ">=3.1.6,<4.0.0" },
|
{ name = "jinja2", specifier = ">=3.1.6,<4.0.0" },
|
||||||
{ name = "libsass", specifier = ">=0.23.0,<1.0.0" },
|
{ name = "libsass", specifier = ">=0.23.0,<1.0.0" },
|
||||||
{ name = "mistune", specifier = ">=3.2.1,<4.0.0" },
|
{ name = "mistune", specifier = ">=3.2.1,<4.0.0" },
|
||||||
{ name = "phonenumbers", specifier = ">=9.0.32,<10.0.0" },
|
{ name = "phonenumbers", specifier = ">=9.0.30,<10.0.0" },
|
||||||
{ name = "pillow", specifier = ">=12.2.0,<13.0.0" },
|
{ name = "pillow", specifier = ">=12.2.0,<13.0.0" },
|
||||||
{ name = "psutil", specifier = ">=7.2.2,<8.0.0" },
|
{ name = "psutil", specifier = ">=7.2.2,<8.0.0" },
|
||||||
{ name = "pydantic-extra-types", specifier = ">=2.11.1,<3.0.0" },
|
{ name = "pydantic-extra-types", specifier = ">=2.11.1,<3.0.0" },
|
||||||
{ name = "python-dateutil", specifier = ">=2.9.0.post0,<3.0.0.0" },
|
{ name = "python-dateutil", specifier = ">=2.9.0.post0,<3.0.0.0" },
|
||||||
{ name = "redis", extras = ["hiredis"], specifier = ">=3.4.0,<8.0.0" },
|
{ name = "redis", extras = ["hiredis"], specifier = ">=3.3.1,<8.0.0" },
|
||||||
{ name = "reportlab", specifier = ">=4.5.1,<5.0.0" },
|
{ name = "reportlab", specifier = ">=4.5.1,<5.0.0" },
|
||||||
{ name = "requests", specifier = ">=2.34.2,<3.0.0" },
|
{ name = "requests", specifier = ">=2.34.2,<3.0.0" },
|
||||||
{ name = "sentry-sdk", specifier = ">=2.61.1,<3.0.0" },
|
{ name = "sentry-sdk", specifier = ">=2.60.0,<3.0.0" },
|
||||||
{ name = "sphinx", specifier = ">=9.1.0,<10" },
|
{ name = "sphinx", specifier = ">=9.1.0,<10" },
|
||||||
{ name = "tomli", specifier = ">=2.4.1,<3.0.0" },
|
{ name = "tomli", specifier = ">=2.4.1,<3.0.0" },
|
||||||
{ name = "xapian-haystack", specifier = ">=4.0.0,<5.0.0" },
|
{ name = "xapian-haystack", specifier = ">=4.0.0,<5.0.0" },
|
||||||
@@ -2184,18 +2180,18 @@ requires-dist = [
|
|||||||
dev = [
|
dev = [
|
||||||
{ name = "django-debug-toolbar", specifier = ">=6.3.0,<7" },
|
{ name = "django-debug-toolbar", specifier = ">=6.3.0,<7" },
|
||||||
{ name = "djhtml", specifier = ">=3.0.11,<4.0.0" },
|
{ name = "djhtml", specifier = ">=3.0.11,<4.0.0" },
|
||||||
{ name = "faker", specifier = ">=40.21.0,<41.0.0" },
|
{ name = "faker", specifier = ">=40.18.0,<41.0.0" },
|
||||||
{ name = "ipython", specifier = ">=9.14.1,<10.0.0" },
|
{ name = "ipython", specifier = ">=9.13.0,<10.0.0" },
|
||||||
{ name = "pre-commit", specifier = ">=4.6.0,<5.0.0" },
|
{ name = "pre-commit", specifier = ">=4.6.0,<5.0.0" },
|
||||||
{ name = "rjsmin", specifier = ">=1.2.5,<2.0.0" },
|
{ name = "rjsmin", specifier = ">=1.2.5,<2.0.0" },
|
||||||
{ name = "ruff", specifier = ">=0.15.16,<1.0.0" },
|
{ name = "ruff", specifier = ">=0.15.13,<1.0.0" },
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
{ name = "mkdocs", specifier = ">=1.6.1,<2.0.0" },
|
{ name = "mkdocs", specifier = ">=1.6.1,<2.0.0" },
|
||||||
{ name = "mkdocs-include-markdown-plugin", specifier = ">=7.3.0,<8.0.0" },
|
{ name = "mkdocs-include-markdown-plugin", specifier = ">=7.3.0,<8.0.0" },
|
||||||
{ name = "mkdocs-material", specifier = ">=9.7.6,<10.0.0" },
|
{ name = "mkdocs-material", specifier = ">=9.7.6,<10.0.0" },
|
||||||
{ name = "mkdocstrings", specifier = ">=1.0.4,<2.0.0" },
|
{ name = "mkdocstrings", specifier = ">=1.0.4,<2.0.0" },
|
||||||
{ name = "mkdocstrings-python", specifier = ">=2.0.4,<3.0.0" },
|
{ name = "mkdocstrings-python", specifier = ">=2.0.3,<3.0.0" },
|
||||||
]
|
]
|
||||||
prod = [{ name = "psycopg", extras = ["c"], specifier = ">=3.3.4,<4.0.0" }]
|
prod = [{ name = "psycopg", extras = ["c"], specifier = ">=3.3.4,<4.0.0" }]
|
||||||
tests = [
|
tests = [
|
||||||
@@ -2219,20 +2215,20 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "snowballstemmer"
|
name = "snowballstemmer"
|
||||||
version = "3.1.1"
|
version = "3.0.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" },
|
{ url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "soupsieve"
|
name = "soupsieve"
|
||||||
version = "2.8.4"
|
version = "2.8.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
|
{ url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2387,11 +2383,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "traitlets"
|
name = "traitlets"
|
||||||
version = "5.15.1"
|
version = "5.15.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/1b/22/40f55b26baeab80c2d7b3f1db0682f8954e4617fee7d90ce634022ef05c6/traitlets-5.15.0.tar.gz", hash = "sha256:4fead733f81cf1c4c938e06f8ca4633896833c9d89eff878159457f4d4392971", size = 163197, upload-time = "2026-05-06T08:05:58.016Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/98/a9937a969d018a23badfea0b381f66783649d48e0ea6c41923265c3cbeb3/traitlets-5.15.0-py3-none-any.whl", hash = "sha256:fb36a18867a6803deab09f3c5e0fa81bb7b26a5c9e82501c9933f759166eff40", size = 85877, upload-time = "2026-05-06T08:05:55.853Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2456,7 +2452,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "virtualenv"
|
name = "virtualenv"
|
||||||
version = "21.4.2"
|
version = "21.3.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "distlib" },
|
{ name = "distlib" },
|
||||||
@@ -2464,9 +2460,9 @@ dependencies = [
|
|||||||
{ name = "platformdirs" },
|
{ name = "platformdirs" },
|
||||||
{ name = "python-discovery" },
|
{ name = "python-discovery" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/0d/4e93c8e6d1001a75763f87d8f5ecda8ebc7f4aa2153dddfaf4ae8892821a/virtualenv-21.4.2.tar.gz", hash = "sha256:38e6ee0a555615c0ea9da2ac7e9998fe8dc3b911dd33ad8eaad2020957653b0c", size = 7613326, upload-time = "2026-05-31T17:01:22.827Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/c4/557dc082be035381b85fdb2b74e21d3d21b57750b74f2b47a32f3a639ff9/virtualenv-21.4.2-py3-none-any.whl", hash = "sha256:854210ca524a1a4d0d744734f4acbc721c3ffe163b85bbf5d56d14d5ae2f0fae", size = 7594079, upload-time = "2026-05-31T17:01:20.735Z" },
|
{ url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user