Compare commits

..

3 Commits

Author SHA1 Message Date
TitouanDor
01eb88ce5d add test with wrong data form 2026-01-22 13:30:08 +01:00
TitouanDor
8d74d18a25 add test_election_form 2026-01-20 22:14:56 +01:00
Sli
2744282fd8 fix: bad value for blank vote and better flow for invalid form
* Add an error message when looking at a public election without being logged in
* Add correct value for blank vote on single vote field
* Redirect to view with an error message if an invalid form has been submitted
2026-01-14 11:45:12 +01:00
14 changed files with 198 additions and 74 deletions

View File

@@ -1,7 +1,7 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: v0.15.0 rev: v0.14.4
hooks: hooks:
- id: ruff-check # just check the code, and print the errors - id: ruff-check # just check the code, and print the errors
- id: ruff-check # actually fix the fixable errors, but print nothing - id: ruff-check # actually fix the fixable errors, but print nothing

View File

@@ -118,9 +118,9 @@ class TestFileModerationView:
(lambda: None, 403), # Anonymous user (lambda: None, 403), # Anonymous user
(lambda: baker.make(User, is_superuser=True), 200), (lambda: baker.make(User, is_superuser=True), 200),
(lambda: baker.make(User), 403), (lambda: baker.make(User), 403),
(subscriber_user.make, 403), (lambda: subscriber_user.make(), 403),
(old_subscriber_user.make, 403), (lambda: old_subscriber_user.make(), 403),
(board_user.make, 403), (lambda: board_user.make(), 403),
], ],
) )
def test_view_access( def test_view_access(
@@ -262,7 +262,7 @@ def test_apply_rights_recursively():
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.parametrize( @pytest.mark.parametrize(
("user_recipe", "file", "expected_status"), ("user_receipe", "file", "expected_status"),
[ [
( (
lambda: None, lambda: None,
@@ -279,21 +279,21 @@ def test_apply_rights_recursively():
403, 403,
), ),
( (
subscriber_user.make, lambda: subscriber_user.make(),
SimpleUploadedFile( SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg" "test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
), ),
200, 200,
), ),
( (
old_subscriber_user.make, lambda: old_subscriber_user.make(),
SimpleUploadedFile( SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg" "test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
), ),
200, 200,
), ),
( (
old_subscriber_user.make, lambda: old_subscriber_user.make(),
SimpleUploadedFile( SimpleUploadedFile(
"ttesttesttesttesttesttesttesttesttesttesttesttesttesttesttestesttesttesttesttesttesttesttesttesttesttesttest.jpg", "ttesttesttesttesttesttesttesttesttesttesttesttesttesttesttestesttesttesttesttesttesttesttesttesttesttesttest.jpg",
content=RED_PIXEL_PNG, content=RED_PIXEL_PNG,
@@ -302,21 +302,21 @@ def test_apply_rights_recursively():
200, 200,
), # very long file name ), # very long file name
( (
old_subscriber_user.make, lambda: old_subscriber_user.make(),
SimpleUploadedFile( SimpleUploadedFile(
"test.jpg", content=b"invalid", content_type="image/jpg" "test.jpg", content=b"invalid", content_type="image/jpg"
), ),
422, 422,
), ),
( (
old_subscriber_user.make, lambda: old_subscriber_user.make(),
SimpleUploadedFile( SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="invalid" "test.jpg", content=RED_PIXEL_PNG, content_type="invalid"
), ),
200, # PIL can guess 200, # PIL can guess
), ),
( (
old_subscriber_user.make, lambda: old_subscriber_user.make(),
SimpleUploadedFile("test.jpg", content=b"invalid", content_type="invalid"), SimpleUploadedFile("test.jpg", content=b"invalid", content_type="invalid"),
422, 422,
), ),
@@ -324,11 +324,11 @@ def test_apply_rights_recursively():
) )
def test_quick_upload_image( def test_quick_upload_image(
client: Client, client: Client,
user_recipe: Callable[[], User | None], user_receipe: Callable[[], User | None],
file: UploadedFile | None, file: UploadedFile | None,
expected_status: int, expected_status: int,
): ):
if (user := user_recipe()) is not None: if (user := user_receipe()) is not None:
client.force_login(user) client.force_login(user)
resp = client.post( resp = client.post(
reverse("api:quick_upload_image"), {"file": file} if file is not None else {} reverse("api:quick_upload_image"), {"file": file} if file is not None else {}

View File

@@ -418,7 +418,9 @@ class TestUserQuerySetViewableBy:
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user) viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == {users[0], users[1]} assert set(viewable) == {users[0], users[1]}
@pytest.mark.parametrize("user_factory", [lambda: baker.make(User), AnonymousUser]) @pytest.mark.parametrize(
"user_factory", [lambda: baker.make(User), lambda: AnonymousUser()]
)
def test_not_subscriber(self, users: list[User], user_factory): def test_not_subscriber(self, users: list[User], user_factory):
user = user_factory() user = user_factory()
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user) viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)

View File

@@ -65,10 +65,10 @@ class Command(BaseCommand):
"""Fetch the users which have a pending account dump.""" """Fetch the users which have a pending account dump."""
threshold = now() - settings.SITH_ACCOUNT_DUMP_DELTA threshold = now() - settings.SITH_ACCOUNT_DUMP_DELTA
ongoing_dump_operations: QuerySet[AccountDump] = ( ongoing_dump_operations: QuerySet[AccountDump] = (
AccountDump.objects.ongoing().filter( AccountDump.objects.ongoing()
customer__user=OuterRef("pk"), warning_mail_sent_at__lt=threshold .filter(customer__user=OuterRef("pk"), warning_mail_sent_at__lt=threshold)
) ) # fmt: off
) # cf. https://github.com/astral-sh/ruff/issues/14103
return ( return (
User.objects.filter(Exists(ongoing_dump_operations)) User.objects.filter(Exists(ongoing_dump_operations))
.annotate( .annotate(

View File

@@ -447,7 +447,8 @@ class Product(models.Model):
buying_groups = list(self.buying_groups.all()) buying_groups = list(self.buying_groups.all())
if not buying_groups: if not buying_groups:
return True return True
return any(user.is_in_group(pk=group.id) for group in buying_groups) res = any(user.is_in_group(pk=group.id) for group in buying_groups)
return res
@property @property
def profit(self): def profit(self):

View File

@@ -104,7 +104,7 @@
</div> </div>
<ul> <ul>
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li> <li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
<template x-for="(item, index) in Object.values(basket)" :key="item.product.id"> <template x-for="(item, index) in Object.values(basket)">
<li> <li>
<template x-for="error in item.errors"> <template x-for="error in item.errors">
<div class="alert alert-red" x-text="error"> <div class="alert alert-red" x-text="error">

View File

@@ -43,13 +43,12 @@ def get_eboutic_products(user: User) -> list[Product]:
products = ( products = (
get_eboutic() get_eboutic()
.products.filter(product_type__isnull=False) .products.filter(product_type__isnull=False)
.filter(archived=False, limit_age__lte=user.age) .filter(archived=False)
.annotate( .filter(limit_age__lte=user.age)
order=F("product_type__order"), .annotate(order=F("product_type__order"))
category=F("product_type__name"), .annotate(category=F("product_type__name"))
category_comment=F("product_type__comment"), .annotate(category_comment=F("product_type__comment"))
price=F("selling_price"), # <-- selected price for basket validation .annotate(price=F("selling_price")) # <-- selected price for basket validation
)
.prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to` .prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to`
) )
return [p for p in products if p.can_be_sold_to(user)] return [p for p in products if p.can_be_sold_to(user)]

View File

@@ -77,14 +77,6 @@
value="{% trans %}Pay with credit card{% endtrans %}" value="{% trans %}Pay with credit card{% endtrans %}"
/> />
</form> </form>
{% else %}
<div class="alert alert-yellow">
{% trans trimmed %}
Credit card payments are currently disabled on the eboutic.
You may still refill your account in one of the AE counters.
Please excuse for the disagreement.
{% endtrans %}
</div>
{% endif %} {% endif %}
{% if basket.contains_refilling_item %} {% if basket.contains_refilling_item %}
<p>{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}</p> <p>{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}</p>

View File

@@ -14,9 +14,9 @@
{% block content %} {% block content %}
<h3 class="election__title">{{ election.title }}</h3> <h3 class="election__title">{{ election.title }}</h3>
{% if not user.has_perm("core.view_user") %} {% if user.is_anonymous %}
<div class="alert alert-red"> <div class="alert alert-red">
{% trans %}Candidate pictures won't display for privacy reasons.{% endtrans %} {% trans %}You are not logged in, candidate pictures won't display for privacy reasons.{% endtrans %}
</div> </div>
{% endif %} {% endif %}
<p class="election__description">{{ election.description }}</p> <p class="election__description">{{ election.description }}</p>

View File

@@ -1,6 +1,7 @@
from datetime import timedelta from datetime import timedelta
import pytest import pytest
from pytest_django.asserts import assertRedirects
from django.conf import settings from django.conf import settings
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
@@ -115,3 +116,142 @@ def test_election_results():
"total vote": 100, "total vote": 100,
}, },
} }
@pytest.mark.django_db
def test_election_good_form(client : Client):
election = baker.make(
Election,
end_date = now() + timedelta(days=1),
)
group = baker.make(Group)
election.vote_groups.add(group)
election.edit_groups.add(group)
lists = baker.make(ElectionList, election=election, _quantity=2, _bulk_create=True)
roles = baker.make(Role, election=election, _quantity=2, _bulk_create=True)
users = baker.make(User, _quantity=4, _bulk_create=True)
cand = [
baker.make(Candidature, role=roles[0], user=users[0], election_list=lists[0]),
baker.make(Candidature, role=roles[0], user=users[1], election_list=lists[1]),
baker.make(Candidature, role=roles[1], user=users[2], election_list=lists[0]),
baker.make(Candidature, role=roles[1], user=users[3], election_list=lists[1]),
]
url = reverse("election:vote", kwargs={"election_id": election.id})
votes = [
{
roles[0].title : "",
roles[1].title : str(cand[2].id),
},
{
roles[0].title : "",
roles[1].title : "",
},
{
roles[0].title : str(cand[0].id),
roles[1].title : str(cand[2].id),
},
{
roles[0].title : str(cand[0].id),
roles[1].title : str(cand[3].id),
},
]
voters = subscriber_user.make(_quantity=len(votes), _bulk_create=True)
group.users.set(voters)
for voter, vote in zip(voters, votes):
assert election.can_vote(voter)
client.force_login(voter)
response = client.post(url, data = vote)
assertRedirects(
response,
reverse("election:detail", kwargs={"election_id": election.id})
)
assert set(election.voters.all()) == set(voters)
assert election.results == {
roles[0].title: {
cand[0].user.username: {"percent": 50.0, "vote": 2},
cand[1].user.username: {"percent": 0.0, "vote": 0},
"blank vote": {"percent": 50.0, "vote": 2},
"total vote": 4,
},
roles[1].title: {
cand[2].user.username: {"percent": 50.0, "vote": 2},
cand[3].user.username: {"percent": 25.0, "vote": 1},
"blank vote": {"percent": 25.0, "vote": 1},
"total vote": 4,
},
}
@pytest.mark.django_db
def test_election_bad_form(client : Client):
election = baker.make(
Election,
end_date = now() + timedelta(days=1),
)
group = baker.make(Group)
election.vote_groups.add(group)
election.edit_groups.add(group)
lists = baker.make(ElectionList, election=election, _quantity=2, _bulk_create=True)
roles = baker.make(Role, election=election, _quantity=2, _bulk_create=True)
users = baker.make(User, _quantity=5, _bulk_create=True)
cand = [
baker.make(Candidature, role=roles[0], user=users[0], election_list=lists[0]),
baker.make(Candidature, role=roles[0], user=users[1], election_list=lists[1]),
baker.make(Candidature, role=roles[1], user=users[2], election_list=lists[0]),
baker.make(Candidature, role=roles[1], user=users[3], election_list=lists[1]),
]
url = reverse("election:vote", kwargs={"election_id": election.id})
votes = [
{
roles[0].title : "",
roles[1].title : str(cand[0].id), #wrong candidate
},
{
roles[0].title : "",
},
{
roles[0].title : "0123456789", #unkwon users
roles[1].title : str(users[4].id), #not a candidate
},
{
},
]
voters = subscriber_user.make(_quantity=len(votes), _bulk_create=True)
group.users.set(voters)
for voter, vote in zip(voters, votes):
assert election.can_vote(voter)
client.force_login(voter)
response = client.post(url, data = vote)
assertRedirects(
response,
reverse("election:detail", kwargs={"election_id": election.id})
)
assert election.results == {
roles[0].title: {
cand[0].user.username: {"percent": 0.0, "vote": 0},
cand[1].user.username: {"percent": 0.0, "vote": 0},
"blank vote": {"percent": 100.0, "vote": 2},
"total vote": 2,
},
roles[1].title: {
cand[2].user.username: {"percent": 0.0, "vote": 0},
cand[3].user.username: {"percent": 0.0, "vote": 0},
"blank vote": {"percent": 100.0, "vote": 2},
"total vote": 2,
},
}

View File

@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-08 16:14+0100\n" "POT-Creation-Date: 2026-01-14 11:34+0100\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -3944,16 +3944,6 @@ msgstr "Solde restant : "
msgid "Pay with credit card" msgid "Pay with credit card"
msgstr "Payer avec une carte bancaire" msgstr "Payer avec une carte bancaire"
#: eboutic/templates/eboutic/eboutic_checkout.jinja
msgid ""
"Credit card payments are currently disabled on the eboutic. You may still "
"refill your account in one of the AE counters. Please excuse for the "
"disagreement."
msgstr ""
"Les paiements par carte bancaire sont actuellement désactivés sur l'eboutic. "
"Vous pouvez cependant toujours recharger votre compte dans un des lieux de vie de l'AE. "
"Veuillez nous excuser pour le désagrément."
#: eboutic/templates/eboutic/eboutic_checkout.jinja #: eboutic/templates/eboutic/eboutic_checkout.jinja
msgid "" msgid ""
"AE account payment disabled because your basket contains refilling items." "AE account payment disabled because your basket contains refilling items."
@@ -4119,10 +4109,9 @@ msgid "Candidature are closed for this election"
msgstr "Les candidatures sont fermées pour cette élection" msgstr "Les candidatures sont fermées pour cette élection"
#: election/templates/election/election_detail.jinja #: election/templates/election/election_detail.jinja
msgid "Candidate pictures won't display for privacy reasons." msgid ""
msgstr "" "You are not logged in, candidate pictures won't display for privacy reasons."
"La photo du candidat ne s'affiche pas pour " msgstr "Vous n'êtes pas connecté, les photos des candidats ne s'afficheront pas pour des raisons de respect de la vie privée."
"des raisons de respect de la vie privée."
#: election/templates/election/election_detail.jinja #: election/templates/election/election_detail.jinja
msgid "Polls close " msgid "Polls close "

View File

@@ -66,7 +66,7 @@ dev = [
"django-debug-toolbar>=6.1.0,<7", "django-debug-toolbar>=6.1.0,<7",
"ipython>=9.7.0,<10.0.0", "ipython>=9.7.0,<10.0.0",
"pre-commit>=4.3.0,<5.0.0", "pre-commit>=4.3.0,<5.0.0",
"ruff>=0.15.0,<1.0.0", "ruff>=0.14.4,<1.0.0",
"djhtml>=3.0.10,<4.0.0", "djhtml>=3.0.10,<4.0.0",
"faker>=37.12.0,<38.0.0", "faker>=37.12.0,<38.0.0",
"rjsmin>=1.2.5,<2.0.0", "rjsmin>=1.2.5,<2.0.0",

View File

@@ -31,7 +31,7 @@ document.addEventListener("alpine:init", () => {
await Promise.all( await Promise.all(
this.downloadPictures.map(async (p: PictureSchema) => { this.downloadPictures.map(async (p: PictureSchema) => {
const imgName = `${p.album.name}/IMG_${p.id}_${p.date.replace(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`; const imgName = `${p.album}/IMG_${p.date.replace(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
return zipWriter.add(imgName, new HttpReader(p.full_size_url), { return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
level: 9, level: 9,
lastModDate: new Date(p.date), lastModDate: new Date(p.date),

41
uv.lock generated
View File

@@ -1969,27 +1969,28 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.0" version = "0.14.6"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } sdist = { url = "https://files.pythonhosted.org/packages/52/f0/62b5a1a723fe183650109407fa56abb433b00aa1c0b9ba555f9c4efec2c6/ruff-0.14.6.tar.gz", hash = "sha256:6f0c742ca6a7783a736b867a263b9a7a80a45ce9bee391eeda296895f1b4e1cc", size = 5669501, upload-time = "2025-11-21T14:26:17.903Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" }, { url = "https://files.pythonhosted.org/packages/67/d2/7dd544116d107fffb24a0064d41a5d2ed1c9d6372d142f9ba108c8e39207/ruff-0.14.6-py3-none-linux_armv6l.whl", hash = "sha256:d724ac2f1c240dbd01a2ae98db5d1d9a5e1d9e96eba999d1c48e30062df578a3", size = 13326119, upload-time = "2025-11-21T14:25:24.2Z" },
{ url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" }, { url = "https://files.pythonhosted.org/packages/36/6a/ad66d0a3315d6327ed6b01f759d83df3c4d5f86c30462121024361137b6a/ruff-0.14.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9f7539ea257aa4d07b7ce87aed580e485c40143f2473ff2f2b75aee003186004", size = 13526007, upload-time = "2025-11-21T14:25:26.906Z" },
{ url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" }, { url = "https://files.pythonhosted.org/packages/a3/9d/dae6db96df28e0a15dea8e986ee393af70fc97fd57669808728080529c37/ruff-0.14.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7f6007e55b90a2a7e93083ba48a9f23c3158c433591c33ee2e99a49b889c6332", size = 12676572, upload-time = "2025-11-21T14:25:29.826Z" },
{ url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" }, { url = "https://files.pythonhosted.org/packages/76/a4/f319e87759949062cfee1b26245048e92e2acce900ad3a909285f9db1859/ruff-0.14.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8e7b9d73d8728b68f632aa8e824ef041d068d231d8dbc7808532d3629a6bef", size = 13140745, upload-time = "2025-11-21T14:25:32.788Z" },
{ url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" }, { url = "https://files.pythonhosted.org/packages/95/d3/248c1efc71a0a8ed4e8e10b4b2266845d7dfc7a0ab64354afe049eaa1310/ruff-0.14.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d50d45d4553a3ebcbd33e7c5e0fe6ca4aafd9a9122492de357205c2c48f00775", size = 13076486, upload-time = "2025-11-21T14:25:35.601Z" },
{ url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" }, { url = "https://files.pythonhosted.org/packages/a5/19/b68d4563fe50eba4b8c92aa842149bb56dd24d198389c0ed12e7faff4f7d/ruff-0.14.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:118548dd121f8a21bfa8ab2c5b80e5b4aed67ead4b7567790962554f38e598ce", size = 13727563, upload-time = "2025-11-21T14:25:38.514Z" },
{ url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" }, { url = "https://files.pythonhosted.org/packages/47/ac/943169436832d4b0e867235abbdb57ce3a82367b47e0280fa7b4eabb7593/ruff-0.14.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:57256efafbfefcb8748df9d1d766062f62b20150691021f8ab79e2d919f7c11f", size = 15199755, upload-time = "2025-11-21T14:25:41.516Z" },
{ url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" }, { url = "https://files.pythonhosted.org/packages/c9/b9/288bb2399860a36d4bb0541cb66cce3c0f4156aaff009dc8499be0c24bf2/ruff-0.14.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff18134841e5c68f8e5df1999a64429a02d5549036b394fafbe410f886e1989d", size = 14850608, upload-time = "2025-11-21T14:25:44.428Z" },
{ url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" }, { url = "https://files.pythonhosted.org/packages/ee/b1/a0d549dd4364e240f37e7d2907e97ee80587480d98c7799d2d8dc7a2f605/ruff-0.14.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c4b7ec1e66a105d5c27bd57fa93203637d66a26d10ca9809dc7fc18ec58440", size = 14118754, upload-time = "2025-11-21T14:25:47.214Z" },
{ url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" }, { url = "https://files.pythonhosted.org/packages/13/ac/9b9fe63716af8bdfddfacd0882bc1586f29985d3b988b3c62ddce2e202c3/ruff-0.14.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:167843a6f78680746d7e226f255d920aeed5e4ad9c03258094a2d49d3028b105", size = 13949214, upload-time = "2025-11-21T14:25:50.002Z" },
{ url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" }, { url = "https://files.pythonhosted.org/packages/12/27/4dad6c6a77fede9560b7df6802b1b697e97e49ceabe1f12baf3ea20862e9/ruff-0.14.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:16a33af621c9c523b1ae006b1b99b159bf5ac7e4b1f20b85b2572455018e0821", size = 14106112, upload-time = "2025-11-21T14:25:52.841Z" },
{ url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" }, { url = "https://files.pythonhosted.org/packages/6a/db/23e322d7177873eaedea59a7932ca5084ec5b7e20cb30f341ab594130a71/ruff-0.14.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1432ab6e1ae2dc565a7eea707d3b03a0c234ef401482a6f1621bc1f427c2ff55", size = 13035010, upload-time = "2025-11-21T14:25:55.536Z" },
{ url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" }, { url = "https://files.pythonhosted.org/packages/a8/9c/20e21d4d69dbb35e6a1df7691e02f363423658a20a2afacf2a2c011800dc/ruff-0.14.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4c55cfbbe7abb61eb914bfd20683d14cdfb38a6d56c6c66efa55ec6570ee4e71", size = 13054082, upload-time = "2025-11-21T14:25:58.625Z" },
{ url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" }, { url = "https://files.pythonhosted.org/packages/66/25/906ee6a0464c3125c8d673c589771a974965c2be1a1e28b5c3b96cb6ef88/ruff-0.14.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:efea3c0f21901a685fff4befda6d61a1bf4cb43de16da87e8226a281d614350b", size = 13303354, upload-time = "2025-11-21T14:26:01.816Z" },
{ url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, { url = "https://files.pythonhosted.org/packages/4c/58/60577569e198d56922b7ead07b465f559002b7b11d53f40937e95067ca1c/ruff-0.14.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:344d97172576d75dc6afc0e9243376dbe1668559c72de1864439c4fc95f78185", size = 14054487, upload-time = "2025-11-21T14:26:05.058Z" },
{ url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, { url = "https://files.pythonhosted.org/packages/67/0b/8e4e0639e4cc12547f41cb771b0b44ec8225b6b6a93393176d75fe6f7d40/ruff-0.14.6-py3-none-win32.whl", hash = "sha256:00169c0c8b85396516fdd9ce3446c7ca20c2a8f90a77aa945ba6b8f2bfe99e85", size = 13013361, upload-time = "2025-11-21T14:26:08.152Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, { url = "https://files.pythonhosted.org/packages/fb/02/82240553b77fd1341f80ebb3eaae43ba011c7a91b4224a9f317d8e6591af/ruff-0.14.6-py3-none-win_amd64.whl", hash = "sha256:390e6480c5e3659f8a4c8d6a0373027820419ac14fa0d2713bd8e6c3e125b8b9", size = 14432087, upload-time = "2025-11-21T14:26:10.891Z" },
{ url = "https://files.pythonhosted.org/packages/a5/1f/93f9b0fad9470e4c829a5bb678da4012f0c710d09331b860ee555216f4ea/ruff-0.14.6-py3-none-win_arm64.whl", hash = "sha256:d43c81fbeae52cfa8728d8766bbf46ee4298c888072105815b392da70ca836b2", size = 13520930, upload-time = "2025-11-21T14:26:13.951Z" },
] ]
[[package]] [[package]]
@@ -2120,7 +2121,7 @@ dev = [
{ name = "ipython", specifier = ">=9.7.0,<10.0.0" }, { name = "ipython", specifier = ">=9.7.0,<10.0.0" },
{ name = "pre-commit", specifier = ">=4.3.0,<5.0.0" }, { name = "pre-commit", specifier = ">=4.3.0,<5.0.0" },
{ name = "rjsmin", specifier = ">=1.2.5,<2.0.0" }, { name = "rjsmin", specifier = ">=1.2.5,<2.0.0" },
{ name = "ruff", specifier = ">=0.15.0,<1.0.0" }, { name = "ruff", specifier = ">=0.14.4,<1.0.0" },
] ]
docs = [ docs = [
{ name = "mkdocs", specifier = ">=1.6.1,<2.0.0" }, { name = "mkdocs", specifier = ">=1.6.1,<2.0.0" },