mirror of
https://github.com/ae-utbm/sith.git
synced 2026-06-06 16:19:20 +00:00
Merge pull request #1424 from ae-utbm/taiste
Basket timeout, clic limit, club-election link, CGV, counter barmen and other
This commit is contained in:
+2
-7
@@ -46,7 +46,7 @@ from django.http import HttpRequest
|
||||
from ninja_extra import ControllerBase
|
||||
from ninja_extra.permissions import BasePermission
|
||||
|
||||
from counter.models import Counter
|
||||
from counter.utils import is_logged_in_counter
|
||||
|
||||
|
||||
class IsInGroup(BasePermission):
|
||||
@@ -186,12 +186,7 @@ class IsLoggedInCounter(BasePermission):
|
||||
"""Check that a user is logged in a counter."""
|
||||
|
||||
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
|
||||
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()
|
||||
return is_logged_in_counter(request)
|
||||
|
||||
|
||||
CanAccessLookup = IsLoggedInCounter | HasPerm("core.access_lookup")
|
||||
|
||||
@@ -21,10 +21,13 @@
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
#
|
||||
#
|
||||
import itertools
|
||||
from operator import attrgetter
|
||||
|
||||
from django import forms
|
||||
from django.db.models import Exists, OuterRef, Q, QuerySet
|
||||
from django.db.models.functions import Lower
|
||||
from django.forms.models import ModelChoiceField, ModelChoiceIterator
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -46,6 +49,37 @@ from counter.models import Counter, Selling
|
||||
from counter.schemas import SaleFilterSchema
|
||||
|
||||
|
||||
class ClubRoleChoiceIterator(ModelChoiceIterator):
|
||||
"""Custom `ModelChoiceIterator` for `ClubRoleChoiceField`"""
|
||||
|
||||
def __iter__(self):
|
||||
if self.field.empty_label is not None:
|
||||
yield "", self.field.empty_label
|
||||
queryset = self.queryset.select_related("club").order_by("club", "order")
|
||||
groups = [
|
||||
(club, [self.choice(role) for role in roles])
|
||||
for club, roles in itertools.groupby(queryset, key=attrgetter("club"))
|
||||
]
|
||||
if len(groups) == 1:
|
||||
# there is only one club involved, no need to have optgroups
|
||||
yield from groups[0][1]
|
||||
else:
|
||||
# there are multiple clubs, optgroups are necessary to differentiate
|
||||
# roles having the same name
|
||||
yield from groups
|
||||
|
||||
|
||||
class ClubRoleChoiceField(ModelChoiceField):
|
||||
"""Custom `ModelChoiceField` for `[ClubRole][club.models.ClubRole]`.
|
||||
|
||||
If only one club is involved, behave like the base `ModelChoiceField`.
|
||||
If dealing with the roles of multiple clubs, group the roles
|
||||
into a different `optgroup` for each club.
|
||||
"""
|
||||
|
||||
iterator = ClubRoleChoiceIterator
|
||||
|
||||
|
||||
class ClubLinkForm(forms.ModelForm):
|
||||
error_css_class = "error"
|
||||
required_css_class = "required"
|
||||
@@ -392,6 +426,30 @@ class ClubRoleForm(forms.ModelForm):
|
||||
self.instance.order = cleaned_data["ORDER"] - 1
|
||||
return cleaned_data
|
||||
|
||||
def save(self, commit=True): # noqa: FBT002
|
||||
instance: ClubRole = super().save(commit=commit)
|
||||
if commit and "is_board" in self.changed_data:
|
||||
# if the role was moved from board to simple member,
|
||||
# remove all users with that role from the club board group.
|
||||
# If the role became a board role, add users with
|
||||
# that role to the club board group.
|
||||
group_id = instance.club.board_group_id
|
||||
if self.cleaned_data["is_board"]:
|
||||
User.groups.through.objects.bulk_create(
|
||||
[
|
||||
User.groups.through(user_id=u, group_id=group_id)
|
||||
for u in Membership.objects.ongoing()
|
||||
.filter(role=instance)
|
||||
.values_list("user_id", flat=True)
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
else:
|
||||
User.groups.through.objects.filter(
|
||||
user__memberships__role=instance, group_id=group_id
|
||||
).delete()
|
||||
return instance
|
||||
|
||||
|
||||
class ClubRoleCreateForm(forms.ModelForm):
|
||||
"""Form to create a club role.
|
||||
|
||||
@@ -25,8 +25,7 @@ class Migration(migrations.Migration):
|
||||
"url_base",
|
||||
models.URLField(
|
||||
help_text=(
|
||||
"The base url that links with this type "
|
||||
"must respect (e.g. `https://www.instagram.com`)"
|
||||
"The base url that links with this type must respect"
|
||||
),
|
||||
unique=True,
|
||||
verbose_name="url base",
|
||||
|
||||
+1
-4
@@ -793,10 +793,7 @@ class LinkType(models.Model):
|
||||
url_base = models.URLField(
|
||||
"url base",
|
||||
unique=True,
|
||||
help_text=_(
|
||||
"The base url that links with this type must respect (e.g. `%(url)s`)"
|
||||
)
|
||||
% {"url": "https://www.instagram.com"},
|
||||
help_text=_("The base url that links with this type must respect"),
|
||||
)
|
||||
icon = models.CharField(
|
||||
_("icon"),
|
||||
|
||||
@@ -4,6 +4,7 @@ import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from model_bakery import baker, seq
|
||||
from model_bakery.recipe import Recipe
|
||||
from pytest_django.asserts import assertRedirects
|
||||
@@ -239,7 +240,7 @@ class TestClubRoleUpdate(TestCase):
|
||||
|
||||
def test_president_moves_itself_out_of_the_presidency(self):
|
||||
"""Test that if the user moves its own role out of the presidency,
|
||||
then it's redirected to another page and loses access to the update page."""
|
||||
then it loses access to the update page."""
|
||||
self.payload["roles-0-is_presidency"] = False
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.post(self.url, data=self.payload)
|
||||
@@ -251,3 +252,29 @@ class TestClubRoleUpdate(TestCase):
|
||||
|
||||
res = self.client.get(self.url)
|
||||
assert res.status_code == 403
|
||||
|
||||
def test_role_stops_being_board(self):
|
||||
"""Test that if a role stops being a board role,
|
||||
its users lose the club board group."""
|
||||
self.payload["roles-0-is_board"] = False
|
||||
self.payload["roles-0-is_presidency"] = False
|
||||
self.payload["roles-1-is_board"] = False
|
||||
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
|
||||
assert formset.is_valid()
|
||||
formset.save()
|
||||
assert not self.user.groups.contains(self.club.board_group)
|
||||
|
||||
def test_role_becomes_board(self):
|
||||
"""Test that if a role becomes a board role,
|
||||
its active users get the club board group"""
|
||||
members = [
|
||||
baker.make(Membership, club=self.club, role=self.roles[0], end_date=None),
|
||||
baker.make(Membership, club=self.club, role=self.roles[0], end_date=now()),
|
||||
]
|
||||
self.payload["roles-2-is_board"] = True
|
||||
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
|
||||
assert formset.is_valid()
|
||||
formset.save()
|
||||
# the second membership is finished, so its user shouldn't get the role
|
||||
assert members[0].user.groups.contains(self.club.board_group)
|
||||
assert not members[1].user.groups.contains(self.club.board_group)
|
||||
|
||||
+1
-1
@@ -170,7 +170,7 @@ class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
|
||||
form_class = NewsForm
|
||||
template_name = "com/news_edit.jinja"
|
||||
pk_url_kwarg = "news_id"
|
||||
permission_required = "com.edit_news"
|
||||
permission_required = "com.change_news"
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form) # Does the saving part
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
#
|
||||
#
|
||||
from datetime import date, timedelta
|
||||
from datetime import date, datetime, timedelta
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, NamedTuple
|
||||
@@ -33,7 +33,8 @@ from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import localdate
|
||||
from django.utils.lorem_ipsum import paragraphs
|
||||
from django.utils.timezone import localdate, now
|
||||
from PIL import Image
|
||||
|
||||
from club.models import Club, ClubLink, ClubRole, LinkType, Membership
|
||||
@@ -43,13 +44,14 @@ from core.models import BanGroup, Group, Page, PageRev, SithFile, User
|
||||
from core.utils import resize_image
|
||||
from counter.models import (
|
||||
Counter,
|
||||
CounterSellers,
|
||||
Price,
|
||||
Product,
|
||||
ProductType,
|
||||
ReturnableProduct,
|
||||
StudentCard,
|
||||
)
|
||||
from election.models import Candidature, Election, ElectionList, Role
|
||||
from election.models import Candidature, Election, ElectionList, Role, Vote
|
||||
from forum.models import Forum
|
||||
from pedagogy.models import UE
|
||||
from sas.models import Album, PeoplePictureRelation, Picture
|
||||
@@ -364,62 +366,15 @@ class Command(BaseCommand):
|
||||
Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE")
|
||||
|
||||
# Add barman to counter
|
||||
Counter.sellers.through.objects.bulk_create(
|
||||
CounterSellers.objects.bulk_create(
|
||||
[
|
||||
Counter.sellers.through(counter_id=1, user=skia), # MDE
|
||||
Counter.sellers.through(counter_id=2, user=krophil), # Foyer
|
||||
CounterSellers(counter_id=1, user=skia, is_regular=True), # MDE
|
||||
CounterSellers(counter_id=2, user=krophil, is_regular=True), # Foyer
|
||||
]
|
||||
)
|
||||
|
||||
# Create an election
|
||||
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",
|
||||
),
|
||||
]
|
||||
)
|
||||
self._create_elections(groups, clubs, skia, sli, krophil)
|
||||
|
||||
# Forum
|
||||
room = Forum.objects.create(
|
||||
@@ -1010,3 +965,132 @@ class Command(BaseCommand):
|
||||
BanGroup.objects.create(name="Banned from buying alcohol", description="")
|
||||
BanGroup.objects.create(name="Banned from counters", description="")
|
||||
BanGroup.objects.create(name="Banned to subscribe", description="")
|
||||
|
||||
def _create_elections(
|
||||
self,
|
||||
groups: PopulatedGroups,
|
||||
clubs: PopulatedClubs,
|
||||
skia: User,
|
||||
sli: User,
|
||||
krophil: User,
|
||||
):
|
||||
"""Populate elections.
|
||||
|
||||
4 elections are created :
|
||||
|
||||
- one that has not started yet,
|
||||
- one on the candidature period
|
||||
- one on the vote period
|
||||
- one that is finished
|
||||
|
||||
All elections have two lists, are linked to the AE and Troll clubs,
|
||||
and have one role for each board role of thos two clubs, plus
|
||||
an additional role linked to no club roles.
|
||||
|
||||
The ongoing vote and finished elections have candidates.
|
||||
|
||||
The finished election has 10 voters.
|
||||
"""
|
||||
|
||||
def election_factory(title: str, start_candidature: datetime):
|
||||
return Election(
|
||||
title=title,
|
||||
description="",
|
||||
start_candidature=start_candidature,
|
||||
end_candidature=start_candidature + timedelta(days=7),
|
||||
start_date=start_candidature + timedelta(days=7),
|
||||
end_date=start_candidature + timedelta(days=14),
|
||||
)
|
||||
|
||||
# create the elections
|
||||
elections = Election.objects.bulk_create(
|
||||
[
|
||||
election_factory("Election terminée", now() - timedelta(days=14)),
|
||||
election_factory("Votes en cours", now() - timedelta(days=7)),
|
||||
election_factory("Candidatures en cours", now()),
|
||||
election_factory("Election à venir", now() + timedelta(days=7)),
|
||||
]
|
||||
)
|
||||
finished, ongoing_vote, _ongoing_candidature, _not_started = elections
|
||||
|
||||
# set the groups (all elections have the same groups)
|
||||
groups.public.viewable_elections.set(elections)
|
||||
clubs.ae.board_group.editable_elections.set(elections)
|
||||
groups.subscribers.candidate_elections.set(elections)
|
||||
groups.subscribers.votable_elections.set(elections)
|
||||
|
||||
# link elections to clubs (AE and Troll for all elections)
|
||||
Election.clubs.through.objects.bulk_create(
|
||||
[
|
||||
*[Election.clubs.through(club=clubs.ae, election=e) for e in elections],
|
||||
*[
|
||||
Election.clubs.through(club=clubs.troll, election=e)
|
||||
for e in elections
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
# Create lists (all elections have two lists)
|
||||
ElectionList.objects.bulk_create(
|
||||
[
|
||||
*[ElectionList(title="Candidat libre", election=e) for e in elections],
|
||||
*[ElectionList(title="Troll", election=e) for e in elections],
|
||||
]
|
||||
)
|
||||
|
||||
# Create roles.
|
||||
# Elections have a role for each board club role of AE and Troll,
|
||||
# +an additional role linked to no club role
|
||||
club_roles = list(
|
||||
ClubRole.objects.filter(club__in=[clubs.ae, clubs.troll], is_board=True)
|
||||
.select_related("club")
|
||||
.order_by("club_id", "order")
|
||||
)
|
||||
Role.objects.bulk_create(
|
||||
[
|
||||
*[
|
||||
Role(election=e, title=f"{r.name} {r.club.name}", club_role=r)
|
||||
for r in club_roles
|
||||
for e in elections
|
||||
],
|
||||
*[Role(election=e, title="Rôle libre") for e in elections],
|
||||
]
|
||||
)
|
||||
|
||||
# create candidatures for ongoing_vote and finished elections
|
||||
candidatures = []
|
||||
lipsum = "\n\n".join(paragraphs(2))
|
||||
for election in ongoing_vote, finished:
|
||||
lists = list(election.election_lists.order_by("id"))
|
||||
roles = list(election.roles.order_by("order")[:3])
|
||||
candidatures.extend(
|
||||
[
|
||||
Candidature(
|
||||
role=roles[0], user=skia, election_list=lists[0], program=lipsum
|
||||
),
|
||||
Candidature(
|
||||
role=roles[1], user=sli, election_list=lists[0], program=lipsum
|
||||
),
|
||||
Candidature(
|
||||
role=roles[2], user=krophil, election_list=lists[1], program=""
|
||||
),
|
||||
Candidature(
|
||||
role=roles[2], user=sli, election_list=lists[0], program=lipsum
|
||||
),
|
||||
]
|
||||
)
|
||||
candidatures = Candidature.objects.bulk_create(candidatures)
|
||||
|
||||
skia, sli_vp, krophil, sli_treso = candidatures[4:] # candidates of finished
|
||||
votes = Vote.objects.bulk_create(
|
||||
[
|
||||
*[Vote(role=skia.role) for _ in range(6)],
|
||||
*[Vote(role=sli_vp.role) for _ in range(8)],
|
||||
*[Vote(role=krophil.role) for _ in range(9)],
|
||||
]
|
||||
)
|
||||
skia.votes.set(votes[:6])
|
||||
sli_vp.votes.set(votes[6:14])
|
||||
krophil.votes.set(votes[14:20])
|
||||
sli_treso.votes.set(votes[20:23])
|
||||
finished.voters.set(list(User.objects.all()[:10]))
|
||||
|
||||
@@ -46,6 +46,10 @@ details.accordion>.accordion-content {
|
||||
border-bottom-right-radius: 3px;
|
||||
border-bottom-left-radius: 3px;
|
||||
overflow: hidden;
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
padding: .75em 1.5em;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin animation($selector) {
|
||||
|
||||
@@ -29,7 +29,12 @@
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
|
||||
&.clickable:hover {
|
||||
&:disabled {
|
||||
background-color: darken($primary-neutral-light-color, 5%);
|
||||
opacity: 65%;
|
||||
}
|
||||
|
||||
&.clickable:not(:disabled):hover {
|
||||
background-color: darken($primary-neutral-light-color, 5%);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
border-radius: 5px;
|
||||
color: black;
|
||||
|
||||
&:hover {
|
||||
&:not(.link-like):not(:disabled):hover {
|
||||
background: hsl(0, 0%, 83%);
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,6 @@ form {
|
||||
display: block;
|
||||
margin: calc(var(--nf-input-size) * 1.5) auto 10px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
|
||||
.fields-centered {
|
||||
padding: 10px 10px 0;
|
||||
|
||||
@@ -123,7 +123,7 @@ $background-color-hovered: #283747;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
>.button {
|
||||
a.button {
|
||||
box-sizing: border-box;
|
||||
height: 35px;
|
||||
background-color: transparent;
|
||||
@@ -139,7 +139,7 @@ $background-color-hovered: #283747;
|
||||
font-size: .9em;
|
||||
width: 120px;
|
||||
|
||||
&:hover {
|
||||
&:not(.link-like):not(:disabled):hover {
|
||||
background-color: $background-color-hovered;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,14 +22,9 @@
|
||||
</form>
|
||||
<ul class="bars">
|
||||
{% cache 100 "counters_activity" %}
|
||||
{# The sith has no periodic tasks manager
|
||||
and using cron jobs would be way too overkill here.
|
||||
Thus the barmen timeout is handled in the only place that
|
||||
is loaded on every page : the header bar.
|
||||
However, let's be clear : this has nothing to do here.
|
||||
It's' merely a contrived workaround that should
|
||||
replaced by a proper task manager as soon as possible. #}
|
||||
{% set _ = Counter.objects.filter(type="BAR").handle_timeout() %}
|
||||
{# It would be cleaner to handle the timeout with django-celery-beat,
|
||||
but doing it here is simpler and less error-prone #}
|
||||
{% do Counter.objects.filter(type="BAR").handle_timeout() %}
|
||||
{% endcache %}
|
||||
{% for bar in Counter.objects.annotate_has_barman(user).annotate_is_open().filter(type="BAR") %}
|
||||
<li>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<template x-for="(message, index) in $notifications.getAll()">
|
||||
<div class="alert" :class="`alert-${message.tag}`" x-transition>
|
||||
<span class="alert-main" x-text="message.text"></span>
|
||||
<span class="clickable" @click="messages = messages.filter((item, i) => i !== index)">
|
||||
<span class="clickable" @click="$store.notifications = $store.notifications.filter((item, i) => i !== index)">
|
||||
<i class="fa fa-close"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+36
-16
@@ -9,6 +9,7 @@ from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.forms import BaseModelFormSet
|
||||
from django.http import HttpRequest
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_celery_beat.models import ClockedSchedule
|
||||
@@ -17,6 +18,7 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||
from club.models import Club
|
||||
from club.widgets.ajax_select import AutoCompleteSelectClub
|
||||
from core.models import User, UserQuerySet
|
||||
from core.views import LoginForm
|
||||
from core.views.forms import (
|
||||
FutureDateTimeField,
|
||||
NFCTextInput,
|
||||
@@ -91,30 +93,18 @@ class StudentCardForm(forms.ModelForm):
|
||||
|
||||
|
||||
class GetUserForm(forms.Form):
|
||||
"""The Form class aims at providing a valid user_id field in its cleaned data, in order to pass it to some view,
|
||||
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)
|
||||
"""
|
||||
"""Find a user to show its click page."""
|
||||
|
||||
code = forms.CharField(
|
||||
label="Code",
|
||||
max_length=StudentCard.UID_SIZE,
|
||||
required=False,
|
||||
widget=NFCTextInput,
|
||||
widget=NFCTextInput(attrs={"autofocus": True}),
|
||||
)
|
||||
id = forms.CharField(
|
||||
label=_("Select user"),
|
||||
help_text=None,
|
||||
widget=AutoCompleteSelectUser,
|
||||
required=False,
|
||||
label=_("Select user"), widget=AutoCompleteSelectUser, required=False
|
||||
)
|
||||
|
||||
def as_p(self):
|
||||
self.fields["code"].widget.attrs["autofocus"] = True
|
||||
return super().as_p()
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
customer = None
|
||||
@@ -136,11 +126,40 @@ class GetUserForm(forms.Form):
|
||||
|
||||
if customer is None or not customer.can_buy:
|
||||
raise forms.ValidationError(_("User not found"))
|
||||
cleaned_data["user_id"] = customer.user.id
|
||||
cleaned_data["user_id"] = customer.user_id
|
||||
cleaned_data["user"] = customer.user
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class CounterLoginForm(LoginForm):
|
||||
"""LoginForm to log a barman in a counter.
|
||||
|
||||
To be able to log in a counter, a user must :
|
||||
|
||||
- be part of the sellers of the given counter
|
||||
- not being already logged in any counter
|
||||
"""
|
||||
|
||||
def __init__(self, *args, request: HttpRequest, counter: Counter, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.counter = counter
|
||||
self.request = request
|
||||
|
||||
def confirm_login_allowed(self, user: User):
|
||||
super().confirm_login_allowed(user)
|
||||
if not self.counter.sellers.contains(user):
|
||||
raise ValidationError(
|
||||
message=_("You are not a barman of this counter."), code="not_barman"
|
||||
)
|
||||
if 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):
|
||||
allowed_refilling_methods = [
|
||||
Refilling.PaymentMethod.CASH,
|
||||
@@ -409,6 +428,7 @@ class ProductForm(forms.ModelForm):
|
||||
"club",
|
||||
"limit_age",
|
||||
"tray",
|
||||
"clic_limit",
|
||||
"archived",
|
||||
]
|
||||
help_texts = {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.13 on 2026-05-13 11:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("counter", "0039_price")]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(model_name="product", name="buying_groups"),
|
||||
migrations.AddField(
|
||||
model_name="product",
|
||||
name="clic_limit",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
help_text=(
|
||||
"If a limit is set, the product won't be purchasable "
|
||||
"anymore on the eboutic once the latter is reached."
|
||||
),
|
||||
null=True,
|
||||
verbose_name="clic limit",
|
||||
),
|
||||
),
|
||||
migrations.RemoveField(model_name="counter", name="token"),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.2.14 on 2026-06-02 10:45
|
||||
|
||||
import django_countries.fields
|
||||
import phonenumber_field.modelfields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("counter", "0040_product_clic_limit")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="billinginfo",
|
||||
name="country",
|
||||
field=django_countries.fields.CountryField(
|
||||
max_length=2, verbose_name="Country"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="billinginfo",
|
||||
name="phone_number",
|
||||
field=phonenumber_field.modelfields.PhoneNumberField(
|
||||
max_length=128, region=None, verbose_name="Phone number"
|
||||
),
|
||||
),
|
||||
]
|
||||
+51
-25
@@ -22,7 +22,7 @@ import string
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import timezone as tz
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING, Literal, Self
|
||||
from typing import Literal, Self
|
||||
|
||||
from dict2xml import dict2xml
|
||||
from django.conf import settings
|
||||
@@ -34,6 +34,7 @@ from django.forms import ValidationError
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
from django_countries.fields import CountryField
|
||||
@@ -47,9 +48,6 @@ from core.utils import get_start_of_semester
|
||||
from counter.fields import CurrencyField
|
||||
from subscription.models import Subscription
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
|
||||
def get_eboutic() -> Counter:
|
||||
return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
|
||||
@@ -230,15 +228,8 @@ class BillingInfo(models.Model):
|
||||
address_2 = models.CharField(_("Address 2"), max_length=50, blank=True, null=True)
|
||||
zip_code = models.CharField(_("Zip code"), max_length=16) # code postal
|
||||
city = models.CharField(_("City"), max_length=50)
|
||||
country = CountryField(blank_label=_("Country"))
|
||||
|
||||
# This table was created during the A22 semester.
|
||||
# However, later on, CA asked for the phone number to be added to the billing info.
|
||||
# As the table was already created, this new field had to be nullable,
|
||||
# even tough it is required by the bank and shouldn't be null.
|
||||
# If one day there is no null phone number remaining,
|
||||
# please make the field non-nullable.
|
||||
phone_number = PhoneNumberField(_("Phone number"), null=True, blank=False)
|
||||
country = CountryField(_("Country"))
|
||||
phone_number = PhoneNumberField(_("Phone number"))
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.first_name} {self.last_name}"
|
||||
@@ -353,6 +344,40 @@ class ProductType(OrderedModel):
|
||||
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
||||
|
||||
|
||||
class ProductQuerySet(models.QuerySet):
|
||||
def under_clic_limit(self) -> Self:
|
||||
"""Filter product which clic limit isn't reached yet.
|
||||
|
||||
The clic limit is reached when the amount of sales
|
||||
and of items in a basket for less than 15 minutes
|
||||
is greater or equal than `Product.clic_limit`.
|
||||
"""
|
||||
# import here to avoid circular import
|
||||
from eboutic.models import BasketItem
|
||||
|
||||
nb_click_subquery = Subquery(
|
||||
Selling.objects.filter(product_id=OuterRef("id"))
|
||||
.values("product_id")
|
||||
.annotate(res=Sum("quantity", default=0))
|
||||
.values("res")[:1]
|
||||
)
|
||||
nb_basket_items_subquery = Subquery(
|
||||
BasketItem.objects.filter(
|
||||
product_id=OuterRef("id"),
|
||||
basket__date__gt=now()
|
||||
- settings.SITH_EBOUTIC_BASKET_TIMEOUT
|
||||
- settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT,
|
||||
)
|
||||
.values("product_id")
|
||||
.annotate(res=Sum("quantity"))
|
||||
.values("res")[:1]
|
||||
)
|
||||
return self.annotate(
|
||||
clicked=Coalesce(nb_click_subquery, 0),
|
||||
reserved=Coalesce(nb_basket_items_subquery, 0),
|
||||
).filter(Q(clic_limit=None) | Q(clic_limit__gt=(F("clicked") + F("reserved"))))
|
||||
|
||||
|
||||
class Product(models.Model):
|
||||
"""A product, with all its related information."""
|
||||
|
||||
@@ -370,8 +395,7 @@ class Product(models.Model):
|
||||
)
|
||||
code = models.CharField(_("code"), max_length=16, blank=True)
|
||||
purchase_price = CurrencyField(
|
||||
_("purchase price"),
|
||||
help_text=_("Initial cost of purchasing the product"),
|
||||
_("purchase price"), help_text=_("Initial cost of purchasing the product")
|
||||
)
|
||||
icon = ResizedImageField(
|
||||
height=70,
|
||||
@@ -388,13 +412,21 @@ class Product(models.Model):
|
||||
tray = models.BooleanField(
|
||||
_("tray price"), help_text=_("Buy five, get the sixth free"), default=False
|
||||
)
|
||||
buying_groups = models.ManyToManyField(
|
||||
Group, related_name="products", verbose_name=_("buying groups"), blank=True
|
||||
clic_limit = models.PositiveSmallIntegerField(
|
||||
_("clic limit"),
|
||||
help_text=_(
|
||||
"If a limit is set, the product won't be purchasable "
|
||||
"anymore on the eboutic once the latter is reached."
|
||||
),
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
archived = models.BooleanField(_("archived"), default=False)
|
||||
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
|
||||
|
||||
objects = ProductQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("product")
|
||||
|
||||
@@ -580,7 +612,6 @@ class Counter(models.Model):
|
||||
view_groups = models.ManyToManyField(
|
||||
Group, related_name="viewable_counters", blank=True
|
||||
)
|
||||
token = models.CharField(_("token"), max_length=30, null=True, blank=True)
|
||||
|
||||
objects = CounterQuerySet.as_manager()
|
||||
|
||||
@@ -733,10 +764,8 @@ class Counter(models.Model):
|
||||
# but they share the same primary key
|
||||
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
|
||||
|
||||
def get_prices_for(
|
||||
self, customer: Customer, *, order_by: Sequence[str] | None = None
|
||||
) -> list[Price]:
|
||||
qs = (
|
||||
def get_prices_for(self, customer: Customer) -> PriceQuerySet:
|
||||
return (
|
||||
Price.objects.filter(
|
||||
product__counters=self, product__product_type__isnull=False
|
||||
)
|
||||
@@ -744,9 +773,6 @@ class Counter(models.Model):
|
||||
.select_related("product", "product__product_type")
|
||||
.prefetch_related("groups")
|
||||
)
|
||||
if order_by:
|
||||
qs = qs.order_by(*order_by)
|
||||
return list(qs)
|
||||
|
||||
|
||||
class CounterSellers(models.Model):
|
||||
|
||||
+7
-14
@@ -20,41 +20,34 @@
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
#
|
||||
#
|
||||
import random
|
||||
|
||||
from django.db.models.signals import pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from core.middleware import get_signal_request
|
||||
from core.models import OperationLog
|
||||
from counter.models import Counter, Refilling, Selling
|
||||
from counter.models import Refilling, Selling
|
||||
|
||||
|
||||
def write_log(instance, operation_type):
|
||||
def write_log(instance: Selling | Refilling, operation_type):
|
||||
def get_user():
|
||||
request = get_signal_request()
|
||||
|
||||
if not request:
|
||||
return None
|
||||
|
||||
# Get a random barmen if deletion is from a counter
|
||||
session = getattr(request, "session", {})
|
||||
session_token = session.get("counter_token", None)
|
||||
if session_token:
|
||||
counter = Counter.objects.filter(token=session_token).first()
|
||||
if counter and len(counter.barmen_list) > 0:
|
||||
return counter.get_random_barman()
|
||||
if request.barmen:
|
||||
return random.choice(list(request.barmen))
|
||||
|
||||
# Get the current logged user if not from a counter
|
||||
if request.user and not request.user.is_anonymous:
|
||||
if request.user.is_authenticated:
|
||||
return request.user
|
||||
|
||||
# Return None by default
|
||||
return None
|
||||
|
||||
OperationLog(
|
||||
label=str(instance),
|
||||
operator=get_user(),
|
||||
operation_type=operation_type,
|
||||
label=str(instance), operator=get_user(), operation_type=operation_type
|
||||
).save()
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RecursivePartial, TomSettings } from "tom-select/dist/types/types";
|
||||
import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base.ts";
|
||||
import { registerComponent } from "#core:utils/web-components.ts";
|
||||
import type { RecursivePartial, TomSettings } from "tom-select/src/types";
|
||||
import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base";
|
||||
import { registerComponent } from "#core:utils/web-components";
|
||||
|
||||
const productParsingRegex = /^(\d+x)?(.*)/i;
|
||||
const codeParsingRegex = / \((\w+)\)$/;
|
||||
@@ -63,13 +63,6 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
this.widget.hook("after", "onOptionSelect", () => {
|
||||
/* Focus the next element if it's an input */
|
||||
if (this.nextElementSibling.nodeName === "INPUT") {
|
||||
(this.nextElementSibling as HTMLInputElement).focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
protected tomSelectSettings(): RecursivePartial<TomSettings> {
|
||||
/* We disable the dropdown on focus because we're going to always autofocus the widget */
|
||||
@@ -80,9 +73,7 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
|
||||
// We need to manually set weights or it results on an inconsistent
|
||||
// behavior between production and development environment
|
||||
searchField: [
|
||||
// @ts-expect-error documentation says it's fine, specified type is wrong
|
||||
{ field: "code", weight: 2 },
|
||||
// @ts-expect-error documentation says it's fine, specified type is wrong
|
||||
{ field: "text", weight: 0.5 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -25,6 +25,9 @@ document.addEventListener("alpine:init", () => {
|
||||
}
|
||||
|
||||
this.codeField = this.$refs.codeField;
|
||||
this.codeField.widget.hook("after", "onOptionSelect", () => {
|
||||
this.handleCode();
|
||||
});
|
||||
this.codeField.widget.focus();
|
||||
|
||||
// It's quite tricky to manually apply attributes to the management part
|
||||
@@ -154,6 +157,7 @@ document.addEventListener("alpine:init", () => {
|
||||
this.addToBasket(code, quantity);
|
||||
}
|
||||
this.codeField.widget.clear();
|
||||
this.codeField.widget.setTextboxValue("");
|
||||
this.codeField.widget.focus();
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -42,7 +42,28 @@
|
||||
min-width: 350px;
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
margin-left: 0;
|
||||
|
||||
.basket-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
.product-name {
|
||||
flex: 1 2 0;
|
||||
min-width: 0;
|
||||
text-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
margin-top: .5rem;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,10 +56,15 @@
|
||||
<div class="accordion-content">
|
||||
{% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %}
|
||||
|
||||
<form method="post" action=""
|
||||
class="code_form" @submit.prevent="handleCode">
|
||||
<form method="post" action="" @submit.prevent="handleCode">
|
||||
|
||||
<counter-product-select name="code" x-ref="codeField" autofocus required placeholder="{% trans %}Select a product...{% endtrans %}">
|
||||
<counter-product-select
|
||||
name="code"
|
||||
x-ref="codeField"
|
||||
autofocus
|
||||
required
|
||||
placeholder="{% trans %}Select a product...{% endtrans %}"
|
||||
>
|
||||
<option value=""></option>
|
||||
<optgroup label="{% trans %}Operations{% endtrans %}">
|
||||
<option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option>
|
||||
@@ -68,13 +73,11 @@
|
||||
{%- for category, prices in categories.items() -%}
|
||||
<optgroup label="{{ category }}">
|
||||
{%- for price in prices -%}
|
||||
<option value="{{ price.id }}">{{ price.full_label }}</option>
|
||||
<option value="{{ price.id }}">{{ price.full_label }} ({{ price.product.code }})</option>
|
||||
{%- endfor -%}
|
||||
</optgroup>
|
||||
{%- endfor -%}
|
||||
</counter-product-select>
|
||||
|
||||
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
|
||||
</form>
|
||||
|
||||
{% for error in form.non_form_errors() %}
|
||||
@@ -102,7 +105,9 @@
|
||||
{{ form.management_form }}
|
||||
</div>
|
||||
<ul>
|
||||
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
|
||||
<li x-show="getBasketSize() === 0">
|
||||
<em>{% trans %}This basket is empty{% endtrans %}</em>
|
||||
</li>
|
||||
<template x-for="(item, index) in Object.values(basket)" :key="item.product.price.id">
|
||||
<li>
|
||||
<template x-for="error in item.errors">
|
||||
@@ -110,12 +115,15 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="basket-row">
|
||||
<div>
|
||||
<button @click.prevent="addToBasket(item.product.price.id, -1)">-</button>
|
||||
<span class="quantity" x-text="item.quantity"></span>
|
||||
<button @click.prevent="addToBasket(item.product.price.id, 1)">+</button>
|
||||
</div>
|
||||
|
||||
<span x-text="item.product.name"></span> :
|
||||
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span>
|
||||
<span class="product-name" x-text="item.product.name"></span>
|
||||
<span x-text="`${item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })} €`"></span>
|
||||
<span x-show="item.getBonusQuantity() > 0"
|
||||
x-text="`${item.getBonusQuantity()} x P`"></span>
|
||||
|
||||
@@ -123,6 +131,7 @@
|
||||
class="remove-item"
|
||||
@click.prevent="removeFromBasket(item.product.price.id)"
|
||||
><i class="fa fa-trash-can delete-action"></i></button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
|
||||
@@ -32,12 +32,11 @@
|
||||
</ul>
|
||||
<p><strong>{% trans %}Total: {% endtrans %}{{ last_total }} €</strong></p>
|
||||
{% endif %}
|
||||
{% if barmen %}
|
||||
{% if can_click %}
|
||||
<p>{% trans %}Enter client code:{% endtrans %}</p>
|
||||
<form method="post" action="">
|
||||
<form method="post" action="" id="select-user-form">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="counter_token" value="{{ counter.token }}" />
|
||||
{{ form.as_p() }}
|
||||
{{ form }}
|
||||
<p><input type="submit" value="{% trans %}validate{% endtrans %}" /></p>
|
||||
</form>
|
||||
{% else %}
|
||||
@@ -45,17 +44,36 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if counter.type == 'BAR' %}
|
||||
<h3>{% trans %}Barmen:{% endtrans %}</h3>
|
||||
|
||||
{% if barmen_here %}
|
||||
<div class="row gap-2x">
|
||||
<div>
|
||||
<h3>{% trans %}Barman: {% endtrans %}</h3>
|
||||
<h4>{% trans %}On this device{% endtrans %}</h4>
|
||||
{% for b in barmen_here %}
|
||||
<p>{{ barman_logout_link(b) }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
<h4>{% trans %}Elsewhere{% endtrans %}</h4>
|
||||
{% if barmen_here|length == barmen|length %}
|
||||
{# all logged barmen are logged in this session #}
|
||||
<p><em>{% trans %}No barman logged elsewhere{% endtrans %}</em></p>
|
||||
{% else %}
|
||||
{% for b in barmen %}
|
||||
{%- if b not in barmen_here -%}
|
||||
<p>{{ barman_logout_link(b) }}</p>
|
||||
{%- endif -%}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% for b in barmen %}
|
||||
<p>{{ barman_logout_link(b) }}</p>
|
||||
{% endfor %}
|
||||
<form method="post" action="{{ url('counter:login', counter_id=counter.id) }}">
|
||||
{% csrf_token %}
|
||||
{{ login_form.as_p() }}
|
||||
<p><input type="submit" value="{% trans %}login{% endtrans %}" /></p>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ login_fragment }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -63,10 +81,10 @@
|
||||
{{ super() }}
|
||||
<script type="text/javascript">
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
// The login form annoyingly takes priority over the code form
|
||||
// This is due to the loading time of the web component
|
||||
// We can't rely on DOMContentLoaded to know if the component is there so we
|
||||
// periodically run a script until the field is there
|
||||
{# The login form annoyingly takes priority over the code form
|
||||
This is due to the loading time of the web component
|
||||
We can't rely on DOMContentLoaded to know if the component is there so we
|
||||
periodically run a script until the field is there #}
|
||||
const autofocus = () => {
|
||||
const field = document.querySelector("input[id='id_code']");
|
||||
if (field === null){
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<form hx-post="{{ action }}" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
{{ form }}
|
||||
<input type="submit" value="{% trans %}Confirm{% endtrans %}"/>
|
||||
</form>
|
||||
@@ -118,6 +118,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset><div>{{ form.clic_limit.as_field_group() }}</div></fieldset>
|
||||
<fieldset><div>{{ form.counters.as_field_group() }}</div></fieldset>
|
||||
|
||||
<h3 class="margin-bottom">{% trans %}Prices{% endtrans %}</h3>
|
||||
|
||||
+115
-51
@@ -17,9 +17,11 @@ from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission, make_password
|
||||
from django.contrib.messages import DEFAULT_LEVELS, get_messages
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import resolve_url
|
||||
from django.test import Client, TestCase
|
||||
@@ -37,6 +39,7 @@ from core.models import BanGroup, Group, User
|
||||
from counter.baker_recipes import price_recipe, product_recipe, sale_recipe
|
||||
from counter.models import (
|
||||
Counter,
|
||||
CounterSellers,
|
||||
Customer,
|
||||
Permanency,
|
||||
ProductType,
|
||||
@@ -66,10 +69,14 @@ class TestFullClickBase(TestCase):
|
||||
cls.subscriber = subscriber_user.make()
|
||||
|
||||
cls.counter = baker.make(Counter, type="BAR")
|
||||
cls.counter.sellers.add(cls.barmen, cls.board_admin)
|
||||
|
||||
cls.other_counter = baker.make(Counter, type="BAR")
|
||||
cls.other_counter.sellers.add(cls.barmen)
|
||||
CounterSellers.objects.bulk_create(
|
||||
[
|
||||
CounterSellers(counter=cls.counter, user=cls.barmen),
|
||||
CounterSellers(counter=cls.counter, user=cls.board_admin),
|
||||
CounterSellers(counter=cls.other_counter, user=cls.barmen),
|
||||
]
|
||||
)
|
||||
|
||||
cls.yet_another_counter = baker.make(Counter, type="BAR")
|
||||
|
||||
@@ -114,7 +121,10 @@ class TestRefilling(TestFullClickBase):
|
||||
) -> HttpResponse:
|
||||
used_client = client if client is not None else self.client
|
||||
return used_client.post(
|
||||
reverse("counter:refilling_create", kwargs={"customer_id": user.pk}),
|
||||
reverse(
|
||||
"counter:refilling_create",
|
||||
kwargs={"customer_id": user.pk, "counter_id": self.counter.pk},
|
||||
),
|
||||
{"amount": str(amount), "payment_method": Refilling.PaymentMethod.CASH},
|
||||
HTTP_REFERER=reverse(
|
||||
"counter:click", kwargs={"counter_id": counter.id, "user_id": user.pk}
|
||||
@@ -138,7 +148,10 @@ class TestRefilling(TestFullClickBase):
|
||||
return self.client.post(
|
||||
reverse(
|
||||
"counter:refilling_create",
|
||||
kwargs={"customer_id": self.customer.pk},
|
||||
kwargs={
|
||||
"customer_id": self.customer.pk,
|
||||
"counter_id": self.counter.pk,
|
||||
},
|
||||
),
|
||||
{"amount": "10", "payment_method": "CASH"},
|
||||
)
|
||||
@@ -442,9 +455,19 @@ class TestCounterClick(TestFullClickBase):
|
||||
|
||||
def test_click_not_connected(self):
|
||||
force_refill_user(self.customer, 10)
|
||||
|
||||
# trying to click on a bar without being logged should result
|
||||
# in a redirect to the counter page with an error message
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.snack.id, 2)])
|
||||
assertRedirects(res, self.counter.get_absolute_url())
|
||||
messages = list(get_messages(res.wsgi_request))
|
||||
assert len(messages) == 1
|
||||
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
|
||||
assert (
|
||||
messages[0].message == "Vous ne pouvez pas cliquer des gens sur ce comptoir"
|
||||
)
|
||||
|
||||
# trying to click on an office counter without permission should 403
|
||||
res = self.submit_basket(
|
||||
self.customer, [BasketItem(self.snack.id, 2)], counter=self.club_counter
|
||||
)
|
||||
@@ -596,7 +619,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
product=iter(_product_recipe.make(archived=False, _quantity=2)),
|
||||
groups=[group],
|
||||
)
|
||||
customer_prices = counter.get_prices_for(customer)
|
||||
customer_prices = list(counter.get_prices_for(customer))
|
||||
assert unarchived_prices == customer_prices
|
||||
|
||||
|
||||
@@ -718,59 +741,97 @@ class TestCounterStats(TestCase):
|
||||
class TestBarmanConnection(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.krophil = User.objects.get(username="krophil")
|
||||
cls.skia = User.objects.get(username="skia")
|
||||
cls.skia.customer.account = 800
|
||||
cls.krophil.customer.save()
|
||||
cls.skia.customer.save()
|
||||
|
||||
cls.counter = Counter.objects.get(id=2)
|
||||
cls.barman = subscriber_user.make()
|
||||
cls.barman.set_password("plop")
|
||||
cls.barman.save()
|
||||
cls.counter = baker.make(Counter, type="BAR", sellers=[cls.barman])
|
||||
cls.login_url = reverse("counter:login", kwargs={"counter_id": cls.counter.id})
|
||||
cls.detail_url = reverse(
|
||||
"counter:details", kwargs={"counter_id": cls.counter.id}
|
||||
)
|
||||
|
||||
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(
|
||||
reverse("counter:login", args=[self.counter.id]),
|
||||
{"username": "krophil", "password": "plop"},
|
||||
self.login_url, {"username": self.barman.username, "password": "plop"}
|
||||
)
|
||||
response = self.client.get(reverse("counter:details", args=[self.counter.id]))
|
||||
self.assert_counter_login_fails(self.barman)
|
||||
|
||||
assert "<p>Entrez un code client : </p>" in str(response.content)
|
||||
|
||||
def test_counters_list_barmen(self):
|
||||
def test_barman_already_logged_elsewhere(self):
|
||||
"""Test when the barman is already logged in another counter."""
|
||||
other_counter = baker.make(Counter, type="BAR")
|
||||
CounterSellers.objects.create(counter=other_counter, user=self.barman)
|
||||
self.client.post(
|
||||
reverse("counter:login", args=[self.counter.id]),
|
||||
{"username": "krophil", "password": "plop"},
|
||||
reverse("counter:login", kwargs={"counter_id": other_counter.id}),
|
||||
{"username": self.barman.username, "password": "plop"},
|
||||
)
|
||||
response = self.client.get(reverse("counter:activity", args=[self.counter.id]))
|
||||
self.assert_counter_login_fails(self.barman)
|
||||
|
||||
assert '<li><a href="/user/10/">Kro Phil'</a></li>' in str(response.content)
|
||||
|
||||
def test_barman_denied(self):
|
||||
self.client.post(
|
||||
reverse("counter:login", args=[self.counter.id]),
|
||||
{"username": "skia", "password": "plop"},
|
||||
def test_login_on_non_bar_counter(self):
|
||||
counter = baker.make(Counter, type="OFFICE")
|
||||
CounterSellers.objects.create(counter=counter, user=self.barman)
|
||||
url = reverse("counter:login", kwargs={"counter_id": counter.id})
|
||||
response = self.client.get(url)
|
||||
assert response.status_code == 403
|
||||
response = self.client.post(
|
||||
url, {"username": self.barman.username, "password": "plop"}
|
||||
)
|
||||
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)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_barman_timeout():
|
||||
def test_barman_timeout(client: Client):
|
||||
"""Test that barmen timeout is well managed."""
|
||||
bar = baker.make(Counter, type="BAR")
|
||||
user = baker.make(User)
|
||||
bar.sellers.add(user)
|
||||
CounterSellers.objects.create(counter=bar, user=user)
|
||||
baker.make(Permanency, counter=bar, user=user, start=now())
|
||||
|
||||
qs = Counter.objects.annotate_is_open().filter(pk=bar.pk)
|
||||
@@ -786,6 +847,8 @@ def test_barman_timeout():
|
||||
bar = qs[0]
|
||||
assert not bar.is_open
|
||||
assert bar.barmen_list == []
|
||||
res = client.get("")
|
||||
assert res.wsgi_request.barmen == set()
|
||||
|
||||
|
||||
class TestClubCounterClickAccess(TestCase):
|
||||
@@ -835,14 +898,14 @@ class TestClubCounterClickAccess(TestCase):
|
||||
|
||||
def test_barman(self):
|
||||
"""Sellers should be able to click on office counters"""
|
||||
self.counter.sellers.add(self.user)
|
||||
CounterSellers.objects.create(counter=self.counter, user=self.user)
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.get(self.click_url)
|
||||
assert res.status_code == 200
|
||||
|
||||
def test_both_barman_and_board_member(self):
|
||||
"""If the user is barman and board member, he should be authorized as well."""
|
||||
self.counter.sellers.add(self.user)
|
||||
CounterSellers.objects.create(counter=self.counter, user=self.user)
|
||||
baker.make(
|
||||
Membership, club=self.counter.club, user=self.user, role=self.board_role
|
||||
)
|
||||
@@ -868,14 +931,15 @@ class TestCounterLogout:
|
||||
)
|
||||
assertRedirects(
|
||||
res,
|
||||
reverse(
|
||||
"counter:details", kwargs={"counter_id": permanence.counter_id}
|
||||
),
|
||||
reverse("counter:details", kwargs={"counter_id": permanence.counter_id}),
|
||||
)
|
||||
permanence.refresh_from_db()
|
||||
assert permanence.end == now()
|
||||
assert permanence.end == permanence.activity
|
||||
assert permanence.user not in res.wsgi_request.barmen
|
||||
|
||||
def test_logout_doesnt_change_old_permanences(self, client: Client):
|
||||
# regression test for #1141
|
||||
# https://github.com/ae-utbm/sith/pull/1141
|
||||
perm_counter = baker.make(Counter, type="BAR")
|
||||
permanence = baker.make(
|
||||
Permanency,
|
||||
@@ -896,6 +960,6 @@ class TestCounterLogout:
|
||||
data={"user_id": permanence.user_id},
|
||||
)
|
||||
permanence.refresh_from_db()
|
||||
assert permanence.end == now()
|
||||
assert permanence.end == permanence.activity
|
||||
old_permanence.refresh_from_db()
|
||||
assert old_permanence.end == old_end
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import itertools
|
||||
from io import BytesIO
|
||||
from typing import Callable
|
||||
from uuid import uuid4
|
||||
@@ -8,6 +9,7 @@ from django.core.cache import cache
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
from PIL import Image
|
||||
@@ -16,9 +18,10 @@ from pytest_django.asserts import assertNumQueries, assertRedirects
|
||||
from club.models import Club
|
||||
from core.baker_recipes import board_user, subscriber_user
|
||||
from core.models import Group, User
|
||||
from counter.baker_recipes import product_recipe
|
||||
from counter.baker_recipes import product_recipe, sale_recipe
|
||||
from counter.forms import ProductForm, ProductPriceFormSet
|
||||
from counter.models import Price, Product, ProductType
|
||||
from counter.models import Price, Product, ProductType, Selling
|
||||
from eboutic.models import Basket, BasketItem
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -222,3 +225,59 @@ def test_price_for_user():
|
||||
assert list(qs.for_user(users[0])) == [prices[0], prices[1], prices[4]]
|
||||
assert list(qs.for_user(users[1])) == [prices[0], prices[4]]
|
||||
assert list(qs.for_user(users[2])) == [prices[0], prices[3]]
|
||||
|
||||
|
||||
class TestProductClicLimit(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.products = product_recipe.make(
|
||||
clic_limit=itertools.chain([5, 10, 15], itertools.repeat(None)),
|
||||
_quantity=6,
|
||||
_bulk_create=True,
|
||||
)
|
||||
cls.qs = Product.objects.filter(id__in=[p.id for p in cls.products])
|
||||
|
||||
def test_no_sales_or_basket(self):
|
||||
"""Test that it works if no sales has been made yet"""
|
||||
assert list(self.qs.under_clic_limit()) == self.products
|
||||
|
||||
def test_with_sales(self):
|
||||
"""Test that it works when there are existing sales"""
|
||||
sales = sale_recipe.make(
|
||||
product=itertools.cycle(self.products),
|
||||
_quantity=len(self.products) * 5,
|
||||
_bulk_create=True,
|
||||
)
|
||||
Selling.objects.filter(id__in=[s.id for s in sales]).update(quantity=2)
|
||||
assert list(self.qs.under_clic_limit()) == self.products[2:]
|
||||
|
||||
def test_with_sales_and_basket(self):
|
||||
"""Test that it works when there are existing sales and basket items."""
|
||||
sales = sale_recipe.make(
|
||||
product=itertools.cycle(self.products),
|
||||
_quantity=len(self.products) * 5,
|
||||
_bulk_create=True,
|
||||
)
|
||||
Selling.objects.filter(id__in=[s.id for s in sales]).update(quantity=1)
|
||||
basket = baker.make(
|
||||
Basket, date=now() - settings.SITH_EBOUTIC_BASKET_TIMEOUT / 2
|
||||
)
|
||||
items = baker.make(
|
||||
BasketItem,
|
||||
product=itertools.cycle(self.products),
|
||||
basket=basket,
|
||||
_quantity=len(self.products) * 5,
|
||||
)
|
||||
BasketItem.objects.filter(id__in=[i.id for i in items]).update(quantity=1)
|
||||
assert list(self.qs.under_clic_limit()) == self.products[2:]
|
||||
|
||||
# expired basket items shouldn't be accounted when computing clic limit
|
||||
item = BasketItem.objects.filter(product=self.products[1])[0]
|
||||
item.basket = baker.make(
|
||||
Basket,
|
||||
date=now()
|
||||
- settings.SITH_EBOUTIC_BASKET_TIMEOUT
|
||||
- settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT,
|
||||
)
|
||||
item.save()
|
||||
assert list(self.qs.under_clic_limit()) == self.products[1:]
|
||||
|
||||
+4
-3
@@ -41,7 +41,6 @@ from counter.views.admin import (
|
||||
ReturnableProductUpdateView,
|
||||
SellingDeleteView,
|
||||
)
|
||||
from counter.views.auth import counter_login, counter_logout
|
||||
from counter.views.cash import (
|
||||
CashSummaryEditView,
|
||||
CashSummaryListView,
|
||||
@@ -57,7 +56,9 @@ from counter.views.eticket import (
|
||||
from counter.views.home import (
|
||||
CounterActivityView,
|
||||
CounterLastOperationsView,
|
||||
CounterLoginFragment,
|
||||
CounterMain,
|
||||
counter_logout,
|
||||
)
|
||||
from counter.views.invoice import InvoiceCallView
|
||||
from counter.views.student_card import StudentCardDeleteView, StudentCardFormFragment
|
||||
@@ -66,7 +67,7 @@ urlpatterns = [
|
||||
path("<int:counter_id>/", CounterMain.as_view(), name="details"),
|
||||
path("<int:counter_id>/click/<int:user_id>/", CounterClick.as_view(), name="click"),
|
||||
path(
|
||||
"refill/<int:customer_id>/",
|
||||
"<int:counter_id>/refill/<int:customer_id>/",
|
||||
RefillingCreateView.as_view(),
|
||||
name="refilling_create",
|
||||
),
|
||||
@@ -82,7 +83,7 @@ urlpatterns = [
|
||||
),
|
||||
path("<int:counter_id>/activity/", CounterActivityView.as_view(), name="activity"),
|
||||
path("<int:counter_id>/stats/", CounterStatView.as_view(), name="stats"),
|
||||
path("<int:counter_id>/login/", counter_login, name="login"),
|
||||
path("<int:counter_id>/login/", CounterLoginFragment.as_view(), name="login"),
|
||||
path("<int:counter_id>/logout/", counter_logout, name="logout"),
|
||||
path("eticket/<int:selling_id>/pdf/", EticketPDFView.as_view(), name="eticket_pdf"),
|
||||
path(
|
||||
|
||||
+3
-16
@@ -3,8 +3,6 @@ from urllib.parse import urlparse
|
||||
from django.http import HttpRequest
|
||||
from django.urls import resolve
|
||||
|
||||
from counter.models import Counter
|
||||
|
||||
|
||||
def is_logged_in_counter(request: HttpRequest) -> bool:
|
||||
"""Check if the request is sent from a device logged to a counter.
|
||||
@@ -20,24 +18,13 @@ def is_logged_in_counter(request: HttpRequest) -> bool:
|
||||
or the request path belongs to the counter app
|
||||
(eg. the barman went back to the main by missclick and go back
|
||||
to the counter)
|
||||
- The current session has a counter token associated with it.
|
||||
- A counter with this token exists.
|
||||
- The counter is open
|
||||
- There are barmen logged in the current session
|
||||
"""
|
||||
referer_ok = (
|
||||
"HTTP_REFERER" in request.META
|
||||
and resolve(urlparse(request.META["HTTP_REFERER"]).path).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:
|
||||
if not referer_ok and request.resolver_match.app_name != "counter":
|
||||
return False
|
||||
|
||||
return (
|
||||
Counter.objects.annotate_is_open()
|
||||
.filter(token=request.session["counter_token"], is_open=True)
|
||||
.exists()
|
||||
)
|
||||
return bool(request.barmen)
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
#
|
||||
# Copyright 2023 © AE UTBM
|
||||
# ae@utbm.fr / ae.info@utbm.fr
|
||||
#
|
||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||
# https://ae.utbm.fr.
|
||||
#
|
||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
||||
#
|
||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
|
||||
from django.http import HttpRequest, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import now
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from core.views.forms import LoginForm
|
||||
from counter.models import Counter, Permanency
|
||||
|
||||
|
||||
@require_POST
|
||||
def counter_login(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
|
||||
"""Log a user in a counter.
|
||||
|
||||
A successful login will result in the beginning of a counter duty
|
||||
for the user.
|
||||
"""
|
||||
counter = get_object_or_404(Counter, pk=counter_id)
|
||||
form = LoginForm(request, data=request.POST)
|
||||
if not form.is_valid():
|
||||
return redirect(counter.get_absolute_url() + "?credentials")
|
||||
user = form.get_user()
|
||||
if not counter.sellers.contains(user) or user in counter.barmen_list:
|
||||
return redirect(counter.get_absolute_url() + "?sellers")
|
||||
if len(counter.barmen_list) == 0:
|
||||
counter.gen_token()
|
||||
request.session["counter_token"] = counter.token
|
||||
counter.permanencies.create(user=user, start=timezone.now())
|
||||
return redirect(counter)
|
||||
|
||||
|
||||
@require_POST
|
||||
def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
|
||||
"""End the permanency of a user in this counter."""
|
||||
Permanency.objects.filter(
|
||||
counter=counter_id, user=request.POST["user_id"], end=None
|
||||
).update(end=now())
|
||||
return redirect("counter:details", counter_id=counter_id)
|
||||
+20
-20
@@ -12,8 +12,10 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
import random
|
||||
from collections import defaultdict
|
||||
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
@@ -21,6 +23,7 @@ from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404, redirect, resolve_url
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import SafeString
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import FormView
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
from ninja.main import HttpRequest
|
||||
@@ -29,13 +32,7 @@ from core.auth.mixins import CanViewMixin
|
||||
from core.models import User
|
||||
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
||||
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.views.mixins import CounterTabsMixin
|
||||
from counter.views.student_card import StudentCardFormFragment
|
||||
@@ -46,7 +43,7 @@ def get_operator(request: HttpRequest, counter: Counter, customer: Customer) ->
|
||||
return request.user
|
||||
if counter.customer_is_barman(customer):
|
||||
return customer.user
|
||||
return counter.get_random_barman()
|
||||
return random.choice(list(request.barmen))
|
||||
|
||||
|
||||
class CounterClick(
|
||||
@@ -78,7 +75,7 @@ class CounterClick(
|
||||
return kwargs
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.customer = get_object_or_404(Customer, user__id=self.kwargs["user_id"])
|
||||
self.customer = get_object_or_404(Customer, user_id=self.kwargs["user_id"])
|
||||
obj: Counter = self.get_object()
|
||||
|
||||
if not self.customer.can_buy or self.customer.user.is_banned_counter:
|
||||
@@ -96,14 +93,13 @@ class CounterClick(
|
||||
# or a seller of this counter.
|
||||
raise PermissionDenied
|
||||
|
||||
if obj.type == "BAR" and (
|
||||
not obj.is_open
|
||||
or "counter_token" not in request.session
|
||||
or request.session["counter_token"] != obj.token
|
||||
if obj.type == "BAR" and not (
|
||||
request.barmen and request.barmen.issubset(set(obj.barmen_list))
|
||||
):
|
||||
messages.error(request, _("You cannot click users on this counter"))
|
||||
return redirect(obj) # Redirect to counter
|
||||
|
||||
self.prices = obj.get_prices_for(self.customer)
|
||||
self.prices = list(obj.get_prices_for(self.customer))
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
@@ -199,7 +195,7 @@ class CounterClick(
|
||||
)
|
||||
if self.object.can_refill():
|
||||
res["refilling_fragment"] = RefillingCreateView.as_fragment()(
|
||||
self.request, customer=self.customer
|
||||
self.request, customer=self.customer, counter=self.object
|
||||
)
|
||||
return res
|
||||
|
||||
@@ -237,11 +233,13 @@ class RefillingCreateView(FragmentMixin, FormView):
|
||||
if not is_logged_in_counter(request):
|
||||
raise PermissionDenied
|
||||
|
||||
self.counter: Counter = get_object_or_404(
|
||||
Counter, token=request.session["counter_token"]
|
||||
)
|
||||
self.counter: Counter = get_object_or_404(Counter, id=self.kwargs["counter_id"])
|
||||
|
||||
if not self.counter.can_refill():
|
||||
if not (
|
||||
request.barmen
|
||||
and request.barmen.issubset(self.counter.barmen_list)
|
||||
and self.counter.can_refill()
|
||||
):
|
||||
raise PermissionDenied
|
||||
|
||||
self.operator = get_operator(request, self.counter, self.customer)
|
||||
@@ -250,6 +248,7 @@ class RefillingCreateView(FragmentMixin, FormView):
|
||||
|
||||
def render_fragment(self, request, **kwargs) -> SafeString:
|
||||
self.customer = kwargs.pop("customer")
|
||||
self.counter = kwargs.pop("counter")
|
||||
return super().render_fragment(request, **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
@@ -264,7 +263,8 @@ class RefillingCreateView(FragmentMixin, FormView):
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["action"] = reverse(
|
||||
"counter:refilling_create", kwargs={"customer_id": self.customer.pk}
|
||||
"counter:refilling_create",
|
||||
kwargs={"customer_id": self.customer.pk, "counter_id": self.counter.pk},
|
||||
)
|
||||
return kwargs
|
||||
|
||||
|
||||
+97
-52
@@ -15,78 +15,120 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import F
|
||||
from django.http import HttpRequest, HttpResponseRedirect
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.safestring import SafeString
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.views.generic import DetailView
|
||||
from django.views.generic.edit import FormMixin, ProcessFormView
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
from django.views.generic.edit import FormView
|
||||
|
||||
from core.auth.mixins import CanViewMixin
|
||||
from core.views.forms import LoginForm
|
||||
from counter.forms import GetUserForm
|
||||
from counter.models import Counter
|
||||
from core.views import FragmentMixin, UseFragmentsMixin
|
||||
from counter.forms import CounterLoginForm, GetUserForm
|
||||
from counter.models import Counter, Permanency
|
||||
from counter.utils import is_logged_in_counter
|
||||
from counter.views.mixins import CounterTabsMixin
|
||||
|
||||
|
||||
class CounterLoginFragment(FragmentMixin, SingleObjectMixin, FormView):
|
||||
model = Counter
|
||||
form_class = CounterLoginForm
|
||||
reload_on_redirect = True
|
||||
pk_url_kwarg = "counter_id"
|
||||
template_name = "counter/fragments/login.jinja"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
if self.object.type != "BAR":
|
||||
# barmen have to log in only if it is a bar,
|
||||
# so calling this view on a non-bar counter makes no sense
|
||||
raise PermissionDenied
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {
|
||||
"request": self.request,
|
||||
"counter": self.object,
|
||||
}
|
||||
|
||||
def form_valid(self, form: CounterLoginForm):
|
||||
user = form.get_user()
|
||||
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(
|
||||
CounterTabsMixin, CanViewMixin, DetailView, ProcessFormView, FormMixin
|
||||
CounterTabsMixin, UseFragmentsMixin, CanViewMixin, SingleObjectMixin, FormView
|
||||
):
|
||||
"""The public (barman) view."""
|
||||
|
||||
model = Counter
|
||||
queryset = Counter.objects.exclude(type="EBOUTIC")
|
||||
template_name = "counter/counter_main.jinja"
|
||||
pk_url_kwarg = "counter_id"
|
||||
form_class = (
|
||||
GetUserForm # Form to enter a client code and get the corresponding user id
|
||||
)
|
||||
form_class = GetUserForm
|
||||
current_tab = "counter"
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().exclude(type="EBOUTIC")
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.object: Counter = self.get_object()
|
||||
if self.object.type == "BAR":
|
||||
self.object.update_activity()
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
if self.object.type == "BAR" and not (
|
||||
"counter_token" in self.request.session
|
||||
and self.request.session["counter_token"] == self.object.token
|
||||
): # Check the token to avoid the bar to be stolen
|
||||
return HttpResponseRedirect(
|
||||
reverse_lazy(
|
||||
"counter:details",
|
||||
args=self.args,
|
||||
kwargs={"counter_id": self.object.id},
|
||||
def get_fragment_context_data(self) -> dict[str, SafeString]:
|
||||
login_fragment = (
|
||||
CounterLoginFragment.as_fragment()(self.request, counter=self.object)
|
||||
if self.object.type == "BAR"
|
||||
else ""
|
||||
)
|
||||
+ "?bad_location"
|
||||
)
|
||||
return super().post(request, *args, **kwargs)
|
||||
return super().get_fragment_context_data() | {"login_fragment": login_fragment}
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""We handle here the login form for the barman."""
|
||||
if self.request.method == "POST":
|
||||
self.object = self.get_object()
|
||||
self.object.update_activity()
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["login_form"] = LoginForm()
|
||||
kwargs["login_form"].fields["username"].widget.attrs["autofocus"] = True
|
||||
kwargs[
|
||||
"login_form"
|
||||
].cleaned_data = {} # add_error fails if there are no cleaned_data
|
||||
if "credentials" in self.request.GET:
|
||||
kwargs["login_form"].add_error(None, _("Bad credentials"))
|
||||
if "sellers" in self.request.GET:
|
||||
kwargs["login_form"].add_error(None, _("User is not barman"))
|
||||
kwargs["form"] = self.get_form()
|
||||
kwargs["form"].cleaned_data = {} # same as above
|
||||
if "bad_location" in self.request.GET:
|
||||
kwargs["form"].add_error(
|
||||
None, _("Bad location, someone is already logged in somewhere else")
|
||||
)
|
||||
if self.object.type == "BAR":
|
||||
kwargs["barmen"] = self.object.barmen_list
|
||||
elif self.request.user.is_authenticated:
|
||||
kwargs["barmen"] = [self.request.user]
|
||||
kwargs["barmen_here"] = list(
|
||||
self.request.barmen.intersection(self.object.barmen_list)
|
||||
)
|
||||
kwargs["can_click"] = (
|
||||
self.object.type == "BAR"
|
||||
and self.request.barmen
|
||||
and self.request.barmen.issubset(set(self.object.barmen_list))
|
||||
) or (
|
||||
self.object.type == "OFFICE"
|
||||
and (
|
||||
self.object.sellers.contains(self.request.user)
|
||||
or self.object.club.has_rights_in_club(self.request.user)
|
||||
)
|
||||
)
|
||||
if "last_basket" in self.request.session:
|
||||
kwargs["last_basket"] = self.request.session.pop("last_basket")
|
||||
kwargs["last_customer"] = self.request.session.pop("last_customer")
|
||||
@@ -96,14 +138,17 @@ class CounterMain(
|
||||
)
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
def form_valid(self, form: GetUserForm):
|
||||
"""We handle here the redirection, passing the user id of the asked customer."""
|
||||
self.kwargs["user_id"] = form.cleaned_data["user_id"]
|
||||
self.success_url = reverse(
|
||||
"counter:click",
|
||||
kwargs={
|
||||
"counter_id": self.kwargs["counter_id"],
|
||||
"user_id": form.cleaned_data["user_id"],
|
||||
},
|
||||
)
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy("counter:click", args=self.args, kwargs=self.kwargs)
|
||||
|
||||
|
||||
class CounterLastOperationsView(CounterTabsMixin, CanViewMixin, DetailView):
|
||||
"""Provide the last operations to allow barmen to delete them."""
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
|
||||
## Fonctionnement général
|
||||
|
||||
La boutique en ligne nécessite une interaction
|
||||
avec la banque pour son fonctionnement.
|
||||
|
||||
@@ -9,3 +11,32 @@ Nous ne pouvons donc que vous redirigez vers la doc du crédit
|
||||
agricole :
|
||||
[https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/](https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/)
|
||||
|
||||
## Limite de clic et expiration des paniers
|
||||
|
||||
Certains produits peuvent avoir un quota de vente.
|
||||
Une fois ce dernier atteint, il ne doit plus être possible de les acheter.
|
||||
|
||||
Pour éviter que cette limite soit dépassée si jamais plusieurs utilisateurs
|
||||
commandent et achètent ce produit à peu près en même temps,
|
||||
un produit est considéré comme « réservé » une fois placé dans un panier.
|
||||
La création du panier s'effectue lors de la soumission du formulaire sur l'eboutic.
|
||||
Une fois la transaction accomplie, le panier est supprimé.
|
||||
|
||||
Cependant, il reste un problème :
|
||||
que faire des utilisateurs qui créent un panier, mais ne terminent
|
||||
pas la transaction ?
|
||||
Pour résoudre ce cas, les paniers ont une durée de validité,
|
||||
définie dans le `settings.py`, grâce à deux variables :
|
||||
|
||||
- `settings.SITH_EBOUTIC_BASKET_TIMEOUT` :
|
||||
le temps pendant lequel un utilisateur peut payer avec son compte AE
|
||||
ou démarrer une etransaction
|
||||
- `settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT` :
|
||||
le temps alloué à l'utilisateur pour effectuer une etransaction ;
|
||||
au-delà de cette durée, la banque refusera le paiement
|
||||
et notifiera le sith de l'erreur.
|
||||
|
||||
Une fois expiré le temps défini par
|
||||
`settings.SITH_EBOUTIC_BASKET_TIMEOUT + settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT`,
|
||||
les produits contenus dans le panier sont à nouveau
|
||||
disponibles à la vente.
|
||||
|
||||
+10
-1
@@ -1,3 +1,6 @@
|
||||
from typing import Any
|
||||
|
||||
from ninja import Status
|
||||
from ninja_extra import ControllerBase, api_controller, route
|
||||
from ninja_extra.exceptions import NotFound
|
||||
|
||||
@@ -8,13 +11,19 @@ from eboutic.models import Basket
|
||||
|
||||
@api_controller("/etransaction", permissions=[CanView])
|
||||
class EtransactionInfoController(ControllerBase):
|
||||
@route.get("/data/{basket_id}", url_name="etransaction_data")
|
||||
@route.get(
|
||||
"/data/{basket_id}",
|
||||
url_name="etransaction_data",
|
||||
response={200: dict[str, Any], 410: str},
|
||||
)
|
||||
def fetch_etransaction_data(self, basket_id: int):
|
||||
"""Generate the data to pay an eboutic command with paybox.
|
||||
|
||||
The data is generated with the basket that is used by the current session.
|
||||
"""
|
||||
basket: Basket = self.get_object_or_exception(Basket, pk=basket_id)
|
||||
if basket.is_expired:
|
||||
return Status(410, "This basket is expired.")
|
||||
try:
|
||||
return dict(basket.get_e_transaction_data())
|
||||
except BillingInfo.DoesNotExist as e:
|
||||
|
||||
+36
-37
@@ -16,7 +16,6 @@ from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Self
|
||||
|
||||
from dict2xml import dict2xml
|
||||
@@ -24,6 +23,7 @@ from django.conf import settings
|
||||
from django.db import DataError, models
|
||||
from django.db.models import F, OuterRef, Subquery, Sum
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.models import User
|
||||
@@ -39,30 +39,6 @@ from counter.models import (
|
||||
)
|
||||
|
||||
|
||||
class BillingInfoState(Enum):
|
||||
VALID = 1
|
||||
EMPTY = 2
|
||||
MISSING_PHONE_NUMBER = 3
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, info: BillingInfo | None) -> BillingInfoState:
|
||||
if info is None:
|
||||
return cls.EMPTY
|
||||
for attr in [
|
||||
"first_name",
|
||||
"last_name",
|
||||
"address_1",
|
||||
"zip_code",
|
||||
"city",
|
||||
"country",
|
||||
]:
|
||||
if getattr(info, attr) == "":
|
||||
return cls.EMPTY
|
||||
if info.phone_number is None:
|
||||
return cls.MISSING_PHONE_NUMBER
|
||||
return cls.VALID
|
||||
|
||||
|
||||
class Basket(models.Model):
|
||||
"""Basket is built when the user connects to an eboutic page."""
|
||||
|
||||
@@ -95,6 +71,19 @@ class Basket(models.Model):
|
||||
]
|
||||
)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Return True if this basket is expired.
|
||||
|
||||
An expired basket can no longer be used tp pay with sith account
|
||||
or to start an etransaction.
|
||||
|
||||
Warnings:
|
||||
Users have an additional time if they pay with an etransaction,
|
||||
so an expired basket may be purchased after its expiration in that case.
|
||||
"""
|
||||
return (self.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT) <= now()
|
||||
|
||||
def generate_sales(
|
||||
self, counter, seller: User, payment_method: Selling.PaymentMethod
|
||||
):
|
||||
@@ -133,15 +122,22 @@ class Basket(models.Model):
|
||||
]
|
||||
|
||||
def get_e_transaction_data(self) -> list[tuple[str, str]]:
|
||||
"""Get data for etransaction payment.
|
||||
|
||||
Raises:
|
||||
Customer.DoesNotExist: if the user linked to this basket
|
||||
has no customer account
|
||||
BillingInfo.DoesNotExist: if the user linked to this basket has no
|
||||
billing infos, or incorrect billing infos.
|
||||
ValueError: if this is called on a basket which payment delay is expired.
|
||||
"""
|
||||
user = self.user
|
||||
if not hasattr(user, "customer"):
|
||||
raise Customer.DoesNotExist
|
||||
if self.is_expired:
|
||||
raise ValueError("This method cannot be called on an expired basket.")
|
||||
customer = user.customer
|
||||
if (
|
||||
not hasattr(user.customer, "billing_infos")
|
||||
or BillingInfoState.from_model(user.customer.billing_infos)
|
||||
!= BillingInfoState.VALID
|
||||
):
|
||||
if not hasattr(user.customer, "billing_infos"):
|
||||
raise BillingInfo.DoesNotExist
|
||||
cart = {
|
||||
"shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}}
|
||||
@@ -155,6 +151,10 @@ class Basket(models.Model):
|
||||
("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT),
|
||||
("PBX_TOTAL", str(int(self.total * 100))),
|
||||
("PBX_DEVISE", "978"), # This is Euro
|
||||
(
|
||||
"PBX_DISPLAY",
|
||||
str(int(settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT.total_seconds())),
|
||||
),
|
||||
("PBX_CMD", str(self.id)),
|
||||
("PBX_PORTEUR", user.email),
|
||||
("PBX_RETOUR", "Amount:M;BasketID:R;Auto:A;Error:E;Sig:K"),
|
||||
@@ -219,16 +219,14 @@ class Invoice(models.Model):
|
||||
if self.validated:
|
||||
raise DataError(_("Invoice already validated"))
|
||||
customer, _created = Customer.get_or_create(user=self.user)
|
||||
kwargs = {
|
||||
"counter": get_eboutic(),
|
||||
"customer": customer,
|
||||
"date": self.date,
|
||||
"payment_method": Selling.PaymentMethod.CARD,
|
||||
}
|
||||
kwargs = {"counter": get_eboutic(), "customer": customer, "date": self.date}
|
||||
for i in self.items.select_related("product"):
|
||||
if i.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
|
||||
Refilling.objects.create(
|
||||
**kwargs, operator=self.user, amount=i.unit_price * i.quantity
|
||||
**kwargs,
|
||||
operator=self.user,
|
||||
amount=i.unit_price * i.quantity,
|
||||
payment_method=Refilling.PaymentMethod.CARD,
|
||||
)
|
||||
else:
|
||||
Selling.objects.create(
|
||||
@@ -239,6 +237,7 @@ class Invoice(models.Model):
|
||||
seller=self.user,
|
||||
unit_price=i.unit_price,
|
||||
quantity=i.quantity,
|
||||
payment_method=Selling.PaymentMethod.CARD,
|
||||
)
|
||||
self.validated = True
|
||||
self.save()
|
||||
|
||||
@@ -1,22 +1,76 @@
|
||||
import { type Notification, NotificationLevel } from "#core:utils/notifications";
|
||||
import { etransactioninfoFetchEtransactionData } from "#openapi";
|
||||
|
||||
interface Basket {
|
||||
id: number;
|
||||
timeout: Date;
|
||||
}
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("etransaction", (initialData, basketId: number) => ({
|
||||
Alpine.data(
|
||||
"etransaction",
|
||||
(initialData: Record<string, string>, basket: Basket) => ({
|
||||
data: initialData,
|
||||
isCbAvailable: Object.keys(initialData).length > 0,
|
||||
isSithAvailable: true,
|
||||
|
||||
async fill() {
|
||||
this.isCbAvailable = false;
|
||||
const res = await etransactioninfoFetchEtransactionData({
|
||||
path: {
|
||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||
basket_id: basketId,
|
||||
},
|
||||
});
|
||||
if (res.response.ok) {
|
||||
this.data = res.data;
|
||||
this.isCbAvailable = true;
|
||||
init() {
|
||||
const now = new Date();
|
||||
const timeout = basket.timeout.getTime() - now.getTime();
|
||||
if (timeout > 0) {
|
||||
// if not going inside this condition, it means that
|
||||
// basket was already outdated at initial page load,
|
||||
// in which case disabling buttons and displaying
|
||||
// error message has been done at rendering time
|
||||
setTimeout(() => this.timeoutBasket(), timeout);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Make this basket into a timeout state.
|
||||
* All submission inputs are disabled, and an error message is displayed.
|
||||
*/
|
||||
timeoutBasket() {
|
||||
this.isCbAvailable = false;
|
||||
this.isSithAvailable = false;
|
||||
const message = gettext("Basket expired");
|
||||
|
||||
const existingNotif: Notification | undefined = this.$notifications
|
||||
.getAll()
|
||||
.find(
|
||||
(n: Notification) =>
|
||||
n.tag === NotificationLevel.Error && n.text === message,
|
||||
);
|
||||
if (existingNotif === undefined) {
|
||||
this.$notifications.error(message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh the data used for etransaction.
|
||||
*
|
||||
* Note: if this is called while the basket is expired, it will be a no-op
|
||||
*/
|
||||
async fill() {
|
||||
if (new Date() > basket.timeout) {
|
||||
// refresh etransaction data only if the basket is still valid.
|
||||
this.timeoutBasket();
|
||||
return;
|
||||
}
|
||||
this.isCbAvailable = false;
|
||||
const res = await etransactioninfoFetchEtransactionData({
|
||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||
path: { basket_id: basket.id },
|
||||
});
|
||||
if (res.response.ok) {
|
||||
this.data = res.data as Record<string, string>;
|
||||
this.isCbAvailable = true;
|
||||
} else if (res.response.status === 410) {
|
||||
// The basket is expired, so no payment method should be available at all.
|
||||
// This shouldn't happen, because we don't send the request
|
||||
// when the timeout is passed, but we are better safe than sorry
|
||||
this.timeoutBasket();
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ const BASKET_CACHE_KEY = "basket";
|
||||
const BASKET_CACHE_VERSION = 1;
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("basket", (lastPurchaseTime?: number) => ({
|
||||
Alpine.data("basket", (validPrices: number[], lastPurchaseTime?: number) => ({
|
||||
basket: [] as BasketItem[],
|
||||
|
||||
init() {
|
||||
@@ -19,15 +19,6 @@ document.addEventListener("alpine:init", () => {
|
||||
this.$watch("basket", () => {
|
||||
this.saveBasket();
|
||||
});
|
||||
// Invalidate basket if a purchase was made
|
||||
if (lastPurchaseTime !== null && localStorage.basketTimestamp !== undefined) {
|
||||
if (
|
||||
new Date(lastPurchaseTime) >=
|
||||
new Date(Number.parseInt(localStorage.basketTimestamp, 10))
|
||||
) {
|
||||
this.basket = [];
|
||||
}
|
||||
}
|
||||
document
|
||||
.getElementById("id_form-TOTAL_FORMS")
|
||||
.setAttribute(":value", "basket.length");
|
||||
@@ -37,7 +28,22 @@ document.addEventListener("alpine:init", () => {
|
||||
const cached = versionedLocalStorage.getItem<BasketItem[]>(BASKET_CACHE_KEY, {
|
||||
version: BASKET_CACHE_VERSION,
|
||||
});
|
||||
return cached ?? [];
|
||||
if (!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() {
|
||||
|
||||
@@ -21,9 +21,10 @@
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#billing-infos-fragment"
|
||||
x-show="collapsed"
|
||||
x-cloak
|
||||
>
|
||||
{% csrf_token %}
|
||||
{{ form.as_p() }}
|
||||
{{ form }}
|
||||
<br>
|
||||
<input
|
||||
type="submit" class="btn btn-blue clickable"
|
||||
|
||||
@@ -16,17 +16,20 @@
|
||||
<h3>{% trans %}Eboutic{% endtrans %}</h3>
|
||||
|
||||
<script type="text/javascript">
|
||||
let billingInfos = {{ billing_infos|safe }};
|
||||
const billingInfos = {{ billing_infos|safe }};
|
||||
</script>
|
||||
|
||||
<div x-data="etransaction(billingInfos, {{ basket.id }})">
|
||||
<div x-data='etransaction(
|
||||
billingInfos,
|
||||
{ id: {{ basket.id }}, timeout: new Date("{{ basket.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT }}") }
|
||||
)'>
|
||||
<p>{% trans %}Basket: {% endtrans %}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Article</td>
|
||||
<td>Quantity</td>
|
||||
<td>Unit price</td>
|
||||
<td>{% trans %}Quantity{% endtrans %}</td>
|
||||
<td>{% trans %}Unit price{% endtrans %}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -60,10 +63,41 @@
|
||||
<div @htmx:after-request="fill">
|
||||
{{ billing_infos_form }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include "core/base/notifications.jinja" %}
|
||||
{% if settings.SITH_EBOUTIC_CB_ENABLED or (basket.total <= user.account_balance and not basket.contains_refilling_item) %}
|
||||
{# don't display the cgv form if no payment mean is available #}
|
||||
<form id="cgv-form" x-ref="cgvForm">
|
||||
{# In order to have one CGV button for both payment means,
|
||||
we have a third dummy form, containing only the cgv button,
|
||||
which validation is triggered when one of the two other forms is submitted.
|
||||
If the validation of this form fails, the submit event will be cancelled. #}
|
||||
<div class="form-group">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="cgv-checkbox"
|
||||
name="cgv"
|
||||
required
|
||||
{% if basket.is_expired %}
|
||||
disabled="disabled"
|
||||
{% else %}
|
||||
:disabled="!isCbAvailable && !isSithAvailable"
|
||||
{% endif %}
|
||||
>
|
||||
<label for="cgv-checkbox">
|
||||
{% trans trimmed %}I have read and I accept{% endtrans %}
|
||||
<a href="{{ url('core:page', 'cgv') }}">{% trans %}the general terms and conditions{% endtrans%}</a>
|
||||
{%trans%}of the student association of the UTBM{% endtrans %}
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if settings.SITH_EBOUTIC_CB_ENABLED %}
|
||||
<form
|
||||
method="post"
|
||||
id="bank-payment-form"
|
||||
action="{{ settings.SITH_EBOUTIC_ET_URL }}"
|
||||
@submit="if (!$refs.cgvForm.reportValidity()) $event.preventDefault()"
|
||||
>
|
||||
<template x-for="[key, value] in Object.entries(data)" :key="key">
|
||||
<input type="hidden" :name="key" :value="value">
|
||||
@@ -72,7 +106,11 @@
|
||||
x-cloak
|
||||
type="submit"
|
||||
id="bank-submit-button"
|
||||
{% if basket.is_expired %}
|
||||
disabled="disabled"
|
||||
{% else %}
|
||||
:disabled="!isCbAvailable"
|
||||
{% endif %}
|
||||
class="btn btn-blue"
|
||||
value="{% trans %}Pay with credit card{% endtrans %}"
|
||||
/>
|
||||
@@ -91,9 +129,23 @@
|
||||
{% elif basket.total > user.account_balance %}
|
||||
<p>{% trans %}AE account payment disabled because you do not have enough money remaining.{% endtrans %}</p>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form">
|
||||
<form
|
||||
method="post"
|
||||
action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}"
|
||||
id="sith-payment-form"
|
||||
@submit="if (!$refs.cgvForm.reportValidity()) $event.preventDefault()"
|
||||
>
|
||||
{% csrf_token %}
|
||||
<input class="btn btn-blue" type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/>
|
||||
<input
|
||||
{% if basket.is_expired %}
|
||||
disabled="disabled"
|
||||
{% else %}
|
||||
:disabled="!isSithAvailable"
|
||||
{% endif %}
|
||||
class="btn btn-blue"
|
||||
type="submit"
|
||||
value="{% trans %}Pay with Sith account{% endtrans %}"
|
||||
/>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,17 @@
|
||||
{% block content %}
|
||||
<h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1>
|
||||
|
||||
<div id="eboutic" x-data="basket({{ last_purchase_time }})">
|
||||
<div
|
||||
id="eboutic"
|
||||
x-data="basket(
|
||||
[{%- for prices in categories -%}
|
||||
{%- for p in prices -%}
|
||||
{% if not p.sold_out %}{{ p.id }},{% endif %}
|
||||
{%- endfor -%}
|
||||
{%- endfor -%}],
|
||||
{{ last_purchase_time }},
|
||||
)"
|
||||
>
|
||||
<div id="basket">
|
||||
<h3>Panier</h3>
|
||||
<form method="post" action="">
|
||||
@@ -187,9 +197,10 @@
|
||||
{% for price in prices %}
|
||||
<button
|
||||
id="{{ price.id }}"
|
||||
class="card product-button clickable shadow"
|
||||
class="card clickable shadow"
|
||||
:class="{selected: basket.some((i) => i.priceId === {{ price.id }})}"
|
||||
@click='addFromCatalog({{ price.id }}, {{ price.full_label|tojson }}, {{ price.amount }})'
|
||||
{% if price.sold_out %}disabled{% endif %}
|
||||
>
|
||||
{% if price.product.icon %}
|
||||
<img
|
||||
@@ -202,6 +213,9 @@
|
||||
{% endif %}
|
||||
<div class="card-content">
|
||||
<h4 class="card-title">{{ price.full_label }}</h4>
|
||||
{% if price.sold_out -%}
|
||||
<p><em>{% trans %}Product sold out{% endtrans %}</em></p>
|
||||
{%- endif %}
|
||||
<p>{{ price.amount }} €</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import freezegun
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse
|
||||
from django.test import TestCase
|
||||
from django.test.client import Client
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import localdate
|
||||
from django.utils.timezone import localdate, now
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
import eboutic.models
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import Group, User
|
||||
from counter.baker_recipes import (
|
||||
@@ -130,9 +135,11 @@ def test_eboutic_basket_expiry(
|
||||
_bulk_create=True,
|
||||
)
|
||||
|
||||
soup = BeautifulSoup(client.get(reverse("eboutic:main")).text, "lxml")
|
||||
assert (
|
||||
f'x-data="basket({int(expected.timestamp() * 1000) if expected else "null"})"'
|
||||
in client.get(reverse("eboutic:main")).text
|
||||
# remove any space from the value before asserting
|
||||
re.sub(r"\s+", "", soup.find(id="eboutic").attrs["x-data"])
|
||||
== f"basket([],{int(expected.timestamp() * 1000) if expected else 'null'},)"
|
||||
)
|
||||
|
||||
|
||||
@@ -231,26 +238,45 @@ class TestEboutic(TestCase):
|
||||
|
||||
def test_add_forbidden_product(self):
|
||||
self.client.force_login(self.new_customer)
|
||||
response = self.submit_basket([BasketItem(self.beer.id, 1)])
|
||||
for product in self.beer, self.cotiz, self.not_in_counter:
|
||||
response = self.submit_basket([BasketItem(product.id, 1)])
|
||||
assert response.status_code == 200
|
||||
assert Basket.objects.first() is None
|
||||
assert not Basket.objects.exists()
|
||||
|
||||
response = self.submit_basket([BasketItem(self.cotiz.id, 1)])
|
||||
def test_sold_out_product(self):
|
||||
sold_out = product_recipe.make(
|
||||
clic_limit=3, counters=[self.eboutic], product_type=baker.make(ProductType)
|
||||
)
|
||||
price = price_recipe.make(product=sold_out, groups=[self.group_cotiz], amount=0)
|
||||
sale_recipe.make(
|
||||
product=sold_out,
|
||||
customer=self.subscriber.customer,
|
||||
unit_price=0,
|
||||
quantity=1,
|
||||
)
|
||||
baker.make(
|
||||
eboutic.models.BasketItem,
|
||||
basket=baker.make(Basket),
|
||||
product=sold_out,
|
||||
quantity=2,
|
||||
)
|
||||
self.client.force_login(self.subscriber)
|
||||
response = self.submit_basket([BasketItem(price.id, 1)])
|
||||
assert response.status_code == 200
|
||||
assert 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
|
||||
|
||||
self.client.force_login(self.new_customer)
|
||||
response = self.submit_basket([BasketItem(self.cotiz.id, 1)])
|
||||
assert response.status_code == 200
|
||||
assert Basket.objects.first() is None
|
||||
|
||||
response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)])
|
||||
assert response.status_code == 200
|
||||
assert Basket.objects.first() is None
|
||||
assert Basket.objects.count() == 1
|
||||
with freezegun.freeze_time(
|
||||
now()
|
||||
+ settings.SITH_EBOUTIC_BASKET_TIMEOUT
|
||||
+ settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT
|
||||
):
|
||||
# after a while, unpaid basket items should expire and make the
|
||||
# product available again.
|
||||
response = self.submit_basket([BasketItem(price.id, 1)])
|
||||
assertRedirects(
|
||||
response,
|
||||
reverse("eboutic:checkout", kwargs={"basket_id": Basket.objects.last().id}),
|
||||
)
|
||||
assert Basket.objects.count() == 2
|
||||
|
||||
def test_create_basket(self):
|
||||
self.client.force_login(self.new_customer)
|
||||
|
||||
@@ -37,12 +37,9 @@ class TestBillingInfo:
|
||||
|
||||
def test_edit_infos(self, client: Client, payload: dict[str, str]):
|
||||
user = subscriber_user.make()
|
||||
baker.make(BillingInfo, customer=user.customer)
|
||||
baker.make(BillingInfo, customer=user.customer, phone_number="06 01 02 03 04")
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
reverse("eboutic:billing_infos"),
|
||||
payload,
|
||||
)
|
||||
response = client.post(reverse("eboutic:billing_infos"), payload)
|
||||
user.refresh_from_db()
|
||||
infos = BillingInfo.objects.get(customer__user=user)
|
||||
assert response.status_code == 302
|
||||
|
||||
@@ -3,6 +3,7 @@ import urllib
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import freezegun
|
||||
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||
from cryptography.hazmat.primitives.hashes import SHA1
|
||||
@@ -17,7 +18,7 @@ from pytest_django.asserts import assertRedirects
|
||||
|
||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||
from counter.baker_recipes import price_recipe, product_recipe
|
||||
from counter.models import Product, ProductType, Selling
|
||||
from counter.models import Product, ProductType, Refilling, Selling
|
||||
from counter.tests.test_counter import force_refill_user
|
||||
from eboutic.models import Basket, BasketItem
|
||||
|
||||
@@ -105,7 +106,7 @@ class TestPaymentSith(TestPaymentBase):
|
||||
),
|
||||
reverse("eboutic:payment_result", kwargs={"result": "success"}),
|
||||
)
|
||||
assert Basket.objects.filter(id=self.basket.id).first() is None
|
||||
assert not Basket.objects.filter(id=self.basket.id).exists()
|
||||
self.customer.customer.refresh_from_db()
|
||||
assert self.customer.customer.amount == Decimal(1)
|
||||
|
||||
@@ -139,10 +140,7 @@ class TestPaymentSith(TestPaymentBase):
|
||||
assert len(messages) == 1
|
||||
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
|
||||
assert messages[0].message == "Solde insuffisant"
|
||||
|
||||
assert Basket.objects.contains(self.basket), (
|
||||
"After an unsuccessful request, the basket should be kept"
|
||||
)
|
||||
assert not Basket.objects.filter(id=self.basket.id).exists()
|
||||
|
||||
def test_refilling_in_basket(self):
|
||||
BasketItem.from_price(self.refilling.prices.first(), 1, self.basket).save()
|
||||
@@ -157,7 +155,7 @@ class TestPaymentSith(TestPaymentBase):
|
||||
response,
|
||||
reverse("eboutic:payment_result", kwargs={"result": "failure"}),
|
||||
)
|
||||
assert Basket.objects.filter(id=self.basket.id).first() is not None
|
||||
assert not Basket.objects.filter(id=self.basket.id).exists()
|
||||
messages = list(get_messages(response.wsgi_request))
|
||||
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
|
||||
assert (
|
||||
@@ -167,6 +165,24 @@ class TestPaymentSith(TestPaymentBase):
|
||||
self.customer.customer.refresh_from_db()
|
||||
assert self.customer.customer.amount == initial_account_balance
|
||||
|
||||
def test_basket_expired(self):
|
||||
self.client.force_login(self.customer)
|
||||
initial_account_balance = self.customer.customer.amount
|
||||
with freezegun.freeze_time(settings.SITH_EBOUTIC_BASKET_TIMEOUT):
|
||||
response = self.client.post(
|
||||
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
|
||||
)
|
||||
assertRedirects(
|
||||
response,
|
||||
reverse("eboutic:payment_result", kwargs={"result": "failure"}),
|
||||
)
|
||||
messages = list(get_messages(response.wsgi_request))
|
||||
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
|
||||
assert messages[0].message == "Panier expiré"
|
||||
assert not Basket.objects.filter(id=self.basket.id).exists()
|
||||
self.customer.customer.refresh_from_db()
|
||||
assert self.customer.customer.amount == initial_account_balance
|
||||
|
||||
|
||||
class TestPaymentCard(TestPaymentBase):
|
||||
def generate_bank_valid_answer(self, basket: Basket):
|
||||
@@ -236,6 +252,10 @@ class TestPaymentCard(TestPaymentBase):
|
||||
|
||||
self.customer.customer.refresh_from_db()
|
||||
assert self.customer.customer.amount == price.amount * 2
|
||||
refill = self.customer.customer.refillings.last()
|
||||
assert refill is not None
|
||||
assert refill.amount == price.amount * 2
|
||||
assert refill.payment_method == Refilling.PaymentMethod.CARD
|
||||
|
||||
def test_multiple_responses(self):
|
||||
bank_response = self.generate_bank_valid_answer(self.basket)
|
||||
|
||||
+35
-24
@@ -33,12 +33,14 @@ from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||
from django.db import DatabaseError, transaction
|
||||
from django.db.models import Subquery
|
||||
from django.db.models import Exists, OuterRef, Subquery
|
||||
from django.db.models.fields import forms
|
||||
from django.db.utils import cached_property
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.formats import localize
|
||||
from django.utils.timezone import localtime
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_GET
|
||||
from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View
|
||||
@@ -56,7 +58,7 @@ from counter.models import (
|
||||
Selling,
|
||||
get_eboutic,
|
||||
)
|
||||
from eboutic.models import Basket, BasketItem, BillingInfoState, Invoice, InvoiceItem
|
||||
from eboutic.models import Basket, BasketItem, Invoice, InvoiceItem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
||||
@@ -90,7 +92,9 @@ class EbouticMainView(LoginRequiredMixin, FormView):
|
||||
kwargs["form_kwargs"] = {
|
||||
"customer": self.customer,
|
||||
"counter": get_eboutic(),
|
||||
"allowed_prices": {price.id: price for price in self.prices},
|
||||
"allowed_prices": {
|
||||
price.id: price for price in self.prices if not price.sold_out
|
||||
},
|
||||
}
|
||||
return kwargs
|
||||
|
||||
@@ -116,9 +120,14 @@ class EbouticMainView(LoginRequiredMixin, FormView):
|
||||
|
||||
@cached_property
|
||||
def prices(self) -> list[Price]:
|
||||
return get_eboutic().get_prices_for(
|
||||
self.customer,
|
||||
order_by=["product__product_type__order", "product_id", "amount"],
|
||||
eboutic = get_eboutic()
|
||||
sold_out_subquery = ~Exists(
|
||||
eboutic.products.under_clic_limit().filter(id=OuterRef("product_id"))
|
||||
)
|
||||
return list(
|
||||
eboutic.get_prices_for(self.customer)
|
||||
.annotate(sold_out=sold_out_subquery)
|
||||
.order_by("product__product_type__order", "product_id", "amount")
|
||||
)
|
||||
|
||||
@cached_property
|
||||
@@ -178,7 +187,7 @@ def payment_result(request, result: str) -> HttpResponse:
|
||||
class BillingInfoFormFragment(
|
||||
LoginRequiredMixin, FragmentMixin, SuccessMessageMixin, UpdateView
|
||||
):
|
||||
"""Update billing info"""
|
||||
"""Update or create billing info"""
|
||||
|
||||
model = BillingInfo
|
||||
form_class = BillingInfoForm
|
||||
@@ -187,9 +196,7 @@ class BillingInfoFormFragment(
|
||||
|
||||
def get_initial(self):
|
||||
if self.object is None:
|
||||
return {
|
||||
"country": Country(code="FR"),
|
||||
}
|
||||
return {"country": Country(code="FR")}
|
||||
return {}
|
||||
|
||||
def render_fragment(self, request, **kwargs) -> SafeString:
|
||||
@@ -211,24 +218,13 @@ class BillingInfoFormFragment(
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["billing_infos_state"] = BillingInfoState.from_model(self.object)
|
||||
kwargs["action"] = reverse("eboutic:billing_infos")
|
||||
match BillingInfoState.from_model(self.object):
|
||||
case BillingInfoState.EMPTY:
|
||||
if not self.object:
|
||||
messages.warning(
|
||||
self.request,
|
||||
_(
|
||||
"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.",
|
||||
"You must fill your billing infos "
|
||||
"if you want to pay with your credit card"
|
||||
),
|
||||
)
|
||||
return kwargs
|
||||
@@ -255,6 +251,15 @@ class EbouticCheckout(CanViewMixin, UseFragmentsMixin, DetailView):
|
||||
kwargs["customer_amount"] = None
|
||||
kwargs["billing_infos"] = {}
|
||||
|
||||
if self.object.is_expired:
|
||||
messages.error(self.request, _("Basket expired"))
|
||||
else:
|
||||
timeout = self.object.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT
|
||||
messages.warning(
|
||||
self.request,
|
||||
_("Basket available until %(until)s")
|
||||
% {"until": localize(localtime(timeout).time())},
|
||||
)
|
||||
with contextlib.suppress(BillingInfo.DoesNotExist):
|
||||
kwargs["billing_infos"] = json.dumps(
|
||||
dict(self.object.get_e_transaction_data())
|
||||
@@ -268,9 +273,14 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
basket = self.get_object()
|
||||
if basket.is_expired:
|
||||
messages.error(self.request, _("Basket expired"))
|
||||
basket.delete()
|
||||
return redirect("eboutic:payment_result", "failure")
|
||||
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
||||
if basket.items.filter(product__product_type_id=refilling).exists():
|
||||
messages.error(self.request, _("You can't buy a refilling with sith money"))
|
||||
basket.delete()
|
||||
return redirect("eboutic:payment_result", "failure")
|
||||
|
||||
eboutic = get_eboutic()
|
||||
@@ -288,6 +298,7 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
|
||||
except DatabaseError as e:
|
||||
sentry_sdk.capture_exception(e)
|
||||
except ValidationError as e:
|
||||
basket.delete()
|
||||
messages.error(self.request, e.message)
|
||||
return redirect("eboutic:payment_result", "failure")
|
||||
|
||||
|
||||
+134
-30
@@ -1,6 +1,18 @@
|
||||
from datetime import timedelta
|
||||
from itertools import groupby, islice
|
||||
from operator import attrgetter
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.forms.models import ModelChoiceIterator, ModelChoiceIteratorValue
|
||||
from django.utils.timezone import localdate, localtime
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from club.forms import ClubRoleChoiceField
|
||||
from club.models import ClubRole, Membership
|
||||
from club.widgets.ajax_select import AutoCompleteSelectMultipleClub
|
||||
from core.models import User
|
||||
from core.views.forms import SelectDateTime
|
||||
from core.views.widgets.ajax_select import (
|
||||
@@ -79,26 +91,19 @@ class VoteForm(forms.Form):
|
||||
class RoleForm(forms.ModelForm):
|
||||
"""Form for creating a role."""
|
||||
|
||||
required_css_class = "required"
|
||||
error_css_class = "error"
|
||||
|
||||
class Meta:
|
||||
model = Role
|
||||
fields = ["title", "election", "description", "max_choice"]
|
||||
widgets = {"election": AutoCompleteSelect}
|
||||
fields = ["club_role", "title", "description", "max_choice"]
|
||||
field_classes = {"club_role": ClubRoleChoiceField}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
election_id = kwargs.pop("election_id", None)
|
||||
def __init__(self, *args, election: Election, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if election_id:
|
||||
self.fields["election"].queryset = Election.objects.filter(
|
||||
id=election_id
|
||||
).all()
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
title = cleaned_data.get("title")
|
||||
election = cleaned_data.get("election")
|
||||
if Role.objects.filter(title=title, election=election).exists():
|
||||
raise forms.ValidationError(
|
||||
_("This role already exists for this election"), code="invalid"
|
||||
self.instance.election = election
|
||||
self.fields["club_role"].queryset = ClubRole.objects.filter(
|
||||
is_board=True, club__in=election.clubs.all()
|
||||
)
|
||||
|
||||
|
||||
@@ -108,21 +113,21 @@ class ElectionListForm(forms.ModelForm):
|
||||
fields = ("title", "election")
|
||||
widgets = {"election": AutoCompleteSelect}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
election_id = kwargs.pop("election_id", None)
|
||||
def __init__(self, *args, election: Election, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if election_id:
|
||||
self.fields["election"].queryset = Election.objects.filter(
|
||||
id=election_id
|
||||
).all()
|
||||
self.instance.election = election
|
||||
|
||||
|
||||
class ElectionForm(forms.ModelForm):
|
||||
required_css_class = "required"
|
||||
error_css_class = "error"
|
||||
|
||||
class Meta:
|
||||
model = Election
|
||||
fields = [
|
||||
"title",
|
||||
"description",
|
||||
"clubs",
|
||||
"archived",
|
||||
"start_candidature",
|
||||
"end_candidature",
|
||||
@@ -134,21 +139,120 @@ class ElectionForm(forms.ModelForm):
|
||||
"candidature_groups",
|
||||
]
|
||||
widgets = {
|
||||
"clubs": AutoCompleteSelectMultipleClub,
|
||||
"edit_groups": AutoCompleteSelectMultipleGroup,
|
||||
"view_groups": AutoCompleteSelectMultipleGroup,
|
||||
"vote_groups": AutoCompleteSelectMultipleGroup,
|
||||
"candidature_groups": AutoCompleteSelectMultipleGroup,
|
||||
"start_date": SelectDateTime,
|
||||
"end_date": SelectDateTime,
|
||||
"start_candidature": SelectDateTime,
|
||||
"end_candidature": SelectDateTime,
|
||||
}
|
||||
|
||||
start_date = forms.DateTimeField(
|
||||
label=_("Start date"), widget=SelectDateTime, required=True
|
||||
|
||||
class ElectionCreateForm(ElectionForm):
|
||||
"""ElectionForm, but specifically for creation."""
|
||||
|
||||
def __init__(self, *args, initial: dict | None = None, **kwargs):
|
||||
# propose sound default timestamps :
|
||||
# start of candidatures at tomorrow 00h01, start of votes a week later.
|
||||
start = localtime().replace(hour=0, minute=1, second=0) + timedelta(days=1)
|
||||
default_initial = {
|
||||
"start_candidature": start,
|
||||
"end_candidature": start + timedelta(days=7, minutes=-2), # 23h59
|
||||
"start_date": start + timedelta(days=7), # 00h01
|
||||
"end_date": start + timedelta(days=14, minutes=-2), # 23h59
|
||||
"view_groups": [settings.SITH_GROUP_PUBLIC_ID],
|
||||
"vote_groups": [settings.SITH_GROUP_SUBSCRIBERS_ID],
|
||||
"candidature_groups": [settings.SITH_GROUP_SUBSCRIBERS_ID],
|
||||
}
|
||||
if initial:
|
||||
default_initial.update(initial)
|
||||
super().__init__(*args, initial=default_initial, **kwargs)
|
||||
|
||||
def save(self, commit=True): # noqa: FBT002
|
||||
instance = super().save(commit=commit)
|
||||
if commit:
|
||||
ElectionList.objects.create(title="Candidat⸱e libre", election=instance)
|
||||
return instance
|
||||
|
||||
|
||||
class ElectionWinnerChoiceIterator(ModelChoiceIterator):
|
||||
"""Iterate over the candidates that gathered enough votes"""
|
||||
|
||||
def __iter__(self):
|
||||
# for each role, yield only the N first candidates,
|
||||
# where N is the election role max_choice
|
||||
qs = (
|
||||
self.queryset.annotate(nb_votes=Count("votes"))
|
||||
.order_by("role__order", "-nb_votes")
|
||||
.select_related("role", "user", "role__club_role", "role__club_role__club")
|
||||
)
|
||||
end_date = forms.DateTimeField(
|
||||
label=_("End date"), widget=SelectDateTime, required=True
|
||||
yield from (
|
||||
(
|
||||
f"{role.title} \u2013 {role.club_role.club.name}",
|
||||
[self.choice(cand) for cand in islice(candidates, role.max_choice)],
|
||||
)
|
||||
start_candidature = forms.DateTimeField(
|
||||
label=_("Start candidature"), widget=SelectDateTime, required=True
|
||||
for role, candidates in groupby(qs, key=attrgetter("role"))
|
||||
)
|
||||
end_candidature = forms.DateTimeField(
|
||||
label=_("End candidature"), widget=SelectDateTime, required=True
|
||||
|
||||
def choice(self, obj: Candidature):
|
||||
return (
|
||||
ModelChoiceIteratorValue(self.field.prepare_value(obj), obj),
|
||||
obj.user.get_full_name(),
|
||||
)
|
||||
|
||||
|
||||
class ElectionWinnerChoiceField(forms.ModelMultipleChoiceField):
|
||||
"""Custom `ModelChoiceField` for `[ClubRole][club.models.ClubRole]`.
|
||||
|
||||
If only one club is involved, behave like the base `ModelChoiceField`.
|
||||
If dealing with the roles of multiple clubs, group the roles
|
||||
into a different `optgroup` for each club.
|
||||
"""
|
||||
|
||||
iterator = ElectionWinnerChoiceIterator
|
||||
widget = forms.CheckboxSelectMultiple
|
||||
|
||||
|
||||
class ApplyElectionResultForm(forms.Form):
|
||||
"""Form to select winners of an election, and automatically apply the results."""
|
||||
|
||||
candidates = ElectionWinnerChoiceField(Candidature.objects.none())
|
||||
|
||||
def __init__(self, *args, election: Election, **kwargs):
|
||||
self.election = election
|
||||
super().__init__(*args, **kwargs)
|
||||
qs = Candidature.objects.filter(
|
||||
role__election=election, role__club_role__isnull=False
|
||||
)
|
||||
# pass all candidates to the ModelChoiceField ;
|
||||
# its inner choice iterator will take care of filtering only the winners.
|
||||
self.fields["candidates"].queryset = qs
|
||||
# By default, mark every candidate as selected.
|
||||
# Election results are usually completely validated during the AG,
|
||||
# so it makes more sense UX-wise to eventually unselect a candidate
|
||||
# than to select everyone.
|
||||
self.fields["candidates"].initial = qs.values_list("id", flat=True)
|
||||
|
||||
def save(self):
|
||||
if self.errors:
|
||||
return
|
||||
candidates: list[Candidature] = list(self.cleaned_data["candidates"])
|
||||
with transaction.atomic():
|
||||
Membership.objects.filter(
|
||||
role__in=[c.role.club_role for c in candidates],
|
||||
end_date=None,
|
||||
start_date__lt=self.election.end_date,
|
||||
).update(end_date=localdate())
|
||||
memberships = [
|
||||
Membership(
|
||||
user_id=c.user_id,
|
||||
club_id=c.role.club_role.club_id,
|
||||
role=c.role.club_role,
|
||||
)
|
||||
for c in candidates
|
||||
]
|
||||
Membership.objects.bulk_create(memberships)
|
||||
Membership._add_club_groups(memberships)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Generated by Django 5.2.14 on 2026-05-30 20:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("club", "0017_linktype_clublink"),
|
||||
("election", "0005_alter_candidature_program_alter_candidature_user"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="election",
|
||||
name="clubs",
|
||||
field=models.ManyToManyField(
|
||||
help_text="The club(s) this election is held for.",
|
||||
related_name="elections",
|
||||
to="club.club",
|
||||
verbose_name="clubs",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="role",
|
||||
name="club_role",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text=(
|
||||
"A club role. Filling this will allow automatic "
|
||||
"completion of title and description, "
|
||||
"and automatic assignation after the elections."
|
||||
),
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="election_roles",
|
||||
to="club.clubrole",
|
||||
verbose_name="club role",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="role",
|
||||
name="description",
|
||||
field=models.TextField(blank=True, default="", verbose_name="description"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="role",
|
||||
name="max_choice",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
default=1, verbose_name="max choice"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="role",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("title", "election"),
|
||||
name="title_election_unique_constraint",
|
||||
violation_error_code="invalid",
|
||||
violation_error_message="This role already exists for this election",
|
||||
),
|
||||
),
|
||||
]
|
||||
+46
-5
@@ -5,6 +5,7 @@ from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from ordered_model.models import OrderedModel
|
||||
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.models import Group, User
|
||||
|
||||
|
||||
@@ -13,6 +14,12 @@ class Election(models.Model):
|
||||
|
||||
title = models.CharField(_("title"), max_length=255)
|
||||
description = models.TextField(_("description"), null=True, blank=True)
|
||||
clubs = models.ManyToManyField(
|
||||
Club,
|
||||
related_name="elections",
|
||||
verbose_name=_("clubs"),
|
||||
help_text=_("The club(s) this election is held for."),
|
||||
)
|
||||
start_candidature = models.DateTimeField(_("start candidature"), blank=False)
|
||||
end_candidature = models.DateTimeField(_("end candidature"), blank=False)
|
||||
start_date = models.DateTimeField(_("start date"), blank=False)
|
||||
@@ -94,9 +101,18 @@ class Election(models.Model):
|
||||
results[role.title] = role.results(total_vote)
|
||||
return results
|
||||
|
||||
@cached_property
|
||||
def results_applied(self) -> bool:
|
||||
"""Returns True if one or more roles of this election have been applied."""
|
||||
return Membership.objects.filter(
|
||||
role__election_roles__election=self,
|
||||
end_date=None,
|
||||
start_date__gte=self.end_date,
|
||||
).exists()
|
||||
|
||||
|
||||
class Role(OrderedModel):
|
||||
"""This class allows to create a new role avaliable for a candidature."""
|
||||
"""This class allows to create a new role available for a candidature."""
|
||||
|
||||
election = models.ForeignKey(
|
||||
Election,
|
||||
@@ -105,17 +121,42 @@ class Role(OrderedModel):
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
title = models.CharField(_("title"), max_length=255)
|
||||
description = models.TextField(_("description"), null=True, blank=True)
|
||||
max_choice = models.IntegerField(_("max choice"), default=1)
|
||||
description = models.TextField(_("description"), default="", blank=True)
|
||||
max_choice = models.PositiveSmallIntegerField(_("max choice"), default=1)
|
||||
club_role = models.ForeignKey(
|
||||
ClubRole,
|
||||
related_name="election_roles",
|
||||
verbose_name=_("club role"),
|
||||
help_text=_(
|
||||
"A club role. Filling this will allow automatic "
|
||||
"completion of title and description, "
|
||||
"and automatic assignation after the elections."
|
||||
),
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
order_with_respect_to = "election"
|
||||
|
||||
class Meta(OrderedModel.Meta):
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["title", "election"],
|
||||
name="title_election_unique_constraint",
|
||||
violation_error_message=_("This role already exists for this election"),
|
||||
violation_error_code="invalid",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} - {self.election.title}"
|
||||
|
||||
def results(self, total_vote: int) -> dict[str, dict[str, int | float]]:
|
||||
if total_vote == 0:
|
||||
candidates = self.candidatures.values_list("user__username")
|
||||
candidates = self.candidatures.values_list("user__username", flat=True)
|
||||
return {
|
||||
key: {"vote": 0, "percent": 0} for key in ["blank_votes", *candidates]
|
||||
key: {"vote": 0, "percent": 0} for key in ["blank vote", *candidates]
|
||||
}
|
||||
total_vote *= self.max_choice
|
||||
results = {"total vote": total_vote}
|
||||
|
||||
@@ -30,12 +30,24 @@
|
||||
{%- else %}
|
||||
{% trans %}Polls will open {% endtrans %}
|
||||
<time datetime="{{ election.start_date }}">{{ election.start_date|localtime|date(DATETIME_FORMAT) }}</time>
|
||||
{% trans %} at {% endtrans %}<time>{{ election.start_date|localtime|time(DATETIME_FORMAT)}}</time>
|
||||
{% trans %}at{% endtrans %}
|
||||
<time>{{ election.start_date|localtime|time(DATETIME_FORMAT) }}</time>
|
||||
{% trans %}and will close {% endtrans %}
|
||||
{%- endif %}
|
||||
<time datetime="{{ election.end_date }}">{{ election.end_date|localtime|date(DATETIME_FORMAT) }}</time>
|
||||
{% trans %} at {% endtrans %}<time>{{ election.end_date|localtime|time(DATETIME_FORMAT)}}</time>
|
||||
{% trans %}at{% endtrans %}
|
||||
<time>{{ election.end_date|localtime|time(DATETIME_FORMAT) }}</time>
|
||||
</p>
|
||||
{%- if election.is_vote_finished and user.can_edit(election) %}
|
||||
<details class="accordion" name="apply-result">
|
||||
<summary>{% trans %}Apply election result{% endtrans %}</summary>
|
||||
<div
|
||||
class="accordion-content aria-busy-grow"
|
||||
hx-get="{{ url("election:apply_result", election_id=election.id) }}"
|
||||
hx-trigger="toggle from:closest details once"
|
||||
></div>
|
||||
</details>
|
||||
{% endif %}
|
||||
{%- if user_has_voted %}
|
||||
<p class="election__elector-infos">
|
||||
{%- if election.is_vote_active %}
|
||||
@@ -47,17 +59,27 @@
|
||||
{%- endif %}
|
||||
</section>
|
||||
<section class="election_vote">
|
||||
<form action="{{ url('election:vote', election.id) }}" method="post" class="election__vote-form" name="vote-form" id="vote-form">
|
||||
<form
|
||||
action="{{ url('election:vote', election.id) }}"
|
||||
method="post"
|
||||
class="election__vote-form"
|
||||
name="vote-form"
|
||||
id="vote-form"
|
||||
>
|
||||
{% csrf_token %}
|
||||
<table class="election_table">
|
||||
<thead class="lists">
|
||||
<tr>
|
||||
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">{% trans %}Blank vote{% endtrans %}</th>
|
||||
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">
|
||||
{% trans %}Blank vote{% endtrans %}
|
||||
</th>
|
||||
{%- for election_list in election_lists %}
|
||||
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">
|
||||
<span>{{ election_list.title }}</span>
|
||||
{% if user.can_edit(election_list) and election.is_vote_editable -%}
|
||||
<a href="{{ url('election:delete_list', list_id=election_list.id) }}"><i class="fa-regular fa-trash-can delete-action"></i></a>
|
||||
<a href="{{ url('election:delete_list', list_id=election_list.id) }}">
|
||||
<i class="fa-regular fa-trash-can delete-action"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</th>
|
||||
{%- endfor %}
|
||||
@@ -103,22 +125,45 @@
|
||||
<button disabled><i class="fa fa-arrow-down"></i></button>
|
||||
<button disabled><i class="fa fa-caret-down"></i></button>
|
||||
{%- else -%}
|
||||
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=bottom');"><i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=down');"><i class="fa fa-caret-down"></i></button>
|
||||
<button
|
||||
type="button"
|
||||
onclick="window.location.replace('?role={{ role.id }}&action=bottom');"
|
||||
>
|
||||
<i class="fa fa-arrow-down"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick="window.location.replace('?role={{ role.id }}&action=down');"
|
||||
>
|
||||
<i class="fa fa-caret-down"></i>
|
||||
</button>
|
||||
{%- endif -%}
|
||||
{%- if loop.first -%}
|
||||
<button disabled><i class="fa fa-caret-up"></i></button>
|
||||
<button disabled><i class="fa fa-arrow-up"></i></button>
|
||||
{%- else -%}
|
||||
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=up');"><i class="fa fa-caret-up"></i></button>
|
||||
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=top');"><i class="fa fa-arrow-up"></i></button>
|
||||
<button
|
||||
type="button"
|
||||
onclick="window.location.replace('?role={{ role.id }}&action=up');"
|
||||
>
|
||||
<i
|
||||
class="fa fa-caret-up"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick="window.location.replace('?role={{ role.id }}&action=top');"
|
||||
><i class="fa fa-arrow-up"></i>
|
||||
</button>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
{%- endif -%}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="role_candidates">
|
||||
<td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%">
|
||||
<td
|
||||
class="list_per_role"
|
||||
style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%"
|
||||
>
|
||||
{%- if role.max_choice == 1 and show_vote_buttons %}
|
||||
<div class="radio-btn">
|
||||
{% set input_id = "blank_vote_" + role.id|string %}
|
||||
@@ -131,26 +176,46 @@
|
||||
{%- if election.is_vote_finished %}
|
||||
{%- set results = election_results[role.title]['blank vote'] %}
|
||||
<div class="election__results">
|
||||
<strong>{{ results.vote }} {% trans %}votes{% endtrans %} ({{ "%.2f" % results.percent }} %)</strong>
|
||||
<strong>
|
||||
{{ results.vote }} {% trans %}votes{% endtrans %} ({{ "%.2f" % results.percent }} %)
|
||||
</strong>
|
||||
</div>
|
||||
{%- endif %}
|
||||
</td>
|
||||
{%- for election_list in election_lists %}
|
||||
<td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%">
|
||||
<td
|
||||
class="list_per_role"
|
||||
style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%"
|
||||
>
|
||||
<ul class="candidates">
|
||||
{%- for candidature in election_list.candidatures.select_related("user", "user__profile_pict").filter(role=role) %}
|
||||
<li class="candidate">
|
||||
{%- if show_vote_buttons %}
|
||||
{% set input_id = "candidature_" + candidature.id|string %}
|
||||
<input id="{{ input_id }}" type="{{ 'checkbox' if role.max_choice > 1 else 'radio' }}" {{ 'checked' if candidature.id|string in role_data else '' }} {{ 'disabled' if user_has_voted else '' }} name="{{ role.title }}" value="{{ candidature.id }}">
|
||||
<input
|
||||
id="{{ input_id }}"
|
||||
type="{{ 'checkbox' if role.max_choice > 1 else 'radio' }}"
|
||||
{% if candidature.id|string in role_data %}checked{% endif %}
|
||||
{% if user_has_voted %}disabled{% endif %}
|
||||
name="{{ role.title }}"
|
||||
value="{{ candidature.id }}"
|
||||
>
|
||||
<label for="{{ input_id }}">
|
||||
{%- endif %}
|
||||
<figure>
|
||||
{%- if user.can_view(candidature.user) %}
|
||||
{% if candidature.user.profile_pict %}
|
||||
<img class="candidate__picture" src="{{ candidature.user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}">
|
||||
<img
|
||||
class="candidate__picture"
|
||||
src="{{ candidature.user.profile_pict.get_download_url() }}"
|
||||
alt="{% trans %}Profile{% endtrans %}"
|
||||
>
|
||||
{% else %}
|
||||
<img class="candidate__picture" src="{{ static('core/img/unknown.jpg') }}" alt="{% trans %}Profile{% endtrans %}">
|
||||
<img
|
||||
class="candidate__picture"
|
||||
src="{{ static('core/img/unknown.jpg') }}"
|
||||
alt="{% trans %}Profile{% endtrans %}"
|
||||
>
|
||||
{% endif %}
|
||||
{%- endif %}
|
||||
<figcaption class="candidate__details">
|
||||
@@ -164,8 +229,12 @@
|
||||
{%- if user.can_edit(candidature) -%}
|
||||
{%- if election.is_vote_editable -%}
|
||||
<div class="edit_btns">
|
||||
<a href="{{url('election:update_candidate', candidature_id=candidature.id)}}"><i class="fa-regular fa-pen-to-square edit-action"></i>️</a>
|
||||
<a href="{{url('election:delete_candidate', candidature_id=candidature.id)}}"><i class="fa-regular fa-trash-can delete-action"></i></a>
|
||||
<a href="{{ url('election:update_candidate', candidature_id=candidature.id) }}">
|
||||
<i class="fa-regular fa-pen-to-square edit-action"></i>
|
||||
</a>
|
||||
<a href="{{ url('election:delete_candidate', candidature_id=candidature.id) }}">
|
||||
<i class="fa-regular fa-trash-can delete-action"></i>
|
||||
</a>
|
||||
</div>
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
{% block head %}
|
||||
{{ super() -}}
|
||||
<style type="text/css">
|
||||
<style>
|
||||
small {
|
||||
font-size: smaller;
|
||||
}
|
||||
@@ -20,6 +20,9 @@
|
||||
|
||||
{% block content %}
|
||||
<h3>{% trans %}Current elections{% endtrans %}</h3>
|
||||
<a class="btn btn-blue" href="{{ url("election:create") }}">
|
||||
<i class="fa fa-plus"></i>{% trans %}New election{% endtrans %}
|
||||
</a>
|
||||
{%- for election in object_list %}
|
||||
<hr>
|
||||
<section>
|
||||
@@ -32,7 +35,7 @@
|
||||
{% trans %} at {% endtrans %}<time>{{ election.start_candidature|localtime|time(DATETIME_FORMAT) }}</time>
|
||||
{% trans %}to{% endtrans %}
|
||||
<time datetime="{{ election.end_candidature }}">{{ election.end_candidature|localtime|date(DATETIME_FORMAT) }}</time>
|
||||
{% trans %} at {% endtrans %}<time>{{ election.end_candidature|time(DATETIME_FORMAT) }}</time>
|
||||
{% trans %} at {% endtrans %}<time>{{ election.end_candidature|localtime|time(DATETIME_FORMAT) }}</time>
|
||||
</p>
|
||||
<p>
|
||||
{% trans %}Polls open from{% endtrans %}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<div id="apply-election-result-fragment">
|
||||
{% if not form.candidates.field.choices %}
|
||||
<em>{% trans %}No result to apply{% endtrans %}</em>
|
||||
<p>
|
||||
{% trans trimmed %}
|
||||
This may be because no role of this election
|
||||
was linked to a club role.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
{% elif form.election.results_applied %}
|
||||
<em>
|
||||
{%- trans trimmed -%}
|
||||
The results of this election have been applied
|
||||
{%- endtrans -%}
|
||||
</em>
|
||||
<p>
|
||||
{% for club in clubs %}
|
||||
<a href="{{ url("club:club_members", club_id=club.id) }}" class="btn btn-blue">
|
||||
<i class="fa fa-arrow-up-right-from-square"></i>
|
||||
{% trans club=club.name %}{{ club }} members{% endtrans %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% else %}
|
||||
<div class="alert alert-yellow">
|
||||
<div class="alert-main">
|
||||
<strong class="alert-title">{% trans %}Warning{% endtrans %}</strong>
|
||||
<p>
|
||||
{%- trans trimmed -%}
|
||||
Only election roles linked to a club role will be automatically applied.
|
||||
{%- endtrans -%}
|
||||
</p>
|
||||
<p>
|
||||
{%- trans trimmed -%}
|
||||
Don't forget to manually apply the eventual remaining roles afterward.
|
||||
{%- endtrans -%}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
hx-post="{{ url("election:apply_result", election_id=form.election.id) }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#apply-election-result-fragment"
|
||||
hx-disabled-elt="find input[type='submit']"
|
||||
>
|
||||
{% csrf_token %}
|
||||
{{ form }}
|
||||
<input type="submit" class="btn btn-blue">
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans name=object_name %}Election role{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if object %}
|
||||
<h1>{% trans election=election %}Create role for election "{{ election }}"{% endtrans %}</h1>
|
||||
{% else %}
|
||||
<h1>{% trans election=election %}Edit role for election "{{ election }}"{% endtrans %}</h1>
|
||||
{% endif %}
|
||||
<form action="" method="post" x-data="{role: null, title: '', description: ''}">
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
{{ form.club_role.label_tag() }}
|
||||
{{ form.club_role.errors }}
|
||||
{{ form.club_role|add_attr("x-model.fill=role,autofocus=true") }}
|
||||
<button
|
||||
class="btn btn-blue"
|
||||
@click.prevent="title = roles[role]?.title ?? '';
|
||||
description = roles[role]?.description ?? '';"
|
||||
>
|
||||
{% trans %}autofill form{% endtrans %}
|
||||
</button>
|
||||
<span class="helptext">{{ form.club_role.help_text }}</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.title.label_tag() }}
|
||||
{{ form.title.errors }}
|
||||
{{ form.title|add_attr("x-model.fill=title") }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.description.label_tag() }}
|
||||
{{ form.description.errors }}
|
||||
{{ form.description|add_attr("x-model.fill=description") }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.max_choice.as_field_group() }}
|
||||
</div>
|
||||
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
const roles = {
|
||||
{%- for role in form.club_role.field.queryset -%}
|
||||
{{ role.id }}: { title: {{ role.name|tojson }}, description: {{ role.description|tojson }} },
|
||||
{%- endfor -%}
|
||||
};
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,191 @@
|
||||
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
|
||||
@@ -2,13 +2,15 @@ from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from django.utils.timezone import localtime, now
|
||||
from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.models import Club
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import Group, User
|
||||
from election.models import Candidature, Election, ElectionList, Role, Vote
|
||||
@@ -38,7 +40,6 @@ class TestElectionDetail(TestElection):
|
||||
reverse("election:detail", args=str(self.election.id))
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "La roue tourne" in str(response.content)
|
||||
|
||||
|
||||
class TestElectionUpdateView(TestElection):
|
||||
@@ -213,3 +214,42 @@ def test_election_results():
|
||||
"total vote": 100,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_election(client: Client):
|
||||
user_group = baker.make(Group)
|
||||
user = baker.make(
|
||||
User,
|
||||
user_permissions=[Permission.objects.get(codename="add_election")],
|
||||
groups=[user_group],
|
||||
)
|
||||
club = baker.make(Club)
|
||||
client.force_login(user)
|
||||
url = reverse("election:create")
|
||||
|
||||
res = client.get(url)
|
||||
assert res.status_code == 200
|
||||
|
||||
start = localtime().replace(hour=0, minute=1, second=0) + timedelta(days=1)
|
||||
res = client.post(
|
||||
url,
|
||||
data={
|
||||
"title": "foo",
|
||||
"clubs": [club.id],
|
||||
"view_groups": [user_group.id],
|
||||
"start_candidature": start,
|
||||
"end_candidature": start + timedelta(days=7, minutes=-2),
|
||||
"start_date": start + timedelta(days=7),
|
||||
"end_date": start + timedelta(days=14, minutes=-2),
|
||||
},
|
||||
)
|
||||
election = Election.objects.last()
|
||||
assertRedirects(
|
||||
res, reverse("election:detail", kwargs={"election_id": election.id})
|
||||
)
|
||||
assert election.title == "foo"
|
||||
assert list(election.clubs.all()) == [club]
|
||||
assert list(election.election_lists.values_list("title", flat=True)) == [
|
||||
"Candidat⸱e libre"
|
||||
]
|
||||
@@ -0,0 +1,110 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.models import Club, ClubRole
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import Group, User
|
||||
from election.models import Election, Role
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCreateRole(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.club = baker.make(Club)
|
||||
cls.edit_group = baker.make(Group)
|
||||
cls.election = baker.make(
|
||||
Election,
|
||||
clubs=[cls.club],
|
||||
edit_groups=[cls.edit_group],
|
||||
view_groups=[Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)],
|
||||
end_candidature=now() + timedelta(days=1),
|
||||
)
|
||||
cls.url = reverse(
|
||||
"election:create_role", kwargs={"election_id": cls.election.id}
|
||||
)
|
||||
cls.election_url = reverse(
|
||||
"election:detail", kwargs={"election_id": cls.election.id}
|
||||
)
|
||||
cls.permission = Permission.objects.get(codename="add_role")
|
||||
|
||||
def assert_role_creation_ok(self):
|
||||
response = self.client.get(self.url)
|
||||
assert response.status_code == 200
|
||||
response = self.client.post(self.url, data={"title": "foo", "max_choice": 1})
|
||||
assertRedirects(response, self.election_url)
|
||||
roles = list(self.election.roles.all())
|
||||
assert len(roles) == 1
|
||||
assert roles[0].title == "foo"
|
||||
|
||||
def assert_role_creation_denied(self):
|
||||
initial_role_count = self.election.roles.count()
|
||||
response = self.client.get(self.url)
|
||||
assert response.status_code == 403
|
||||
response = self.client.post(self.url, data={"title": "foo", "max_choice": 1})
|
||||
assert response.status_code == 403
|
||||
assert self.election.roles.count() == initial_role_count
|
||||
|
||||
def test_admin(self):
|
||||
user = baker.make(User, user_permissions=[self.permission])
|
||||
self.client.force_login(user)
|
||||
self.assert_role_creation_ok()
|
||||
|
||||
def test_edit_group(self):
|
||||
user = baker.make(User, groups=[self.edit_group])
|
||||
self.client.force_login(user)
|
||||
self.assert_role_creation_ok()
|
||||
|
||||
def test_role_linked_to_club_role(self):
|
||||
user = baker.make(User, user_permissions=[self.permission])
|
||||
self.client.force_login(user)
|
||||
club_role = baker.make(ClubRole, is_board=True, club=self.club)
|
||||
response = self.client.post(
|
||||
self.url, data={"title": "foo", "max_choice": 1, "club_role": club_role.id}
|
||||
)
|
||||
assertRedirects(response, self.election_url)
|
||||
roles = list(self.election.roles.all())
|
||||
assert len(roles) == 1
|
||||
assert roles[0].title == "foo"
|
||||
assert roles[0].club_role == club_role
|
||||
|
||||
def test_permission_denied(self):
|
||||
user = subscriber_user.make()
|
||||
self.client.force_login(user)
|
||||
self.assert_role_creation_denied()
|
||||
|
||||
def test_election_not_editable(self):
|
||||
user = baker.make(User, user_permissions=[self.permission])
|
||||
self.election.end_candidature = now() - timedelta(minutes=1)
|
||||
self.election.save()
|
||||
self.client.force_login(user)
|
||||
self.assert_role_creation_denied()
|
||||
|
||||
|
||||
class TestUpdateRole(TestCreateRole):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
# TestUpdateRole is just TestCreateRole, but with different parameters
|
||||
cls.club = baker.make(Club)
|
||||
cls.edit_group = baker.make(Group)
|
||||
cls.election = baker.make(
|
||||
Election,
|
||||
clubs=[cls.club],
|
||||
edit_groups=[cls.edit_group],
|
||||
view_groups=[Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)],
|
||||
end_candidature=now() + timedelta(days=1),
|
||||
)
|
||||
cls.role = baker.make(Role, election=cls.election)
|
||||
cls.url = reverse("election:update_role", kwargs={"role_id": cls.role.id})
|
||||
cls.election_url = reverse(
|
||||
"election:detail", kwargs={"election_id": cls.election.id}
|
||||
)
|
||||
cls.permission = Permission.objects.get(codename="change_role")
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from election.views import (
|
||||
ApplyResultFragment,
|
||||
CandidatureCreateView,
|
||||
CandidatureDeleteView,
|
||||
CandidatureUpdateView,
|
||||
@@ -56,4 +57,9 @@ urlpatterns = [
|
||||
),
|
||||
path("<int:election_id>/vote/", VoteFormView.as_view(), name="vote"),
|
||||
path("<int:election_id>/detail/", ElectionDetailView.as_view(), name="detail"),
|
||||
path(
|
||||
"fragment/<int:election_id>/apply/",
|
||||
ApplyResultFragment.as_view(),
|
||||
name="apply_result",
|
||||
),
|
||||
]
|
||||
|
||||
+65
-65
@@ -18,7 +18,9 @@ from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateVi
|
||||
|
||||
from core.auth.mixins import CanEditMixin, CanViewMixin
|
||||
from election.forms import (
|
||||
ApplyElectionResultForm,
|
||||
CandidateForm,
|
||||
ElectionCreateForm,
|
||||
ElectionForm,
|
||||
ElectionListForm,
|
||||
RoleForm,
|
||||
@@ -208,7 +210,7 @@ class CandidatureCreateView(LoginRequiredMixin, CreateView):
|
||||
|
||||
class ElectionCreateView(PermissionRequiredMixin, CreateView):
|
||||
model = Election
|
||||
form_class = ElectionForm
|
||||
form_class = ElectionCreateForm
|
||||
template_name = "core/create.jinja"
|
||||
permission_required = "election.add_election"
|
||||
|
||||
@@ -219,7 +221,7 @@ class ElectionCreateView(PermissionRequiredMixin, CreateView):
|
||||
class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
|
||||
model = Role
|
||||
form_class = RoleForm
|
||||
template_name = "core/create.jinja"
|
||||
template_name = "election/role_form.jinja"
|
||||
|
||||
@cached_property
|
||||
def election(self):
|
||||
@@ -228,22 +230,17 @@ class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
|
||||
def test_func(self):
|
||||
if not self.election.is_vote_editable:
|
||||
return False
|
||||
if self.request.user.has_perm("election.add_role"):
|
||||
return True
|
||||
return self.election.edit_groups.filter(
|
||||
id__in=self.request.user.all_groups
|
||||
).exists()
|
||||
|
||||
def get_initial(self):
|
||||
return {"election": self.election}
|
||||
user = self.request.user
|
||||
return user.has_perm("election.add_role") or user.can_edit(self.election)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {"election_id": self.election.id}
|
||||
return super().get_form_kwargs() | {"election": self.election}
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return reverse(
|
||||
"election:detail", kwargs={"election_id": self.object.election_id}
|
||||
)
|
||||
return reverse("election:detail", kwargs={"election_id": self.election.id})
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(**kwargs) | {"election": self.election}
|
||||
|
||||
|
||||
class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
|
||||
@@ -267,16 +264,11 @@ class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView
|
||||
)
|
||||
return not groups.isdisjoint(self.request.user.all_groups.keys())
|
||||
|
||||
def get_initial(self):
|
||||
return {"election": self.election}
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {"election_id": self.election.id}
|
||||
return super().get_form_kwargs() | {"election": self.election}
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return reverse(
|
||||
"election:detail", kwargs={"election_id": self.object.election_id}
|
||||
)
|
||||
return reverse("election:detail", kwargs={"election_id": self.election.id})
|
||||
|
||||
|
||||
# Update view
|
||||
@@ -288,18 +280,6 @@ class ElectionUpdateView(CanEditMixin, UpdateView):
|
||||
template_name = "core/edit.jinja"
|
||||
pk_url_kwarg = "election_id"
|
||||
|
||||
def get_initial(self):
|
||||
return {
|
||||
"start_date": self.object.start_date.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"end_date": self.object.end_date.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"start_candidature": self.object.start_candidature.strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
),
|
||||
"end_candidature": self.object.end_candidature.strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
),
|
||||
}
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return reverse_lazy("election:detail", kwargs={"election_id": self.object.id})
|
||||
|
||||
@@ -324,48 +304,30 @@ class CandidatureUpdateView(LoginRequiredMixin, CanEditMixin, UpdateView):
|
||||
)
|
||||
|
||||
|
||||
class RoleUpdateView(CanEditMixin, UpdateView):
|
||||
class RoleUpdateView(UserPassesTestMixin, UpdateView):
|
||||
model = Role
|
||||
form_class = RoleForm
|
||||
template_name = "core/edit.jinja"
|
||||
template_name = "election/role_form.jinja"
|
||||
pk_url_kwarg = "role_id"
|
||||
|
||||
def dispatch(self, request, *arg, **kwargs):
|
||||
self.object = self.get_object()
|
||||
if not self.object.election.is_vote_editable:
|
||||
raise PermissionDenied
|
||||
return super().dispatch(request, *arg, **kwargs)
|
||||
@cached_property
|
||||
def election(self):
|
||||
return self.get_object().election
|
||||
|
||||
def remove_fields(self):
|
||||
self.form.fields.pop("election", None)
|
||||
def test_func(self):
|
||||
if not self.election.is_vote_editable:
|
||||
return False
|
||||
user = self.request.user
|
||||
return user.has_perm("election.change_role") or user.can_edit(self.election)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
self.form = self.get_form()
|
||||
self.remove_fields()
|
||||
return self.render_to_response(self.get_context_data(form=self.form))
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
self.form = self.get_form()
|
||||
self.remove_fields()
|
||||
if (
|
||||
request.user.is_authenticated
|
||||
and request.user.can_edit(self.object)
|
||||
and self.form.is_valid()
|
||||
):
|
||||
return super().form_valid(self.form)
|
||||
return self.form_invalid(self.form)
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(**kwargs) | {"election": self.election}
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["election_id"] = self.object.election.id
|
||||
return kwargs
|
||||
return super().get_form_kwargs() | {"election": self.election}
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return reverse_lazy(
|
||||
"election:detail", kwargs={"election_id": self.object.election.id}
|
||||
)
|
||||
return reverse("election:detail", kwargs={"election_id": self.election.id})
|
||||
|
||||
|
||||
# Delete Views
|
||||
@@ -425,3 +387,41 @@ class ElectionListDeleteView(CanEditMixin, DeleteView):
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return reverse("election:detail", kwargs={"election_id": self.election.id})
|
||||
|
||||
|
||||
class ApplyResultFragment(LoginRequiredMixin, UserPassesTestMixin, FormView):
|
||||
template_name = "election/fragments/apply_result.jinja"
|
||||
form_class = ApplyElectionResultForm
|
||||
|
||||
@cached_property
|
||||
def election(self):
|
||||
return get_object_or_404(Election, pk=self.kwargs["election_id"])
|
||||
|
||||
def test_func(self):
|
||||
if not self.election.is_vote_finished:
|
||||
return False
|
||||
if self.request.user.has_perm("club.add_membership"):
|
||||
return True
|
||||
return self.election.edit_groups.filter(
|
||||
id__in=self.request.user.all_groups
|
||||
).exists()
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if self.election.results_applied:
|
||||
raise PermissionDenied
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {"election": self.election}
|
||||
|
||||
def form_valid(self, form: ApplyElectionResultForm):
|
||||
form.save()
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(**kwargs) | {"clubs": self.election.clubs.all()}
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return reverse(
|
||||
"election:apply_result", kwargs={"election_id": self.election.id}
|
||||
)
|
||||
|
||||
+164
-65
@@ -6,7 +6,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-05-12 11:12+0200\n"
|
||||
"POT-Creation-Date: 2026-06-05 13:39+0200\n"
|
||||
"PO-Revision-Date: 2016-07-18\n"
|
||||
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||
@@ -141,8 +141,7 @@ msgstr "vous devez spécifier au moins un utilisateur ou une adresse email"
|
||||
msgid "Begin date"
|
||||
msgstr "Date de début"
|
||||
|
||||
#: club/forms.py com/forms.py counter/forms.py election/forms.py
|
||||
#: subscription/forms.py
|
||||
#: club/forms.py com/forms.py counter/forms.py subscription/forms.py
|
||||
msgid "End date"
|
||||
msgstr "Date de fin"
|
||||
|
||||
@@ -261,7 +260,7 @@ msgstr ""
|
||||
"Si ce rôle est inactif, il ne pourra pas être attribué aux gens qui "
|
||||
"rejoignent le club."
|
||||
|
||||
#: club/models.py
|
||||
#: club/models.py election/models.py
|
||||
msgid "club role"
|
||||
msgstr "rôle de club"
|
||||
|
||||
@@ -363,11 +362,8 @@ msgid "Unregistered user"
|
||||
msgstr "Utilisateur non enregistré"
|
||||
|
||||
#: club/models.py
|
||||
#, python-format
|
||||
msgid "The base url that links with this type must respect (e.g. `%(url)s`)"
|
||||
msgstr ""
|
||||
"L'url de base que tous les liens de ce type doivent respecter (par exemple "
|
||||
"`%(url)s`)"
|
||||
msgid "The base url that links with this type must respect"
|
||||
msgstr "L'url de base que tous les liens de ce type doivent respecter"
|
||||
|
||||
#: club/models.py counter/models.py
|
||||
msgid "icon"
|
||||
@@ -595,6 +591,7 @@ msgstr ""
|
||||
#: counter/templates/counter/cash_register_summary.jinja
|
||||
#: counter/templates/counter/invoices_call.jinja
|
||||
#: counter/templates/counter/product_form.jinja
|
||||
#: election/templates/election/role_form.jinja
|
||||
#: forum/templates/forum/reply.jinja
|
||||
#: subscription/templates/subscription/fragments/creation_form_existing_user.jinja
|
||||
#: subscription/templates/subscription/fragments/creation_form_new_user.jinja
|
||||
@@ -683,6 +680,7 @@ msgstr "Étiquette"
|
||||
#: core/templates/core/user_account_detail.jinja
|
||||
#: core/templates/core/user_stats.jinja
|
||||
#: counter/templates/counter/last_ops.jinja
|
||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
msgid "Quantity"
|
||||
msgstr "Quantité"
|
||||
|
||||
@@ -966,7 +964,7 @@ msgstr "rôle de club – membre"
|
||||
msgid "Benefit"
|
||||
msgstr "Bénéfice"
|
||||
|
||||
#: club/views.py
|
||||
#: club/views.py eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
msgid "Unit price"
|
||||
msgstr "Prix unitaire"
|
||||
|
||||
@@ -978,7 +976,7 @@ msgstr "Prix d'achat"
|
||||
msgid "Format: 16:9 | Resolution: 1920x1080"
|
||||
msgstr "Format : 16:9 | Résolution : 1920x1080"
|
||||
|
||||
#: com/forms.py election/forms.py subscription/forms.py
|
||||
#: com/forms.py subscription/forms.py
|
||||
msgid "Start date"
|
||||
msgstr "Date de début"
|
||||
|
||||
@@ -2203,6 +2201,7 @@ msgstr "Êtes-vous sûr de vouloir supprimer \"%(name)s\" ?"
|
||||
#: core/templates/core/delete_confirm.jinja
|
||||
#: core/templates/core/file_delete_confirm.jinja
|
||||
#: counter/templates/counter/fragments/delete_student_card.jinja
|
||||
#: counter/templates/counter/fragments/login.jinja
|
||||
msgid "Confirm"
|
||||
msgstr "Confirmation"
|
||||
|
||||
@@ -3206,6 +3205,18 @@ msgstr "Cet UID est invalide"
|
||||
msgid "User not found"
|
||||
msgstr "Utilisateur non trouvé"
|
||||
|
||||
#: counter/forms.py
|
||||
msgid "You are not a barman of this counter."
|
||||
msgstr "Vous n'êtes pas barman sur ce comptoir."
|
||||
|
||||
#: counter/forms.py
|
||||
msgid "You are already logged in this counter."
|
||||
msgstr "Vous êtes déjà connecté à ce comptoir."
|
||||
|
||||
#: counter/forms.py
|
||||
msgid "You are already logged in another counter."
|
||||
msgstr "Vous êtes déjà connecté à un autre comptoir."
|
||||
|
||||
#: counter/forms.py
|
||||
msgid "Regular barmen"
|
||||
msgstr "Barmen réguliers"
|
||||
@@ -3408,8 +3419,16 @@ msgid "Buy five, get the sixth free"
|
||||
msgstr "Pour cinq achetés, le sixième offert"
|
||||
|
||||
#: counter/models.py
|
||||
msgid "buying groups"
|
||||
msgstr "groupe d'achat"
|
||||
msgid "clic limit"
|
||||
msgstr "limite de clic"
|
||||
|
||||
#: counter/models.py
|
||||
msgid ""
|
||||
"If a limit is set, the product won't be purchasable anymore on the eboutic "
|
||||
"once the latter is reached."
|
||||
msgstr ""
|
||||
"Si une limite est donnée, le produit ne sera plus achetable sur l'eboutic "
|
||||
"une fois celle-ci atteinte."
|
||||
|
||||
#: counter/models.py election/models.py
|
||||
msgid "archived"
|
||||
@@ -3476,10 +3495,6 @@ msgstr "Bureau"
|
||||
msgid "sellers"
|
||||
msgstr "vendeurs"
|
||||
|
||||
#: counter/models.py
|
||||
msgid "token"
|
||||
msgstr "jeton"
|
||||
|
||||
#: counter/models.py
|
||||
msgid "regular barman"
|
||||
msgstr "barman régulier"
|
||||
@@ -3765,15 +3780,6 @@ msgstr "Confirmer (FIN)"
|
||||
msgid "Cancel (ANN)"
|
||||
msgstr "Annuler (ANN)"
|
||||
|
||||
#: counter/templates/counter/counter_click.jinja
|
||||
#: counter/templates/counter/fragments/create_refill.jinja
|
||||
#: counter/templates/counter/fragments/create_student_card.jinja
|
||||
#: counter/templates/counter/invoices_call.jinja
|
||||
#: sas/templates/sas/picture.jinja
|
||||
#: subscription/templates/subscription/stats.jinja
|
||||
msgid "Go"
|
||||
msgstr "Valider"
|
||||
|
||||
#: counter/templates/counter/counter_click.jinja
|
||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
msgid "Basket: "
|
||||
@@ -3804,7 +3810,7 @@ msgstr ""
|
||||
|
||||
#: counter/templates/counter/counter_click.jinja
|
||||
msgid "No products available on this counter for this user"
|
||||
msgstr "Pas de produits disponnibles dans ce comptoir pour cet utilisateur"
|
||||
msgstr "Pas de produits disponibles dans ce comptoir pour cet utilisateur"
|
||||
|
||||
#: counter/templates/counter/counter_list.jinja
|
||||
msgid "Counter admin list"
|
||||
@@ -3865,12 +3871,20 @@ msgid "Please, login"
|
||||
msgstr "Merci de vous identifier"
|
||||
|
||||
#: counter/templates/counter/counter_main.jinja
|
||||
msgid "Barman: "
|
||||
msgstr "Barman : "
|
||||
msgid "Barmen:"
|
||||
msgstr "Barmen :"
|
||||
|
||||
#: counter/templates/counter/counter_main.jinja
|
||||
msgid "login"
|
||||
msgstr "login"
|
||||
msgid "On this device"
|
||||
msgstr "Sur cet appareil"
|
||||
|
||||
#: counter/templates/counter/counter_main.jinja
|
||||
msgid "Elsewhere"
|
||||
msgstr "Ailleurs"
|
||||
|
||||
#: counter/templates/counter/counter_main.jinja
|
||||
msgid "No barman logged elsewhere"
|
||||
msgstr "Pas de barman connecté ailleurs"
|
||||
|
||||
#: counter/templates/counter/eticket_list.jinja
|
||||
msgid "Eticket list"
|
||||
@@ -3922,6 +3936,14 @@ msgstr ""
|
||||
msgid "New formula"
|
||||
msgstr "Nouvelle formule"
|
||||
|
||||
#: counter/templates/counter/fragments/create_refill.jinja
|
||||
#: counter/templates/counter/fragments/create_student_card.jinja
|
||||
#: counter/templates/counter/invoices_call.jinja
|
||||
#: sas/templates/sas/picture.jinja
|
||||
#: subscription/templates/subscription/stats.jinja
|
||||
msgid "Go"
|
||||
msgstr "Valider"
|
||||
|
||||
#: counter/templates/counter/fragments/create_student_card.jinja
|
||||
msgid "No student card registered."
|
||||
msgstr "Aucune carte étudiante enregistrée."
|
||||
@@ -4275,22 +4297,14 @@ msgstr "Montant du chèque"
|
||||
msgid "Check quantity"
|
||||
msgstr "Nombre de chèque"
|
||||
|
||||
#: counter/views/click.py
|
||||
msgid "You cannot click users on this counter"
|
||||
msgstr "Vous ne pouvez pas cliquer des gens sur ce comptoir"
|
||||
|
||||
#: counter/views/eticket.py
|
||||
msgid "people(s)"
|
||||
msgstr "personne(s)"
|
||||
|
||||
#: counter/views/home.py
|
||||
msgid "Bad credentials"
|
||||
msgstr "Mauvais identifiants"
|
||||
|
||||
#: counter/views/home.py
|
||||
msgid "User is not barman"
|
||||
msgstr "L'utilisateur n'est pas barman."
|
||||
|
||||
#: counter/views/home.py
|
||||
msgid "Bad location, someone is already logged in somewhere else"
|
||||
msgstr "Mauvais comptoir, quelqu'un est déjà connecté ailleurs"
|
||||
|
||||
#: counter/views/invoice.py
|
||||
msgid "Invoice calls status has been updated."
|
||||
msgstr "Le statut des appels à facture a été mis à jour."
|
||||
@@ -4367,6 +4381,18 @@ msgstr "Solde actuel : "
|
||||
msgid "Remaining account amount: "
|
||||
msgstr "Solde restant : "
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
msgid "I have read and I accept"
|
||||
msgstr "J'ai lu et j'accepte"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
msgid "the general terms and conditions"
|
||||
msgstr "les conditions générales de vente"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
msgid "of the student association of the UTBM"
|
||||
msgstr "de l'Association des étudiants de l'UTBM"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
msgid "Pay with credit card"
|
||||
msgstr "Payer avec une carte bancaire"
|
||||
@@ -4462,6 +4488,10 @@ msgstr ""
|
||||
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
|
||||
"du vendredi au dimanche."
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
msgid "Product sold out"
|
||||
msgstr "Produit épuisé"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
msgid "There are no items available for sale"
|
||||
msgstr "Aucun article n'est disponible à la vente"
|
||||
@@ -4494,16 +4524,13 @@ msgstr ""
|
||||
"par carte bancaire"
|
||||
|
||||
#: eboutic/views.py
|
||||
msgid ""
|
||||
"The Crédit Agricole changed its policy related to the billing information "
|
||||
"that must be provided in order to pay with a credit card. If you want to pay "
|
||||
"with your credit card, you must add a phone number to the data you already "
|
||||
"provided."
|
||||
msgstr ""
|
||||
"Le Crédit Agricole a changé sa politique relative aux informations à "
|
||||
"fournir pour effectuer un paiement par carte bancaire. De ce fait, si vous "
|
||||
"souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux "
|
||||
"données que vous aviez déjà fourni."
|
||||
msgid "Basket expired"
|
||||
msgstr "Panier expiré"
|
||||
|
||||
#: eboutic/views.py
|
||||
#, python-format
|
||||
msgid "Basket available until %(until)s"
|
||||
msgstr "Panier disponible jusqu'à %(until)s"
|
||||
|
||||
#: eboutic/views.py
|
||||
msgid "You can't buy a refilling with sith money"
|
||||
@@ -4521,17 +4548,13 @@ msgstr "Utilisateur se présentant"
|
||||
msgid "Blank vote"
|
||||
msgstr "Vote blanc"
|
||||
|
||||
#: election/forms.py
|
||||
msgid "This role already exists for this election"
|
||||
msgstr "Ce rôle existe déjà pour cette élection"
|
||||
#: election/models.py
|
||||
msgid "clubs"
|
||||
msgstr "clubs"
|
||||
|
||||
#: election/forms.py
|
||||
msgid "Start candidature"
|
||||
msgstr "Début des candidatures"
|
||||
|
||||
#: election/forms.py
|
||||
msgid "End candidature"
|
||||
msgstr "Fin des candidatures"
|
||||
#: election/models.py
|
||||
msgid "The club(s) this election is held for."
|
||||
msgstr "Le(s) club(s) pour lequel cette élection est tenue."
|
||||
|
||||
#: election/models.py
|
||||
msgid "start candidature"
|
||||
@@ -4569,6 +4592,18 @@ msgstr "élection"
|
||||
msgid "max choice"
|
||||
msgstr "nombre de choix maxi"
|
||||
|
||||
#: election/models.py
|
||||
msgid ""
|
||||
"A club role. Filling this will allow automatic completion of title and "
|
||||
"description, and automatic assignation after the elections."
|
||||
msgstr ""
|
||||
"Un rôle de club. Remplir ce champ permet l'autocomplétion du titre et de la "
|
||||
"description, et l'attribution automatique des rôles après les élections."
|
||||
|
||||
#: election/models.py
|
||||
msgid "This role already exists for this election"
|
||||
msgstr "Ce rôle existe déjà pour cette élection"
|
||||
|
||||
#: election/models.py
|
||||
msgid "election list"
|
||||
msgstr "liste électorale"
|
||||
@@ -4605,8 +4640,6 @@ msgid "Polls will open "
|
||||
msgstr "Les votes ouvriront "
|
||||
|
||||
#: election/templates/election/election_detail.jinja
|
||||
#: election/templates/election/election_list.jinja
|
||||
#: forum/templates/forum/macros.jinja
|
||||
msgid "at"
|
||||
msgstr "à"
|
||||
|
||||
@@ -4614,6 +4647,10 @@ msgstr " à "
|
||||
msgid "and will close "
|
||||
msgstr "et fermeront"
|
||||
|
||||
#: election/templates/election/election_detail.jinja
|
||||
msgid "Apply election result"
|
||||
msgstr "Appliquer les résultats de l'élection"
|
||||
|
||||
#: election/templates/election/election_detail.jinja
|
||||
msgid "You already have submitted your vote."
|
||||
msgstr "Vous avez déjà soumis votre vote."
|
||||
@@ -4655,10 +4692,19 @@ msgstr "Liste des élections"
|
||||
msgid "Current elections"
|
||||
msgstr "Élections actuelles"
|
||||
|
||||
#: election/templates/election/election_list.jinja
|
||||
msgid "New election"
|
||||
msgstr "Nouvelle élection"
|
||||
|
||||
#: election/templates/election/election_list.jinja
|
||||
msgid "Applications open from"
|
||||
msgstr "Candidatures ouvertes à partir du"
|
||||
|
||||
#: election/templates/election/election_list.jinja
|
||||
#: forum/templates/forum/macros.jinja
|
||||
msgid " at "
|
||||
msgstr " à "
|
||||
|
||||
#: election/templates/election/election_list.jinja
|
||||
msgid "to"
|
||||
msgstr "au"
|
||||
@@ -4667,6 +4713,59 @@ msgstr "au"
|
||||
msgid "Polls open from"
|
||||
msgstr "Votes ouverts du"
|
||||
|
||||
#: election/templates/election/fragments/apply_result.jinja
|
||||
msgid "No result to apply"
|
||||
msgstr "Pas de résultats à appliquer"
|
||||
|
||||
#: election/templates/election/fragments/apply_result.jinja
|
||||
msgid "This may be because no role of this election was linked to a club role."
|
||||
msgstr ""
|
||||
"Ceci s'explique peut-être parce qu'aucun poste de cette élection n'était lié "
|
||||
"à un rôle de club."
|
||||
|
||||
#: election/templates/election/fragments/apply_result.jinja
|
||||
msgid "The results of this election have been applied"
|
||||
msgstr "Les résultats de cette élection ont été appliqués"
|
||||
|
||||
#: election/templates/election/fragments/apply_result.jinja
|
||||
#, python-format
|
||||
msgid "%(club)s members"
|
||||
msgstr "Membres %(club)s"
|
||||
|
||||
#: election/templates/election/fragments/apply_result.jinja
|
||||
msgid "Warning"
|
||||
msgstr "Attention"
|
||||
|
||||
#: election/templates/election/fragments/apply_result.jinja
|
||||
msgid ""
|
||||
"Only election roles linked to a club role will be automatically applied."
|
||||
msgstr ""
|
||||
"Seuls les postes de cette élection qui sont liés à un rôle de club seront "
|
||||
"automatiquement appliqués."
|
||||
|
||||
#: election/templates/election/fragments/apply_result.jinja
|
||||
msgid "Don't forget to manually apply the eventual remaining roles afterward."
|
||||
msgstr ""
|
||||
"N'oubliez pas après d'attribuer manuellement les éventuels postes restants."
|
||||
|
||||
#: election/templates/election/role_form.jinja
|
||||
msgid "Election role"
|
||||
msgstr "Rôle d'élection"
|
||||
|
||||
#: election/templates/election/role_form.jinja
|
||||
#, python-format
|
||||
msgid "Create role for election \"%(election)s\""
|
||||
msgstr "Création d'un rôle pour l'élection « %(election)s »"
|
||||
|
||||
#: election/templates/election/role_form.jinja
|
||||
#, python-format
|
||||
msgid "Edit role for election \"%(election)s\""
|
||||
msgstr "Modification d'un rôle pour l'élection « %(election)s »"
|
||||
|
||||
#: election/templates/election/role_form.jinja
|
||||
msgid "autofill form"
|
||||
msgstr "compléter le formulaire"
|
||||
|
||||
#: election/views.py
|
||||
msgid "Form is invalid"
|
||||
msgstr "Formulaire invalide"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-17 22:42+0200\n"
|
||||
"POT-Creation-Date: 2026-05-17 10:03+0200\n"
|
||||
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
|
||||
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||
@@ -263,6 +263,10 @@ msgstr "Types de produits réordonnés !"
|
||||
msgid "Product type reorganisation failed with status code : %d"
|
||||
msgstr "La réorganisation des types de produit a échoué avec le code : %d"
|
||||
|
||||
#: eboutic/static/bundled/eboutic/checkout-index.ts
|
||||
msgid "Basket expired"
|
||||
msgstr "Panier expiré"
|
||||
|
||||
#: sas/static/bundled/sas/pictures-download-index.ts
|
||||
msgid "pictures.%(extension)s"
|
||||
msgstr "photos.%(extension)s"
|
||||
|
||||
Generated
+804
-840
File diff suppressed because it is too large
Load Diff
+9
-9
@@ -24,18 +24,18 @@
|
||||
"#com:*": "./com/static/bundled/*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/preset-env": "^7.29.5",
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@hey-api/openapi-ts": "^0.94.5",
|
||||
"@babel/core": "^7.29.7",
|
||||
"@babel/preset-env": "^7.29.7",
|
||||
"@biomejs/biome": "^2.4.16",
|
||||
"@hey-api/openapi-ts": "^0.98.1",
|
||||
"@types/alpinejs": "^3.13.11",
|
||||
"@types/alpinejs__sort": "^3.13.0",
|
||||
"@types/cytoscape-cxtmenu": "^3.4.5",
|
||||
"@types/cytoscape-klay": "^3.1.5",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.13"
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alpinejs/sort": "^3.15.12",
|
||||
@@ -46,13 +46,13 @@
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/icalendar": "^6.1.20",
|
||||
"@fullcalendar/list": "^6.1.20",
|
||||
"@sentry/browser": "^10.53.1",
|
||||
"@sentry/browser": "^10.56.0",
|
||||
"@zip.js/zip.js": "^2.8.26",
|
||||
"3d-force-graph": "^1.80.0",
|
||||
"alpinejs": "^3.15.12",
|
||||
"chart.js": "^4.5.1",
|
||||
"country-flag-emoji-polyfill": "^0.1.8",
|
||||
"cytoscape": "^3.33.4",
|
||||
"cytoscape": "^3.34.0",
|
||||
"cytoscape-cxtmenu": "^3.5.0",
|
||||
"cytoscape-klay": "^3.1.4",
|
||||
"d3-force-3d": "^3.0.6",
|
||||
@@ -60,7 +60,7 @@
|
||||
"glob": "^13.0.6",
|
||||
"html2canvas": "^1.4.1",
|
||||
"htmx.org": "^2.0.10",
|
||||
"js-cookie": "^3.0.7",
|
||||
"js-cookie": "^3.0.8",
|
||||
"lit-html": "^3.3.3",
|
||||
"native-file-system-adapter": "^3.0.1",
|
||||
"three": "^0.184.0",
|
||||
|
||||
+10
-10
@@ -19,7 +19,7 @@ authors = [
|
||||
license = { text = "GPL-3.0-only" }
|
||||
requires-python = "<4.0,>=3.12"
|
||||
dependencies = [
|
||||
"django>=5.2.14,<6.0.0",
|
||||
"django>=5.2.15,<6.0.0",
|
||||
"django-ninja>=1.6.2,<2.0.0",
|
||||
"django-ninja-extra>=0.31.4",
|
||||
"Pillow>=12.2.0,<13.0.0",
|
||||
@@ -27,15 +27,15 @@ dependencies = [
|
||||
"django-jinja<3.0.0,>=2.11.0",
|
||||
"cryptography>=48.0.0,<49.0.0",
|
||||
"django-phonenumber-field>=8.4.0,<9.0.0",
|
||||
"phonenumbers>=9.0.30,<10.0.0",
|
||||
"phonenumbers>=9.0.32,<10.0.0",
|
||||
"reportlab>=4.5.1,<5.0.0",
|
||||
"django-haystack>=3.3.0,<4.0.0",
|
||||
"django-haystack>=3.4.0,<4.0.0",
|
||||
"xapian-haystack>=4.0.0,<5.0.0",
|
||||
"libsass>=0.23.0,<1.0.0",
|
||||
"django-ordered-model>=3.7.4,<4.0.0",
|
||||
"django-simple-captcha>=0.6.3,<1.0.0",
|
||||
"python-dateutil>=2.9.0.post0,<3.0.0.0",
|
||||
"sentry-sdk>=2.60.0,<3.0.0",
|
||||
"sentry-sdk>=2.61.1,<3.0.0",
|
||||
"jinja2>=3.1.6,<4.0.0",
|
||||
"django-countries>=8.2.0,<9.0.0",
|
||||
"dict2xml>=1.7.8,<2.0.0",
|
||||
@@ -44,8 +44,8 @@ dependencies = [
|
||||
"django-honeypot>=1.3.0,<2",
|
||||
"pydantic-extra-types>=2.11.1,<3.0.0",
|
||||
"ical>=12.0.0,<14.0.0",
|
||||
"redis[hiredis]>=3.3.1,<8.0.0",
|
||||
"environs[django]>=15.0.1,<16",
|
||||
"redis[hiredis]>=3.4.0,<8.0.0",
|
||||
"environs[django]>=6.0.5,<16",
|
||||
"requests>=2.34.2,<3.0.0",
|
||||
"honcho>=2.0.0",
|
||||
"psutil>=7.2.2,<8.0.0",
|
||||
@@ -64,11 +64,11 @@ prod = [
|
||||
]
|
||||
dev = [
|
||||
"django-debug-toolbar>=6.3.0,<7",
|
||||
"ipython>=9.13.0,<10.0.0",
|
||||
"ipython>=9.14.1,<10.0.0",
|
||||
"pre-commit>=4.6.0,<5.0.0",
|
||||
"ruff>=0.15.13,<1.0.0",
|
||||
"ruff>=0.15.16,<1.0.0",
|
||||
"djhtml>=3.0.11,<4.0.0",
|
||||
"faker>=40.18.0,<41.0.0",
|
||||
"faker>=40.21.0,<41.0.0",
|
||||
"rjsmin>=1.2.5,<2.0.0",
|
||||
]
|
||||
tests = [
|
||||
@@ -84,7 +84,7 @@ docs = [
|
||||
"mkdocs>=1.6.1,<2.0.0",
|
||||
"mkdocs-material>=9.7.6,<10.0.0",
|
||||
"mkdocstrings>=1.0.4,<2.0.0",
|
||||
"mkdocstrings-python>=2.0.3,<3.0.0",
|
||||
"mkdocstrings-python>=2.0.4,<3.0.0",
|
||||
"mkdocs-include-markdown-plugin>=7.3.0,<8.0.0",
|
||||
]
|
||||
|
||||
|
||||
+15
-2
@@ -34,6 +34,7 @@ https://docs.djangoproject.com/en/1.8/ref/settings/
|
||||
"""
|
||||
|
||||
import binascii
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
@@ -41,6 +42,7 @@ from pathlib import Path
|
||||
|
||||
import sentry_sdk
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.utils.deprecation import RemovedInDjango60Warning
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from environs import Env
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
@@ -91,6 +93,7 @@ ALLOWED_HOSTS = ["*"]
|
||||
# RemovedInDjango60Warning: It's a transitional setting helpful in early
|
||||
# adoption of "https" as the new default value of forms.URLField.assume_scheme.
|
||||
# Remove this after upgrading to Django 6.x
|
||||
with contextlib.suppress(RemovedInDjango60Warning):
|
||||
FORMS_URLFIELD_ASSUME_HTTPS = True
|
||||
|
||||
# Application definition
|
||||
@@ -112,6 +115,7 @@ INSTALLED_APPS = (
|
||||
"django_jinja",
|
||||
"ninja_extra",
|
||||
"haystack",
|
||||
"django_countries",
|
||||
"django_celery_results",
|
||||
"django_celery_beat",
|
||||
"captcha",
|
||||
@@ -138,13 +142,13 @@ MIDDLEWARE = (
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"core.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.locale.LocaleMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"core.middleware.AuthenticationMiddleware",
|
||||
"core.middleware.SignalRequestMiddleware",
|
||||
"counter.middleware.BarmenMiddleware",
|
||||
)
|
||||
|
||||
ROOT_URLCONF = "sith.urls"
|
||||
@@ -291,7 +295,11 @@ USE_TZ = True
|
||||
|
||||
LOCALE_PATHS = [BASE_DIR / "locale"]
|
||||
|
||||
# for PhoneNumberField
|
||||
PHONENUMBER_DEFAULT_REGION = "FR"
|
||||
# for CountryField
|
||||
COUNTRIES_FIRST = ["FR", "CH", "DE"]
|
||||
COUNTRIES_FIRST_BREAK = "───────────"
|
||||
|
||||
# Medias
|
||||
MEDIA_URL = "/data/"
|
||||
@@ -571,6 +579,11 @@ SITH_BARMAN_TIMEOUT = 30
|
||||
# Minutes to delete the last operations
|
||||
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
|
||||
SITH_EBOUTIC_CB_ENABLED = env.bool("SITH_EBOUTIC_CB_ENABLED", default=True)
|
||||
SITH_EBOUTIC_ET_URL = env.str(
|
||||
|
||||
@@ -282,14 +282,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.4.0"
|
||||
version = "8.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -349,86 +349,86 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.14.0"
|
||||
version = "7.14.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" },
|
||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -513,11 +513,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -543,16 +543,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "5.2.14"
|
||||
version = "5.2.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -622,13 +622,13 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django-haystack"
|
||||
version = "3.3.0"
|
||||
version = "3.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "packaging" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
|
||||
[[package]]
|
||||
name = "django-honeypot"
|
||||
@@ -789,23 +789,23 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "faker"
|
||||
version = "40.18.0"
|
||||
version = "40.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.29.0"
|
||||
version = "3.29.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -843,62 +843,66 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "hiredis"
|
||||
version = "3.3.1"
|
||||
version = "3.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
|
||||
@@ -932,7 +936,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ical"
|
||||
version = "13.2.2"
|
||||
version = "13.2.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.13'",
|
||||
@@ -942,9 +946,9 @@ dependencies = [
|
||||
{ name = "python-dateutil", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "tzdata", marker = "python_full_version >= '3.13'" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -958,11 +962,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.15"
|
||||
version = "3.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -994,7 +998,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ipython"
|
||||
version = "9.13.0"
|
||||
version = "9.14.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -1004,14 +1008,14 @@ dependencies = [
|
||||
{ name = "matplotlib-inline" },
|
||||
{ name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
{ name = "prompt-toolkit" },
|
||||
{ name = "psutil" },
|
||||
{ name = "psutil", marker = "sys_platform != 'emscripten'" },
|
||||
{ name = "pygments" },
|
||||
{ name = "stack-data" },
|
||||
{ name = "traitlets" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1389,16 +1393,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mkdocstrings-python"
|
||||
version = "2.0.3"
|
||||
version = "2.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "griffelib" },
|
||||
{ name = "mkdocs-autorefs" },
|
||||
{ name = "mkdocstrings" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1472,11 +1476,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "phonenumbers"
|
||||
version = "9.0.30"
|
||||
version = "9.0.32"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1550,11 +1554,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.9.6"
|
||||
version = "4.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1863,15 +1867,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-discovery"
|
||||
version = "1.3.1"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "filelock" },
|
||||
{ name = "platformdirs" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2032,40 +2036,40 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.13"
|
||||
version = "0.15.16"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sentry-sdk"
|
||||
version = "2.60.0"
|
||||
version = "2.61.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2091,7 +2095,7 @@ dependencies = [
|
||||
{ name = "environs", extra = ["django"] },
|
||||
{ name = "honcho" },
|
||||
{ name = "ical", version = "12.0.0", 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 = "ical", version = "13.2.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "libsass" },
|
||||
{ name = "mistune" },
|
||||
@@ -2144,11 +2148,11 @@ requires-dist = [
|
||||
{ name = "celery", extras = ["redis"], specifier = ">=5.6.3,<8" },
|
||||
{ name = "cryptography", specifier = ">=48.0.0,<49.0.0" },
|
||||
{ name = "dict2xml", specifier = ">=1.7.8,<2.0.0" },
|
||||
{ name = "django", specifier = ">=5.2.14,<6.0.0" },
|
||||
{ name = "django", specifier = ">=5.2.15,<6.0.0" },
|
||||
{ name = "django-celery-beat", specifier = ">=2.9.0" },
|
||||
{ name = "django-celery-results", specifier = ">=2.6.0" },
|
||||
{ name = "django-countries", specifier = ">=8.2.0,<9.0.0" },
|
||||
{ name = "django-haystack", specifier = ">=3.3.0,<4.0.0" },
|
||||
{ name = "django-haystack", specifier = ">=3.4.0,<4.0.0" },
|
||||
{ name = "django-honeypot", specifier = ">=1.3.0,<2" },
|
||||
{ name = "django-jinja", specifier = ">=2.11.0,<3.0.0" },
|
||||
{ name = "django-ninja", specifier = ">=1.6.2,<2.0.0" },
|
||||
@@ -2156,21 +2160,21 @@ requires-dist = [
|
||||
{ name = "django-ordered-model", specifier = ">=3.7.4,<4.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 = "environs", extras = ["django"], specifier = ">=15.0.1,<16" },
|
||||
{ name = "environs", extras = ["django"], specifier = ">=6.0.5,<16" },
|
||||
{ name = "honcho", specifier = ">=2.0.0" },
|
||||
{ name = "ical", specifier = ">=12.0.0,<14.0.0" },
|
||||
{ name = "jinja2", specifier = ">=3.1.6,<4.0.0" },
|
||||
{ name = "libsass", specifier = ">=0.23.0,<1.0.0" },
|
||||
{ name = "mistune", specifier = ">=3.2.1,<4.0.0" },
|
||||
{ name = "phonenumbers", specifier = ">=9.0.30,<10.0.0" },
|
||||
{ name = "phonenumbers", specifier = ">=9.0.32,<10.0.0" },
|
||||
{ name = "pillow", specifier = ">=12.2.0,<13.0.0" },
|
||||
{ name = "psutil", specifier = ">=7.2.2,<8.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 = "redis", extras = ["hiredis"], specifier = ">=3.3.1,<8.0.0" },
|
||||
{ name = "redis", extras = ["hiredis"], specifier = ">=3.4.0,<8.0.0" },
|
||||
{ name = "reportlab", specifier = ">=4.5.1,<5.0.0" },
|
||||
{ name = "requests", specifier = ">=2.34.2,<3.0.0" },
|
||||
{ name = "sentry-sdk", specifier = ">=2.60.0,<3.0.0" },
|
||||
{ name = "sentry-sdk", specifier = ">=2.61.1,<3.0.0" },
|
||||
{ name = "sphinx", specifier = ">=9.1.0,<10" },
|
||||
{ name = "tomli", specifier = ">=2.4.1,<3.0.0" },
|
||||
{ name = "xapian-haystack", specifier = ">=4.0.0,<5.0.0" },
|
||||
@@ -2180,18 +2184,18 @@ requires-dist = [
|
||||
dev = [
|
||||
{ name = "django-debug-toolbar", specifier = ">=6.3.0,<7" },
|
||||
{ name = "djhtml", specifier = ">=3.0.11,<4.0.0" },
|
||||
{ name = "faker", specifier = ">=40.18.0,<41.0.0" },
|
||||
{ name = "ipython", specifier = ">=9.13.0,<10.0.0" },
|
||||
{ name = "faker", specifier = ">=40.21.0,<41.0.0" },
|
||||
{ name = "ipython", specifier = ">=9.14.1,<10.0.0" },
|
||||
{ name = "pre-commit", specifier = ">=4.6.0,<5.0.0" },
|
||||
{ name = "rjsmin", specifier = ">=1.2.5,<2.0.0" },
|
||||
{ name = "ruff", specifier = ">=0.15.13,<1.0.0" },
|
||||
{ name = "ruff", specifier = ">=0.15.16,<1.0.0" },
|
||||
]
|
||||
docs = [
|
||||
{ name = "mkdocs", specifier = ">=1.6.1,<2.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 = "mkdocstrings", specifier = ">=1.0.4,<2.0.0" },
|
||||
{ name = "mkdocstrings-python", specifier = ">=2.0.3,<3.0.0" },
|
||||
{ name = "mkdocstrings-python", specifier = ">=2.0.4,<3.0.0" },
|
||||
]
|
||||
prod = [{ name = "psycopg", extras = ["c"], specifier = ">=3.3.4,<4.0.0" }]
|
||||
tests = [
|
||||
@@ -2215,20 +2219,20 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "snowballstemmer"
|
||||
version = "3.0.1"
|
||||
version = "3.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.8.3"
|
||||
version = "2.8.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2383,11 +2387,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "traitlets"
|
||||
version = "5.15.0"
|
||||
version = "5.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2452,7 +2456,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
version = "21.3.3"
|
||||
version = "21.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "distlib" },
|
||||
@@ -2460,9 +2464,9 @@ dependencies = [
|
||||
{ name = "platformdirs" },
|
||||
{ name = "python-discovery" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user