1 Commits

Author SHA1 Message Date
dependabot[bot] 39e1c2744d [UPDATE] Update environs requirement from <16,>=6.0.5 to >=15.0.1,<16
Updates the requirements on [environs](https://github.com/sloria/environs) to permit the latest version.
- [Changelog](https://github.com/sloria/environs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sloria/environs/compare/6.1.0...15.0.1)

---
updated-dependencies:
- dependency-name: environs
  dependency-version: 15.0.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-08 08:23:05 +00:00
31 changed files with 327 additions and 530 deletions
+1 -1
View File
@@ -1,5 +1,4 @@
import pytest import pytest
from aemark import markdown
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.test import Client from django.test import Client
from django.urls import reverse from django.urls import reverse
@@ -8,6 +7,7 @@ from pytest_django.asserts import assertHTMLEqual, assertRedirects
from club.models import Club, ClubRole, Membership from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.markdown import markdown
from core.models import PageRev, User from core.models import PageRev, User
+1 -1
View File
@@ -1,13 +1,13 @@
from datetime import datetime from datetime import datetime
from typing import Annotated from typing import Annotated
from aemark import markdown
from ninja import FilterLookup, FilterSchema, ModelSchema from ninja import FilterLookup, FilterSchema, ModelSchema
from ninja_extra import service_resolver from ninja_extra import service_resolver
from ninja_extra.context import RouteContext from ninja_extra.context import RouteContext
from club.schemas import ClubProfileSchema from club.schemas import ClubProfileSchema
from com.models import News, NewsDate from com.models import News, NewsDate
from core.markdown import markdown
class NewsDateFilterSchema(FilterSchema): class NewsDateFilterSchema(FilterSchema):
+1 -1
View File
@@ -2,7 +2,6 @@ from datetime import timedelta
from pathlib import Path from pathlib import Path
import pytest import pytest
from aemark import markdown
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.http import HttpResponse from django.http import HttpResponse
@@ -14,6 +13,7 @@ from pytest_django.asserts import assertNumQueries
from com.ics_calendar import IcsCalendar from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate from com.models import News, NewsDate
from core.markdown import markdown
from core.models import User from core.models import User
+36 -35
View File
@@ -2,9 +2,12 @@
<h1>Markdown-AE Documentation</h1> <h1>Markdown-AE Documentation</h1>
<p>Le Markdown le plus standard se trouve documenté ici: <p>Le Markdown le plus standard se trouve documenté ici:
<a href="https://www.markdownguide.org/basic-syntax">https://www.markdownguide.org/basic-syntax</a>.<br /> <a href="https://www.markdownguide.org/basic-syntax">https://www.markdownguide.org/basic-syntax</a>.<br />
Si cette page nest pas exhaustive vis à vis de la syntaxe du site AE, Si cette page n'est pas exhaustive vis à vis de la syntaxe du site AE,
elle a au moins le mérite de bien documenter le Markdown original.</p> elle a au moins le mérite de bien documenter le Markdown original.</p>
<p>Le réel parseur du site AE est une version tunée de <a href="https://github.com/kivikakk/comrak">comrak</a>.</p> <p>Le réel parseur du site AE est une version tunée de <a href="https://github.com/lepture/mistune">mistune</a>.<br />
Les plus aventureux pourront aller lire ses <a href="https://github.com/lepture/mistune/blob/master/tests/fixtures">tests</a>
afin d'en connaître la syntaxe le plus finement possible.<br />
En pratique, cette page devrait déjà résumer une bonne partie.</p>
<h2>Basique</h2> <h2>Basique</h2>
<ul> <ul>
<li>Mettre le texte en <strong>gras</strong> : <code>**texte**</code></li> <li>Mettre le texte en <strong>gras</strong> : <code>**texte**</code></li>
@@ -12,8 +15,8 @@ elle a au moins le mérite de bien documenter le Markdown original.</p>
<li><u>Souligner</u> le texte : <code>__texte__</code></li> <li><u>Souligner</u> le texte : <code>__texte__</code></li>
<li><del>Barrer du texte</del> : <code>~~texte~~</code></li> <li><del>Barrer du texte</del> : <code>~~texte~~</code></li>
<li>On peut bien sûr tout <del><em><strong><u>combiner</u></strong></em></del> : <code>~~***__texte__***~~</code></li> <li>On peut bien sûr tout <del><em><strong><u>combiner</u></strong></em></del> : <code>~~***__texte__***~~</code></li>
<li>Mettre du texte<sup>en exposant</sup> : <code>&lt;sup&gt;texte&lt;/sup&gt;</code></li> <li>Mettre du texte^en exposant^ : <code>&lt;sup&gt;texte&lt;/sup&gt;</code></li>
<li>Mettre du texte<sub>en indice</sub> : <code>&lt;sub&gt;texte&lt;/sub&gt;</code></li> <li>Mettre du texte~en indice~ : <code>&lt;sub&gt;texte&lt;/sub&gt;</code></li>
</ul> </ul>
<h2>Liens</h2> <h2>Liens</h2>
<ul> <ul>
@@ -25,10 +28,10 @@ elle a au moins le mérite de bien documenter le Markdown original.</p>
</ul> </ul>
<p><a href="http://www.site.com">nom du lien</a></p> <p><a href="http://www.site.com">nom du lien</a></p>
<ul> <ul>
<li>Les liens peuvent être internes au site de lAE, on peut dès lors éviter dentrer <li>Les liens peuvent être internes au site de l'AE, on peut dès lors éviter d'entrer
ladresse complète dune page : <code>[nom du lien](page://nomDeLaPage)</code></li> l'adresse complète d'une page : <code>[nom du lien](page://nomDeLaPage)</code></li>
</ul> </ul>
<p><a href="/page/nomDeLaPage">nom du lien</a></p> <p><a href="/page/nomDeLaPage/">nom du lien</a></p>
<ul> <ul>
<li>On peut également utiliser une image pour les liens : <li>On peut également utiliser une image pour les liens :
<code>[nom du lien]![images/imageDuSiteAE.png](/chemin/vers/image.png titre optionnel)(options)</code></li> <code>[nom du lien]![images/imageDuSiteAE.png](/chemin/vers/image.png titre optionnel)(options)</code></li>
@@ -91,25 +94,25 @@ etc...
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Titre</th> <th>Titre</th>
<th>Titre2</th> <th>Titre2</th>
<th>Titre3</th> <th>Titre3</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td>test</td> <td>test</td>
<td>test</td> <td>test</td>
<td>test</td> <td>test</td>
</tr> </tr>
<tr> <tr>
<td>test</td> <td>test</td>
<td>test</td> <td>test</td>
<td>test</td> <td>test</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<p>Lalignement dans les cellules est géré comme suit, avec les : sur la ligne en dessous du titre:</p> <p>L'alignement dans les cellules est géré comme suit, avec les ':' sur la ligne en dessous du titre:</p>
<pre><code>| Titre | Titre2 | Titre3 | <pre><code>| Titre | Titre2 | Titre3 |
|:-------|:------:|-------:| |:-------|:------:|-------:|
| gauche | centre | droite | | gauche | centre | droite |
@@ -117,16 +120,16 @@ etc...
<table> <table>
<thead> <thead>
<tr> <tr>
<th align="left">Titre</th> <th style="text-align:left">Titre</th>
<th align="center">Titre2</th> <th style="text-align:center">Titre2</th>
<th align="right">Titre3</th> <th style="text-align:right">Titre3</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td align="left">gauche</td> <td style="text-align:left">gauche</td>
<td align="center">centre</td> <td style="text-align:center">centre</td>
<td align="right">droite</td> <td style="text-align:right">droite</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -138,11 +141,11 @@ etc...
![image de 350 pixels de large](/static/core/img/logo.png?350 &quot;Image de 350 pixels&quot;) ![image de 350 pixels de large](/static/core/img/logo.png?350 &quot;Image de 350 pixels&quot;)
![image de 350x100 pixels](/static/core/img/logo.png?350x100 &quot;Image de 350x100 pixels&quot;) ![image de 350x100 pixels](/static/core/img/logo.png?350x100 &quot;Image de 350x100 pixels&quot;)
</code></pre> </code></pre>
<p><img src="/static/core/img/logo.png" style="width:50%" alt="image à 50%" title="Image à 50%" /><br /> <p><img src="/static/core/img/logo.png" alt="image à 50%" title="Image à 50%" style="width:50%;" /><br />
Image à 50% de la largeur de la page.</p> Image à 50% de la largeur de la page.</p>
<p><img src="/static/core/img/logo.png" style="width:350px" alt="image de 350 pixels de large" title="Image de 350 pixels" /><br /> <p><img src="/static/core/img/logo.png" alt="image de 350 pixels de large" title="Image de 350 pixels" style="width:350px;" /><br />
Image de 350 pixels de large.</p> Image de 350 pixels de large.</p>
<p><img src="/static/core/img/logo.png" style="width:350px;height:100px" alt="image de 350x100 pixels" title="Image de 350x100 pixels" /><br /> <p><img src="/static/core/img/logo.png" alt="image de 350x100 pixels" title="Image de 350x100 pixels" style="width:350px;height:100px;" /><br />
Image de 350x100 pixels.</p> Image de 350x100 pixels.</p>
<p>(devrait pouvoir détecter si vidéo ou non)</p> <p>(devrait pouvoir détecter si vidéo ou non)</p>
<h2>Blocs de citations</h2> <h2>Blocs de citations</h2>
@@ -156,9 +159,9 @@ Image de 350x100 pixels.</p>
un bloc de un bloc de
citation</p> citation</p>
</blockquote> </blockquote>
<p>Il est possible dintégrer de la syntaxe Markdown-AE dans un tel bloc.</p> <p>Il est possible d'intégrer de la syntaxe Markdown-AE dans un tel bloc.</p>
<h2>Note de bas de page</h2> <h2>Note de bas de page</h2>
<p>On les crée comme ça<sup class="footnote-ref"><a href="#fn-key" id="fnref-key" data-footnote-ref>1</a></sup>:</p> <p>On les crée comme ça<sup class="footnote-ref" id="fnref-1"><a href="#fn-1">1</a></sup>:</p>
<pre><code>Je fais une note[^clef]. <pre><code>Je fais une note[^clef].
[^clef]: je note ensuite où je veux le contenu de ma clef qui apparaîtra quand même en bas [^clef]: je note ensuite où je veux le contenu de ma clef qui apparaîtra quand même en bas
@@ -172,15 +175,13 @@ citation</p>
</code></pre> </code></pre>
<h2>Échapper des caractères</h2> <h2>Échapper des caractères</h2>
<ul> <ul>
<li>Il est possible dignorer un caractère spécial en léchappant à laide dun \</li> <li>Il est possible d'ignorer un caractère spécial en l'échappant à l'aide d'un \</li>
<li>Léchappement de blocs de codes complet se fera à laide de balises &lt;nosyntax&gt;&lt;/nosyntax&gt;</li> <li>L'échappement de blocs de codes complet se fera à l'aide de balises &lt;nosyntax&gt;&lt;/nosyntax&gt;</li>
</ul> </ul>
<h2>Autres (hérité de lancien wiki)</h2> <h2>Autres (hérité de l'ancien wiki)</h2>
<p>Une ligne peut être créée avec une ligne contenant 4 tirets (<code>----</code>).</p> <p>Une ligne peut être créée avec une ligne contenant 4 tirets (<code>----</code>).</p>
<section class="footnotes" data-footnotes> <section class="footnotes">
<ol> <ol>
<li id="fn-key"> <li id="fn-1"><p>ceci est le contenu de ma clef<a href="#fnref-1" class="footnote">&#8617;</a></p></li>
<p>ceci est le contenu de ma clef <a href="#fnref-key" class="footnote-backref" data-footnote-backref data-footnote-backref-idx="1" aria-label="Back to reference 1"></a></p>
</li>
</ol> </ol>
</section> </section>
+4 -1
View File
@@ -7,7 +7,10 @@ https://www.markdownguide.org/basic-syntax.
Si cette page n'est pas exhaustive vis à vis de la syntaxe du site AE, Si cette page n'est pas exhaustive vis à vis de la syntaxe du site AE,
elle a au moins le mérite de bien documenter le Markdown original. elle a au moins le mérite de bien documenter le Markdown original.
Le réel parseur du site AE est une version tunée de [comrak](https://github.com/kivikakk/comrak). Le réel parseur du site AE est une version tunée de [mistune](https://github.com/lepture/mistune).
Les plus aventureux pourront aller lire ses [tests](https://github.com/lepture/mistune/blob/master/tests/fixtures)
afin d'en connaître la syntaxe le plus finement possible.
En pratique, cette page devrait déjà résumer une bonne partie.
## Basique ## Basique
+2 -1
View File
@@ -22,10 +22,11 @@
# #
from aemark import markdown
from django.conf import settings from django.conf import settings
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from core.markdown import markdown
class Command(BaseCommand): class Command(BaseCommand):
help = "Output the fully rendered SYNTAX.md file" help = "Output the fully rendered SYNTAX.md file"
+132
View File
@@ -0,0 +1,132 @@
#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from __future__ import annotations
import re
from typing import TYPE_CHECKING
import mistune
from django.urls import reverse
from mistune import HTMLRenderer, Markdown
if TYPE_CHECKING:
from mistune import InlineParser, InlineState
# match __text__, without linebreak in the text, nor backslash prepending an underscore
# Examples :
# - "__text__" : OK
# - "__te xt__" : OK
# - "__te_xt__" : nope (underscore in the middle)
# - "__te\_xt__" : Ok (the middle underscore is escaped)
# - "__te\nxt__" : nope (there is a linebreak in the text)
# - "\__text__" : nope (one of the underscores have a backslash prepended)
# - "\\__text__" : Ok (the backslash is ignored, because there is another backslash before)
UNDERLINED_RE = (
r"(?<!\\)(?:\\{2})*" # ignore if there is an odd number of backslashes before
r"_{2}" # two underscores
r"(?P<underlined>([^\\_]|\\.)+)" # the actual text
r"_{2}" # closing underscores
)
SITH_LINK_RE = (
r"\[(?P<page_name>[\w\s]+)\]" # [nom du lien]
r"\(page:\/\/" # (page://
r"(?P<page_slug>[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9])" # actual page name
r"\)" # )
)
CUSTOM_DIMENSIONS_IMAGE_RE = (
r"\[(?P<img_name>[\w\s]+)\]" # [nom du lien]
r"\(img:\/\/" # (img://
r"(?P<img_slug>[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9])" # actual page name
r"\)" # )
)
def parse_underline(_inline: InlineParser, m: re.Match, state: InlineState):
state.append_token({"type": "underline", "raw": m.group("underlined")})
return m.end()
def underline(md_instance: Markdown):
md_instance.inline.register(
"underline",
UNDERLINED_RE,
parse_underline,
before="emphasis",
)
md_instance.renderer.register("underline", lambda _, text: f"<u>{text}</u>")
def parse_sith_link(_inline: InlineParser, m: re.Match, state: InlineState):
page_name = m.group("page_name")
page_slug = m.group("page_slug")
state.append_token(
{
"type": "link",
"children": [{"type": "text", "raw": page_name}],
"attrs": {"url": reverse("core:page", kwargs={"page_name": page_slug})},
}
)
return m.end()
def sith_link(md_instance: Markdown):
md_instance.inline.register(
"sith_link",
SITH_LINK_RE,
parse_sith_link,
before="emphasis",
)
# no custom renderer here.
# we just add another parsing rule, but render it as if it was
# a regular markdown link
class SithRenderer(HTMLRenderer):
def image(self, text: str, url: str, title=None) -> str:
if "?" not in url:
return super().image(text, url, title)
new_url, params = url.rsplit("?", maxsplit=1)
m = re.match(r"^(?P<width>\d+(%|px)?)(x(?P<height>\d+(%|px)?))?$", params)
if not m:
return super().image(text, url, title)
width, height = m.group("width"), m.group("height")
if not width.endswith(("%", "px")):
width += "px"
style = f"width:{width};"
if height is not None:
if not height.endswith(("%", "px")):
height += "px"
style += f"height:{height};"
return super().image(text, new_url, title).replace("/>", f'style="{style}" />')
markdown = mistune.create_markdown(
renderer=SithRenderer(escape=True),
plugins=[
underline,
sith_link,
"strikethrough",
"footnotes",
"table",
"spoiler",
"subscript",
"superscript",
"url",
],
)
+2 -1
View File
@@ -25,13 +25,14 @@
import datetime import datetime
import phonenumbers import phonenumbers
from aemark import markdown as md
from django import template from django import template
from django.forms import BoundField from django.forms import BoundField
from django.template.defaultfilters import stringfilter from django.template.defaultfilters import stringfilter
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import ngettext from django.utils.translation import ngettext
from core.markdown import markdown as md
register = template.Library() register = template.Library()
+12 -9
View File
@@ -18,7 +18,6 @@ from smtplib import SMTPException
import freezegun import freezegun
import pytest import pytest
from aemark import markdown
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
@@ -35,6 +34,7 @@ from pytest_django.asserts import assertInHTML, assertRedirects
from antispam.models import ToxicDomain from antispam.models import ToxicDomain
from club.models import Club from club.models import Club
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User, validate_promo from core.models import AnonymousUser, Group, Page, User, validate_promo
from core.utils import get_last_promo, get_semester_code, get_start_of_semester from core.utils import get_last_promo, get_semester_code, get_start_of_semester
from core.views import AllowFragment from core.views import AllowFragment
@@ -200,28 +200,31 @@ class TestUserLogin:
[ [
( (
"[nom du lien](page://nomDeLaPage)", "[nom du lien](page://nomDeLaPage)",
'<a href="/page/nomDeLaPage">nom du lien</a>', '<a href="/page/nomDeLaPage/">nom du lien</a>',
), ),
("__texte__", "<u>texte</u>"), ("__texte__", "<u>texte</u>"),
("~~***__texte__***~~", "<del><em><strong><u>texte</u></strong></em></del>"), ("~~***__texte__***~~", "<del><em><strong><u>texte</u></strong></em></del>"),
( (
'![tst_alt](/img.png?50% "tst_title")', '![tst_alt](/img.png?50% "tst_title")',
'<img src="/img.png" style="width:50%" alt="tst_alt" title="tst_title" />', '<img src="/img.png" alt="tst_alt" title="tst_title" style="width:50%;" />',
),
(
"[texte](page://tst-page)",
'<a href="/page/tst-page/">texte</a>',
), ),
("[texte](page://tst-page)", '<a href="/page/tst-page">texte</a>'),
( (
"![](/img.png?50x450)", "![](/img.png?50x450)",
'<img src="/img.png" style="width:50px;height:450px" alt="" />', '<img src="/img.png" alt="" style="width:50px;height:450px;" />',
), ),
("![](/img.png)", '<img src="/img.png" alt="" />'), ("![](/img.png)", '<img src="/img.png" alt="" />'),
( (
"![](/img.png?50%x120%)", "![](/img.png?50%x120%)",
'<img src="/img.png" style="width:50%;height:120%" alt="" />', '<img src="/img.png" alt="" style="width:50%;height:120%;" />',
), ),
("![](/img.png?50px)", '<img src="/img.png" style="width:50px" alt="" />'), ("![](/img.png?50px)", '<img src="/img.png" alt="" style="width:50px;" />'),
( (
"![](/img.png?50pxx120%)", "![](/img.png?50pxx120%)",
'<img src="/img.png" style="width:50px;height:120%" alt="" />', '<img src="/img.png" alt="" style="width:50px;height:120%;" />',
), ),
# when the image dimension has a wrong format, don't touch the url # when the image dimension has a wrong format, don't touch the url
("![](/img.png?50pxxxxxxxx)", '<img src="/img.png?50pxxxxxxxx" alt="" />'), ("![](/img.png?50pxxxxxxxx)", '<img src="/img.png?50pxxxxxxxx" alt="" />'),
@@ -347,7 +350,7 @@ http://git.an
<p><a href="http://git.an">http://git.an</a></p> <p><a href="http://git.an">http://git.an</a></p>
<h1>Swag</h1> <h1>Swag</h1>
<p>&lt;guy&gt;Bibou&lt;/guy&gt;</p> <p>&lt;guy&gt;Bibou&lt;/guy&gt;</p>
&lt;script&gt;alert('Guy');&lt;/script&gt; <p>&lt;script&gt;alert('Guy');&lt;/script&gt;</p>
""" """
assertInHTML(expected, response.text) assertInHTML(expected, response.text)
+1 -1
View File
@@ -2,7 +2,6 @@ from datetime import timedelta
import freezegun import freezegun
import pytest import pytest
from aemark import markdown
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
@@ -14,6 +13,7 @@ from pytest_django.asserts import assertHTMLEqual, assertRedirects
from club.models import Club, Membership from club.models import Club, Membership
from core.baker_recipes import board_user, subscriber_user from core.baker_recipes import board_user, subscriber_user
from core.markdown import markdown
from core.models import AnonymousUser, Page, PageRev, User from core.models import AnonymousUser, Page, PageRev, User
+2 -8
View File
@@ -200,11 +200,7 @@ class TestFilterInactive(TestCase):
] ]
sale_recipe.make(customer=cls.users[3].customer, date=time_active) sale_recipe.make(customer=cls.users[3].customer, date=time_active)
baker.make( baker.make(
Refilling, Refilling, customer=cls.users[4].customer, date=time_active, counter=counter
customer=cls.users[4].customer,
date=time_active,
counter=counter,
amount=1,
) )
sale_recipe.make(customer=cls.users[5].customer, date=time_inactive) sale_recipe.make(customer=cls.users[5].customer, date=time_inactive)
@@ -459,9 +455,7 @@ def test_user_preferences(client: Client):
@pytest.mark.django_db @pytest.mark.django_db
def test_user_stats(client: Client): def test_user_stats(client: Client):
user = subscriber_user.make() user = subscriber_user.make()
baker.make( baker.make(Refilling, customer=user.customer, amount=99999)
Refilling, customer=user.customer, amount=settings.SITH_ACCOUNT_MAX_MONEY
)
bars = [b[0] for b in settings.SITH_COUNTER_BARS] bars = [b[0] for b in settings.SITH_COUNTER_BARS]
baker.make( baker.make(
Permanency, Permanency,
+4 -50
View File
@@ -1,68 +1,22 @@
from decimal import Decimal from decimal import Decimal
from django.conf import settings from django.conf import settings
from django.core import checks
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.utils.functional import cached_property
class CurrencyField(models.DecimalField): class CurrencyField(models.DecimalField):
"""Custom database field used for currency.""" """Custom database field used for currency."""
def __init__( def __init__(self, *args, **kwargs):
self, verbose_name=None, name=None, min_value=None, max_value=None, **kwargs kwargs["max_digits"] = 12
): kwargs["decimal_places"] = 2
kwargs.update({"max_digits": 12, "decimal_places": 2}) super().__init__(*args, **kwargs)
self.min_value = min_value
self.max_value = max_value
super().__init__(verbose_name, name, **kwargs)
def to_python(self, value): def to_python(self, value):
if value is None: if value is None:
return None return None
return super().to_python(value).quantize(Decimal("0.01")) return super().to_python(value).quantize(Decimal("0.01"))
@cached_property
def validators(self):
res = []
if self.max_value:
res.append(MaxValueValidator(self.max_value))
if self.min_value:
res.append(MinValueValidator(self.min_value))
return [*super().validators, *res]
def check(self, **kwargs): # pragma: no cover
# this is executed during runserver, but won't run in prod
errors = super().check(**kwargs)
for name, val in ("min_value", self.min_value), ("max_value", self.max_value):
if not val:
continue
try:
float(val)
except ValueError:
errors.append(
checks.Error(
f"CurrencyField.{name} must be a valid float",
obj=self,
id="sith.E001",
)
)
return errors
def formfield(self, **kwargs):
return super().formfield(
**{"min_value": self.min_value, "max_value": self.max_value, **kwargs}
)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
if self.min_value is not None:
kwargs["min_value"] = self.min_value
if self.max_value is not None:
kwargs["max_value"] = self.max_value
return name, path, args, kwargs
if settings.TESTING: if settings.TESTING:
from model_bakery import baker from model_bakery import baker
+27 -59
View File
@@ -3,16 +3,13 @@ import math
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from typing import ClassVar
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django import forms from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet from django.forms import BaseModelFormSet
from django.http import HttpRequest from django.http import HttpRequest
from django.utils.functional import cached_property
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_celery_beat.models import ClockedSchedule from django_celery_beat.models import ClockedSchedule
@@ -42,7 +39,6 @@ from counter.models import (
Customer, Customer,
Eticket, Eticket,
InvoiceCall, InvoiceCall,
Permanency,
Price, Price,
Product, Product,
ProductFormula, ProductFormula,
@@ -155,13 +151,12 @@ class CounterLoginForm(LoginForm):
raise ValidationError( raise ValidationError(
message=_("You are not a barman of this counter."), code="not_barman" message=_("You are not a barman of this counter."), code="not_barman"
) )
if Permanency.objects.filter(end=None, user=user).exists():
if user in self.request.barmen: if user in self.request.barmen:
message = _("You are already logged in this counter.") message = (
elif user in self.counter.barmen_list: _("You are already logged in this counter.")
message = _("You are already logged in another counter.") if user in self.counter.barmen_list
else: else _("You are already logged in another counter.")
message = _("You are already logged on another device") )
raise ValidationError(message=message, code="already_logged_in") raise ValidationError(message=message, code="already_logged_in")
@@ -173,19 +168,18 @@ class RefillForm(forms.ModelForm):
error_css_class = "error" error_css_class = "error"
required_css_class = "required" required_css_class = "required"
amount = forms.FloatField(
min_value=0, widget=forms.NumberInput(attrs={"class": "focus"})
)
class Meta: class Meta:
model = Refilling model = Refilling
fields = ["amount", "payment_method"] fields = ["amount", "payment_method"]
widgets = {"payment_method": forms.RadioSelect} widgets = {"payment_method": forms.RadioSelect}
def __init__( def __init__(self, *args, **kwargs):
self, *args, counter: Counter, operator: User, customer: Customer, **kwargs
):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
max_value = settings.SITH_ACCOUNT_MAX_MONEY - customer.amount
# server-side max_value validation is done by Refilling.clean
self.fields["amount"].widget.attrs["max"] = max_value
self.fields["payment_method"].choices = ( self.fields["payment_method"].choices = (
method method
for method in self.fields["payment_method"].choices for method in self.fields["payment_method"].choices
@@ -193,9 +187,6 @@ class RefillForm(forms.ModelForm):
) )
if self.fields["payment_method"].initial not in self.allowed_refilling_methods: if self.fields["payment_method"].initial not in self.allowed_refilling_methods:
self.fields["payment_method"].initial = self.allowed_refilling_methods[0] self.fields["payment_method"].initial = self.allowed_refilling_methods[0]
self.instance.counter = counter
self.instance.operator = operator
self.instance.customer = customer
class CounterEditForm(forms.ModelForm): class CounterEditForm(forms.ModelForm):
@@ -569,7 +560,16 @@ class BasketItemForm(forms.Form):
quantity = forms.IntegerField(min_value=1, required=True) quantity = forms.IntegerField(min_value=1, required=True)
price_id = forms.IntegerField(min_value=0, required=True) price_id = forms.IntegerField(min_value=0, required=True)
def __init__(self, allowed_prices: dict[int, Price], *args, **kwargs): def __init__(
self,
customer: Customer,
counter: Counter,
allowed_prices: dict[int, Price],
*args,
**kwargs,
):
self.customer = customer # Used by formset
self.counter = counter # Used by formset
self.allowed_prices = allowed_prices self.allowed_prices = allowed_prices
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -604,15 +604,6 @@ class BasketItemForm(forms.Form):
class BaseBasketForm(forms.BaseFormSet): class BaseBasketForm(forms.BaseFormSet):
# Minimum amount of money there must be on the account after the transaction
# If None, the min balance check is skipped
min_result_balance: ClassVar[int | None] = 0
def __init__(self, *args, customer: Customer, counter: Counter, **kwargs):
super().__init__(*args, **kwargs)
self.customer = customer
self.counter = counter
def clean(self): def clean(self):
self.forms = [form for form in self.forms if form.cleaned_data != {}] self.forms = [form for form in self.forms if form.cleaned_data != {}]
@@ -621,8 +612,8 @@ class BaseBasketForm(forms.BaseFormSet):
self._check_forms_have_errors() self._check_forms_have_errors()
self._check_product_are_unique() self._check_product_are_unique()
self._check_recorded_products() self._check_recorded_products(self[0].customer)
self._check_account_balance() self._check_enough_money(self[0].counter, self[0].customer)
def _check_forms_have_errors(self): def _check_forms_have_errors(self):
if any(len(form.errors) > 0 for form in self): if any(len(form.errors) > 0 for form in self):
@@ -633,35 +624,12 @@ class BaseBasketForm(forms.BaseFormSet):
if len(price_ids) != len(self.forms): if len(price_ids) != len(self.forms):
raise forms.ValidationError(_("Duplicated product entries.")) raise forms.ValidationError(_("Duplicated product entries."))
@cached_property def _check_enough_money(self, counter: Counter, customer: Customer):
def total_price(self): self.total_price = sum([data["total_price"] for data in self.cleaned_data])
refill = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING if self.total_price > customer.amount:
total_other = sum(
form.cleaned_data["total_price"]
for form in self.forms
if form.price.product.product_type_id != refill
)
total_refill = sum(
form.cleaned_data["total_price"]
for form in self.forms
if form.price.product.product_type_id == refill
)
return total_other - total_refill
def _check_account_balance(self):
result_balance = self.customer.amount - self.total_price
if (
self.min_result_balance is not None
and self.min_result_balance > result_balance
):
raise forms.ValidationError(_("Not enough money")) raise forms.ValidationError(_("Not enough money"))
if result_balance > settings.SITH_ACCOUNT_MAX_MONEY:
raise ValidationError(
_("There cannot be more than %(money)d€ on an AE account")
% {"money": settings.SITH_ACCOUNT_MAX_MONEY}
)
def _check_recorded_products(self): def _check_recorded_products(self, customer: Customer):
"""Check for, among other things, ecocups and pitchers""" """Check for, among other things, ecocups and pitchers"""
items = defaultdict(int) items = defaultdict(int)
for form in self.forms: for form in self.forms:
@@ -670,7 +638,7 @@ class BaseBasketForm(forms.BaseFormSet):
returnables = list( returnables = list(
ReturnableProduct.objects.filter( ReturnableProduct.objects.filter(
Q(product_id__in=ids) | Q(returned_product_id__in=ids) Q(product_id__in=ids) | Q(returned_product_id__in=ids)
).annotate_balance_for(self.customer) ).annotate_balance_for(customer)
) )
limit_reached = [] limit_reached = []
for returnable in returnables: for returnable in returnables:
+19 -21
View File
@@ -1,7 +1,8 @@
from typing import TYPE_CHECKING, Callable from typing import TYPE_CHECKING, Callable
from django.db.models import Exists, OuterRef
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.utils.functional import SimpleLazyObject from django.utils.functional import SimpleLazyObject, empty
from core.models import User from core.models import User
from counter.models import Permanency from counter.models import Permanency
@@ -10,31 +11,20 @@ if TYPE_CHECKING:
from django.contrib.sessions.backends.base import SessionBase from django.contrib.sessions.backends.base import SessionBase
SESSION_PERMANENCES_KEY = "permanence_ids" SESSION_BARMEN_KEY = "barmen_ids"
def get_cached_barmen(request: HttpRequest) -> set[User]: def get_cached_barmen(request: HttpRequest) -> set[User]:
if not hasattr(request, "_cached_barmen"): if not hasattr(request, "_cached_barmen"):
session: SessionBase = request.session session: SessionBase = request.session
barmen_ids = session.get(SESSION_BARMEN_KEY, [])
if session_ids := session.get(SESSION_PERMANENCES_KEY, None): if barmen_ids:
# Get ongoing permanences which id is in session. request._cached_barmen = set(
# Note : we store permanence ids rather than user id to be sure User.objects.filter(
# not to wrongfully mark someone as logged here, Exists(Permanency.objects.filter(user=OuterRef("pk"), end=None)),
# even if it logged out then logged in elsewhere. id__in=barmen_ids,
permanences = ( )
Permanency.objects.filter(end=None, id__in=session_ids)
.order_by("id")
.select_related("user")
) )
# if the list of permanences occurring on this device has changed
# since the last page load, change the ids stored in session
real_ids = [p.id for p in permanences]
if real_ids != session_ids:
session[SESSION_PERMANENCES_KEY] = real_ids
request._cached_barmen = {p.user for p in permanences}
else: else:
request._cached_barmen = set() request._cached_barmen = set()
@@ -63,4 +53,12 @@ class BarmenMiddleware:
def __call__(self, request: HttpRequest): def __call__(self, request: HttpRequest):
request.barmen = SimpleLazyObject(lambda: get_cached_barmen(request)) request.barmen = SimpleLazyObject(lambda: get_cached_barmen(request))
return self.get_response(request) response = self.get_response(request)
if request.barmen._wrapped is not empty and {
b.id for b in request.barmen
} != set(request.session.get(SESSION_BARMEN_KEY, [])):
# update the session data only if `session.barmen`
# has been accessed and modified.
request.session[SESSION_BARMEN_KEY] = [b.id for b in request.barmen]
return response
@@ -1,30 +0,0 @@
# Generated by Django 5.2.15 on 2026-06-07 12:08
from django.db import migrations
import counter.fields
class Migration(migrations.Migration):
dependencies = [("counter", "0041_alter_billinginfo_country_and_more")]
operations = [
migrations.AlterField(
model_name="customer",
name="amount",
field=counter.fields.CurrencyField(
decimal_places=2,
default=0,
max_digits=12,
max_value=250,
verbose_name="amount",
),
),
migrations.AlterField(
model_name="refilling",
name="amount",
field=counter.fields.CurrencyField(
decimal_places=2, max_digits=12, min_value=0.01, verbose_name="amount"
),
),
]
+8 -20
View File
@@ -28,7 +28,7 @@ from dict2xml import dict2xml
from django.conf import settings from django.conf import settings
from django.core.validators import MinLengthValidator from django.core.validators import MinLengthValidator
from django.db import models from django.db import models
from django.db.models import Exists, F, Max, OuterRef, Q, QuerySet, Subquery, Sum, Value from django.db.models import Exists, F, OuterRef, Q, QuerySet, Subquery, Sum, Value
from django.db.models.functions import Coalesce, Concat, Length from django.db.models.functions import Coalesce, Concat, Length
from django.forms import ValidationError from django.forms import ValidationError
from django.urls import reverse from django.urls import reverse
@@ -99,9 +99,7 @@ class Customer(models.Model):
user = models.OneToOneField(User, primary_key=True, on_delete=models.CASCADE) user = models.OneToOneField(User, primary_key=True, on_delete=models.CASCADE)
account_id = models.CharField(_("account id"), max_length=10, unique=True) account_id = models.CharField(_("account id"), max_length=10, unique=True)
amount: CurrencyField = CurrencyField( amount = CurrencyField(_("amount"), default=0)
_("amount"), max_value=settings.SITH_ACCOUNT_MAX_MONEY, default=0
)
objects = CustomerQuerySet.as_manager() objects = CustomerQuerySet.as_manager()
@@ -158,15 +156,13 @@ class Customer(models.Model):
unique_fields=["customer", "returnable"], unique_fields=["customer", "returnable"],
) )
@cached_property @property
def can_buy(self) -> bool: def can_buy(self) -> bool:
"""Check if whether this customer has the right to purchase any item.""" """Check if whether this customer has the right to purchase any item."""
subscription_end = self.user.subscriptions.aggregate( subscription = self.user.subscriptions.order_by("subscription_end").last()
res=Max("subscription_end") if subscription is None:
).get("res")
if subscription_end is None:
return False return False
return (date.today() - subscription_end) < timedelta(days=90) return (date.today() - subscription.subscription_end) < timedelta(days=90)
@classmethod @classmethod
def get_or_create(cls, user: User) -> tuple[Customer, bool]: def get_or_create(cls, user: User) -> tuple[Customer, bool]:
@@ -827,7 +823,7 @@ class Refilling(models.Model):
counter = models.ForeignKey( counter = models.ForeignKey(
Counter, related_name="refillings", blank=False, on_delete=models.CASCADE Counter, related_name="refillings", blank=False, on_delete=models.CASCADE
) )
amount: CurrencyField = CurrencyField(_("amount"), min_value=0.01) amount = CurrencyField(_("amount"))
operator = models.ForeignKey( operator = models.ForeignKey(
User, User,
related_name="refillings_as_operator", related_name="refillings_as_operator",
@@ -881,14 +877,6 @@ class Refilling(models.Model):
return False return False
return user.is_owner(self.counter) and self.payment_method != "CARD" return user.is_owner(self.counter) and self.payment_method != "CARD"
def clean(self):
super().clean()
if (self.amount + self.customer.amount) > settings.SITH_ACCOUNT_MAX_MONEY:
raise ValidationError(
_("There cannot be more than %(money)d€ on an AE account")
% {"money": settings.SITH_ACCOUNT_MAX_MONEY}
)
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
self.customer.amount -= self.amount self.customer.amount -= self.amount
self.customer.save() self.customer.save()
@@ -1117,7 +1105,7 @@ class Permanency(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
start = models.DateTimeField(_("start date")) start = models.DateTimeField(_("start date"))
end = models.DateTimeField(_("end date"), null=True, blank=True, db_index=True) end = models.DateTimeField(_("end date"), null=True, db_index=True)
activity = models.DateTimeField(_("last activity date"), auto_now=True) activity = models.DateTimeField(_("last activity date"), auto_now=True)
class Meta: class Meta:
@@ -6,7 +6,7 @@ const productParsingRegex = /^(\d+x)?(.*)/i;
const codeParsingRegex = / \((\w+)\)$/; const codeParsingRegex = / \((\w+)\)$/;
function parseProduct(query: string): [number, string] { function parseProduct(query: string): [number, string] {
const parsed = productParsingRegex.exec(query) as RegExpExecArray; const parsed = productParsingRegex.exec(query);
return [Number.parseInt(parsed[1] || "1", 10), parsed[2]]; return [Number.parseInt(parsed[1] || "1", 10), parsed[2]];
} }
@@ -3,6 +3,7 @@ import { BasketItem } from "#counter:counter/basket";
import type { import type {
CounterConfig, CounterConfig,
CounterItem, CounterItem,
ErrorMessage,
ProductFormula, ProductFormula,
} from "#counter:counter/types"; } from "#counter:counter/types";
import type { CounterProductSelect } from "./components/counter-product-select-index"; import type { CounterProductSelect } from "./components/counter-product-select-index";
@@ -23,7 +24,7 @@ document.addEventListener("alpine:init", () => {
} }
} }
this.codeField = this.$refs.codeField as CounterProductSelect; this.codeField = this.$refs.codeField;
this.codeField.widget.hook("after", "onOptionSelect", () => { this.codeField.widget.hook("after", "onOptionSelect", () => {
this.handleCode(); this.handleCode();
}); });
@@ -33,14 +34,14 @@ document.addEventListener("alpine:init", () => {
// of a formset so we dynamically apply it here // of a formset so we dynamically apply it here
this.$refs.basketManagementForm this.$refs.basketManagementForm
.querySelector("#id_form-TOTAL_FORMS") .querySelector("#id_form-TOTAL_FORMS")
?.setAttribute(":value", "getBasketSize()"); .setAttribute(":value", "getBasketSize()");
}, },
removeFromBasket(id: string) { removeFromBasket(id: string) {
delete this.basket[id]; delete this.basket[id];
}, },
addToBasket(id: string, quantity: number) { addToBasket(id: string, quantity: number): ErrorMessage {
const item: BasketItem = const item: BasketItem =
this.basket[id] || new BasketItem(config.products[id], 0); this.basket[id] || new BasketItem(config.products[id], 0);
@@ -49,7 +50,7 @@ document.addEventListener("alpine:init", () => {
if (item.quantity <= 0) { if (item.quantity <= 0) {
delete this.basket[id]; delete this.basket[id];
return; return "";
} }
this.basket[id] = item; this.basket[id] = item;
@@ -71,7 +72,7 @@ document.addEventListener("alpine:init", () => {
const products = new Set( const products = new Set(
Object.values(this.basket).map((item: BasketItem) => item.product.productId), Object.values(this.basket).map((item: BasketItem) => item.product.productId),
); );
const formula = config.formulas.find((f: ProductFormula) => { const formula: ProductFormula = config.formulas.find((f: ProductFormula) => {
return f.products.every((p: number) => products.has(p)); return f.products.every((p: number) => products.has(p));
}); });
if (formula === undefined) { if (formula === undefined) {
@@ -79,13 +80,9 @@ document.addEventListener("alpine:init", () => {
} }
// Now that the formula is found, remove the items composing it from the basket // Now that the formula is found, remove the items composing it from the basket
for (const product of formula.products) { for (const product of formula.products) {
const item = Object.entries(this.basket).find( const key = Object.entries(this.basket).find(
([_, i]: [string, BasketItem]) => i.product.productId === product, ([_, i]: [string, BasketItem]) => i.product.productId === product,
); )[0];
if (item === undefined) {
continue;
}
const key = item[0];
this.basket[key].quantity -= 1; this.basket[key].quantity -= 1;
if (this.basket[key].quantity <= 0) { if (this.basket[key].quantity <= 0) {
this.removeFromBasket(key); this.removeFromBasket(key);
@@ -95,7 +92,7 @@ document.addEventListener("alpine:init", () => {
const result = Object.values(config.products) const result = Object.values(config.products)
.filter((item: CounterItem) => item.productId === formula.result) .filter((item: CounterItem) => item.productId === formula.result)
.reduce((acc, curr) => (acc.price.amount < curr.price.amount ? acc : curr)); .reduce((acc, curr) => (acc.price.amount < curr.price.amount ? acc : curr));
this.addToBasket(result.price.id.toString(), 1); this.addToBasket(result.price.id, 1);
this.alertMessage.display( this.alertMessage.display(
interpolate( interpolate(
gettext("Formula %(formula)s applied"), gettext("Formula %(formula)s applied"),
@@ -122,18 +119,14 @@ document.addEventListener("alpine:init", () => {
}, },
onRefillingSuccess(event: CustomEvent) { onRefillingSuccess(event: CustomEvent) {
if ( if (event.type !== "htmx:after-request" || event.detail.failed) {
event.type !== "htmx:after-swap" ||
event.detail.failed ||
event.detail.elt.querySelector(".errorlist")
) {
return; return;
} }
this.customerBalance += Number.parseFloat( this.customerBalance += Number.parseFloat(
(event.detail.target.querySelector("#id_amount") as HTMLInputElement).value, (event.detail.target.querySelector("#id_amount") as HTMLInputElement).value,
); );
document.getElementById("selling-accordion")?.setAttribute("open", ""); document.getElementById("selling-accordion").setAttribute("open", "");
this.codeField?.widget.focus(); this.codeField.widget.focus();
}, },
finish() { finish() {
@@ -143,7 +136,7 @@ document.addEventListener("alpine:init", () => {
}); });
return; return;
} }
(this.$refs.basketForm as HTMLFormElement).submit(); this.$refs.basketForm.submit();
}, },
cancel() { cancel() {
@@ -151,8 +144,6 @@ document.addEventListener("alpine:init", () => {
}, },
handleCode() { handleCode() {
if (!this.codeField) throw Error("Unexpected null codeField.");
const [quantity, code] = this.codeField.getSelectedProduct() as [number, string]; const [quantity, code] = this.codeField.getSelectedProduct() as [number, string];
if (this.codeField.getOperationCodes().includes(code.toUpperCase())) { if (this.codeField.getOperationCodes().includes(code.toUpperCase())) {
@@ -176,17 +176,13 @@
</form> </form>
</div> </div>
</details> </details>
<details <details class="accordion" name="selling">
class="accordion"
name="selling"
@toggle="if ($event.newState === 'open') $el.querySelector('input[type=number]')?.focus()"
>
<summary>{% trans %}Refilling{% endtrans %}</summary> <summary>{% trans %}Refilling{% endtrans %}</summary>
{% if object.type == "BAR" %} {% if object.type == "BAR" %}
{% if refilling_fragment %} {% if refilling_fragment %}
<div <div
class="accordion-content" class="accordion-content"
@htmx:after-swap="onRefillingSuccess" @htmx:after-request="onRefillingSuccess"
> >
{{ refilling_fragment }} {{ refilling_fragment }}
</div> </div>
+4 -65
View File
@@ -144,8 +144,6 @@ class TestRefilling(TestFullClickBase):
assert self.updated_amount(self.customer) == 0 assert self.updated_amount(self.customer) == 0
def test_refilling_no_refer_fail(self): def test_refilling_no_refer_fail(self):
"""Check that the refill fails is the HTTP_REFERER header is missing"""
def refill(): def refill():
return self.client.post( return self.client.post(
reverse( reverse(
@@ -159,13 +157,13 @@ class TestRefilling(TestFullClickBase):
) )
self.client.force_login(self.club_admin) self.client.force_login(self.club_admin)
assert refill().status_code == 403 assert refill()
self.client.force_login(self.root) self.client.force_login(self.root)
assert refill().status_code == 403 assert refill()
self.client.force_login(self.subscriber) self.client.force_login(self.subscriber)
assert refill().status_code == 403 assert refill()
assert self.updated_amount(self.customer) == 0 assert self.updated_amount(self.customer) == 0
@@ -201,17 +199,6 @@ class TestRefilling(TestFullClickBase):
== 404 == 404
) )
def test_refilling_above_limit_fails(self):
"""Test that it's forbidden to refill a customer above the limit."""
self.login_in_bar()
limit = settings.SITH_ACCOUNT_MAX_MONEY
# create a refilling to check that current balance is taken into account
baker.make(Refilling, customer=self.customer.customer, amount=limit // 2)
response = self.refill_user(self.customer, self.counter, (limit // 2) + 1)
assert response.status_code == 200 # no redirect = failure
self.customer.customer.refresh_from_db()
assert self.updated_amount(self.customer) == limit // 2
def test_refilling_counter_success(self): def test_refilling_counter_success(self):
self.login_in_bar() self.login_in_bar()
@@ -535,19 +522,6 @@ class TestCounterClick(TestFullClickBase):
assert self.updated_amount(self.customer) == Decimal(10) assert self.updated_amount(self.customer) == Decimal(10)
def test_unrecord_above_limit_fails(self):
"""Test that it's forbidden to give back a recorded product
if it puts the account balance above the limit.
"""
self.login_in_bar()
limit = settings.SITH_ACCOUNT_MAX_MONEY
# put the account balance just at the limit
baker.make(Refilling, customer=self.customer.customer, amount=limit)
response = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)])
assert response.status_code == 200 # no redirect = failure
self.customer.customer.refresh_from_db()
assert self.updated_amount(self.customer) == limit
def test_annotate_has_barman_queryset(self): def test_annotate_has_barman_queryset(self):
"""Test if the custom queryset method `annotate_has_barman` works as intended.""" """Test if the custom queryset method `annotate_has_barman` works as intended."""
counters = Counter.objects.annotate_has_barman(self.barmen) counters = Counter.objects.annotate_has_barman(self.barmen)
@@ -786,10 +760,10 @@ class TestBarmanConnection(TestCase):
assert last_perm.counter == self.counter assert last_perm.counter == self.counter
assert last_perm.user == self.barman assert last_perm.user == self.barman
assert last_perm.end is None assert last_perm.end is None
assert self.barman in response.wsgi_request.barmen
response = self.client.get( response = self.client.get(
self.detail_url, {"username": self.barman.username, "password": "plop"} self.detail_url, {"username": self.barman.username, "password": "plop"}
) )
assert self.barman in response.wsgi_request.barmen
assert response.context_data.get("barmen") == [self.barman] assert response.context_data.get("barmen") == [self.barman]
soup = BeautifulSoup(response.text, "lxml") soup = BeautifulSoup(response.text, "lxml")
assert soup.find("form", id="select-user-form") is not None assert soup.find("form", id="select-user-form") is not None
@@ -830,41 +804,6 @@ class TestBarmanConnection(TestCase):
) )
self.assert_counter_login_fails(self.barman) self.assert_counter_login_fails(self.barman)
def test_barman_already_logged_in_another_device(self):
"""Test when the barman is already logged in the current counter on another device."""
other_client = Client()
other_client.post(
self.login_url, {"username": self.barman.username, "password": "plop"}
)
self.assert_counter_login_fails(self.barman)
def test_barman_login_elsewhere(self):
"""Test when the barman log himself out then log in on another device."""
self.client.post(
self.login_url, {"username": self.barman.username, "password": "plop"}
)
other_client = Client()
other_client.post(
reverse("counter:logout", kwargs={"counter_id": self.counter.id}),
data={"user_id": self.barman.id},
)
response = other_client.post(
self.login_url, {"username": self.barman.username, "password": "plop"}
)
assert response.status_code == 200
assert response.headers["HX-Redirect"] == self.detail_url
# the barmen should now be logged in `other_client`...
response = other_client.get(
self.detail_url, {"username": self.barman.username, "password": "plop"}
)
assert self.barman in response.wsgi_request.barmen
# ... but not in `self.client`
response = self.client.get(
self.detail_url, {"username": self.barman.username, "password": "plop"}
)
assert self.barman not in response.wsgi_request.barmen
def test_barman_already_logged_elsewhere(self): def test_barman_already_logged_elsewhere(self):
"""Test when the barman is already logged in another counter.""" """Test when the barman is already logged in another counter."""
other_counter = baker.make(Counter, type="BAR") other_counter = baker.make(Counter, type="BAR")
+1 -2
View File
@@ -15,7 +15,6 @@ from core.models import User
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
from counter.models import ( from counter.models import (
Counter, Counter,
CounterSellers,
Customer, Customer,
Refilling, Refilling,
ReturnableProduct, ReturnableProduct,
@@ -39,7 +38,7 @@ class TestStudentCard(TestCase):
cls.subscriber = subscriber_user.make() cls.subscriber = subscriber_user.make()
cls.counter = baker.make(Counter, type="BAR") cls.counter = baker.make(Counter, type="BAR")
CounterSellers.objects.create(counter=cls.counter, user=cls.barmen) cls.counter.sellers.add(cls.barmen)
cls.club_counter = baker.make(Counter) cls.club_counter = baker.make(Counter)
role = baker.make(ClubRole, club=cls.club_counter.club, is_board=True) role = baker.make(ClubRole, club=cls.club_counter.club, is_board=True)
+17 -22
View File
@@ -24,7 +24,7 @@ from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic import CreateView, FormView from django.views.generic import FormView
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from ninja.main import HttpRequest from ninja.main import HttpRequest
@@ -32,14 +32,7 @@ from core.auth.mixins import CanViewMixin
from core.models import User from core.models import User
from core.views.mixins import FragmentMixin, UseFragmentsMixin from core.views.mixins import FragmentMixin, UseFragmentsMixin
from counter.forms import BasketForm, RefillForm from counter.forms import BasketForm, RefillForm
from counter.models import ( from counter.models import Counter, Customer, ProductFormula, ReturnableProduct, Selling
Counter,
Customer,
ProductFormula,
Refilling,
ReturnableProduct,
Selling,
)
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterTabsMixin from counter.views.mixins import CounterTabsMixin
from counter.views.student_card import StudentCardFormFragment from counter.views.student_card import StudentCardFormFragment
@@ -73,13 +66,13 @@ class CounterClick(
current_tab = "counter" current_tab = "counter"
def get_form_kwargs(self): def get_form_kwargs(self):
return super().get_form_kwargs() | { kwargs = super().get_form_kwargs()
kwargs["form_kwargs"] = {
"customer": self.customer, "customer": self.customer,
"counter": self.object, "counter": self.object,
"form_kwargs": { "allowed_prices": {price.id: price for price in self.prices},
"allowed_prices": {price.id: price for price in self.prices}
},
} }
return kwargs
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
self.customer = get_object_or_404(Customer, user_id=self.kwargs["user_id"]) self.customer = get_object_or_404(Customer, user_id=self.kwargs["user_id"])
@@ -226,10 +219,9 @@ class CounterClick(
return kwargs return kwargs
class RefillingCreateView(FragmentMixin, CreateView): class RefillingCreateView(FragmentMixin, FormView):
"""This is a fragment only view which integrates with counter_click.jinja""" """This is a fragment only view which integrates with counter_click.jinja"""
model = Refilling
form_class = RefillForm form_class = RefillForm
template_name = "counter/fragments/create_refill.jinja" template_name = "counter/fragments/create_refill.jinja"
@@ -250,20 +242,23 @@ class RefillingCreateView(FragmentMixin, CreateView):
): ):
raise PermissionDenied raise PermissionDenied
self.operator = get_operator(request, self.counter, self.customer)
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def render_fragment(self, request, **kwargs) -> SafeString: def render_fragment(self, request, **kwargs) -> SafeString:
self.customer = kwargs.pop("customer") self.customer = kwargs.pop("customer")
self.counter = kwargs.pop("counter") self.counter = kwargs.pop("counter")
self.object = None
return super().render_fragment(request, **kwargs) return super().render_fragment(request, **kwargs)
def get_form_kwargs(self): def form_valid(self, form):
return super().get_form_kwargs() | { res = super().form_valid(form)
"counter": self.counter, form.clean()
"operator": get_operator(self.request, self.counter, self.customer), form.instance.counter = self.counter
"customer": self.customer, form.instance.operator = self.operator
} form.instance.customer = self.customer
form.instance.save()
return res
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
+2 -3
View File
@@ -30,7 +30,6 @@ from django.views.generic.edit import FormView
from core.auth.mixins import CanViewMixin from core.auth.mixins import CanViewMixin
from core.views import FragmentMixin, UseFragmentsMixin from core.views import FragmentMixin, UseFragmentsMixin
from counter.forms import CounterLoginForm, GetUserForm from counter.forms import CounterLoginForm, GetUserForm
from counter.middleware import SESSION_PERMANENCES_KEY
from counter.models import Counter, Permanency from counter.models import Counter, Permanency
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterTabsMixin from counter.views.mixins import CounterTabsMixin
@@ -59,8 +58,8 @@ class CounterLoginFragment(FragmentMixin, SingleObjectMixin, FormView):
def form_valid(self, form: CounterLoginForm): def form_valid(self, form: CounterLoginForm):
user = form.get_user() user = form.get_user()
perm = self.object.permanencies.create(user=user, start=timezone.now()) self.object.permanencies.create(user=user, start=timezone.now())
self.request.session.setdefault(SESSION_PERMANENCES_KEY, []).append(perm.id) self.request.barmen.add(user)
self.success_url = reverse( self.success_url = reverse(
"counter:details", kwargs={"counter_id": self.object.id} "counter:details", kwargs={"counter_id": self.object.id}
) )
@@ -5,11 +5,10 @@ interface BasketItem {
name: string; name: string;
quantity: number; quantity: number;
unitPrice: number; unitPrice: number;
isRefill: boolean;
} }
const BASKET_CACHE_KEY = "basket"; const BASKET_CACHE_KEY = "basket";
const BASKET_CACHE_VERSION = 2; const BASKET_CACHE_VERSION = 1;
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("basket", (validPrices: number[], lastPurchaseTime?: number) => ({ Alpine.data("basket", (validPrices: number[], lastPurchaseTime?: number) => ({
@@ -22,7 +21,7 @@ document.addEventListener("alpine:init", () => {
}); });
document document
.getElementById("id_form-TOTAL_FORMS") .getElementById("id_form-TOTAL_FORMS")
?.setAttribute(":value", "basket.length"); .setAttribute(":value", "basket.length");
}, },
loadBasket(): BasketItem[] { loadBasket(): BasketItem[] {
@@ -33,8 +32,8 @@ document.addEventListener("alpine:init", () => {
return []; return [];
} }
if ( if (
lastPurchaseTime && lastPurchaseTime !== null &&
localStorage.basketTimestamp && localStorage.basketTimestamp !== undefined &&
new Date(lastPurchaseTime) >= new Date(lastPurchaseTime) >=
new Date(Number.parseInt(localStorage.basketTimestamp, 10)) new Date(Number.parseInt(localStorage.basketTimestamp, 10))
) { ) {
@@ -65,19 +64,6 @@ document.addEventListener("alpine:init", () => {
); );
}, },
/**
* Get the total of money that would be added to the AE account on basket purchase.
*/
getTotalAdded() {
return this.basket
.filter((item) => item.isRefill || item.unitPrice < 0)
.reduce(
(acc: number, item: BasketItem) =>
acc + Math.abs(item.quantity * item.unitPrice),
0,
);
},
/** /**
* Add 1 to the quantity of an item in the basket * Add 1 to the quantity of an item in the basket
* @param {BasketItem} item * @param {BasketItem} item
@@ -100,7 +86,7 @@ document.addEventListener("alpine:init", () => {
if (this.basket[index].quantity === 0) { if (this.basket[index].quantity === 0) {
this.basket = this.basket.filter( this.basket = this.basket.filter(
(e: BasketItem) => e.priceId !== this.basket[index].priceId, (e: BasketItem) => e.priceId !== this.basket[index].id,
); );
} }
}, },
@@ -117,16 +103,14 @@ document.addEventListener("alpine:init", () => {
* @param id The id of the product to add * @param id The id of the product to add
* @param name The name of the product * @param name The name of the product
* @param price The unit price of the product * @param price The unit price of the product
* @param isRefill true if the product is a refill bond
* @returns The created item * @returns The created item
*/ */
createItem(id: number, name: string, price: number, isRefill: boolean): BasketItem { createItem(id: number, name: string, price: number): BasketItem {
const newItem = { const newItem = {
priceId: id, priceId: id,
name, name,
quantity: 0, quantity: 0,
unitPrice: price, unitPrice: price,
isRefill,
} as BasketItem; } as BasketItem;
this.basket.push(newItem); this.basket.push(newItem);
@@ -141,17 +125,16 @@ document.addEventListener("alpine:init", () => {
* @param id The id of the product to add * @param id The id of the product to add
* @param name The name of the product * @param name The name of the product
* @param price The unit price of the product * @param price The unit price of the product
* @param isRefill true if the product is a refill bond
*/ */
addFromCatalog(id: number, name: string, price: number, isRefill: boolean) { addFromCatalog(id: number, name: string, price: number) {
const item = this.basket.find((e: BasketItem) => e.priceId === id); let item = this.basket.find((e: BasketItem) => e.priceId === id);
// if the item is not in the basket, we create it // if the item is not in the basket, we create it
// else we add + 1 to it // else we add + 1 to it
if (item) { if (item) {
this.add(item); this.add(item);
} else { } else {
this.createItem(id, name, price, isRefill); item = this.createItem(id, name, price);
} }
}, },
})); }));
+3 -22
View File
@@ -58,17 +58,6 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<template x-if="(getTotalAdded() + {{ customer_amount }}) > {{ settings.SITH_ACCOUNT_MAX_MONEY }}">
<div class="alert alert-red">
<div class="alert-main">
{% trans trimmed limit=settings.SITH_ACCOUNT_MAX_MONEY %}
You cannot purchase the current basket,
because it would put your AE account balance
above the {{ limit }}€ limit
{% endtrans %}
</div>
</div>
</template>
<ul class="item-list"> <ul class="item-list">
{# Starting money #} {# Starting money #}
<li> <li>
@@ -120,12 +109,9 @@
<i class="fa fa-trash"></i> <i class="fa fa-trash"></i>
{% trans %}Clear{% endtrans %} {% trans %}Clear{% endtrans %}
</button> </button>
<button <button class="btn btn-blue">
class="btn btn-blue"
:disabled="(getTotalAdded() + {{ customer_amount }}) > {{ settings.SITH_ACCOUNT_MAX_MONEY }}"
>
<i class="fa fa-check"></i> <i class="fa fa-check"></i>
{% trans %}Validate{% endtrans %} <input type="submit" value="{% trans %}Validate{% endtrans %}"/>
</button> </button>
</div> </div>
</form> </form>
@@ -213,12 +199,7 @@
id="{{ price.id }}" id="{{ price.id }}"
class="card clickable shadow" class="card clickable shadow"
:class="{selected: basket.some((i) => i.priceId === {{ price.id }})}" :class="{selected: basket.some((i) => i.priceId === {{ price.id }})}"
@click='addFromCatalog( @click='addFromCatalog({{ price.id }}, {{ price.full_label|tojson }}, {{ price.amount }})'
{{ price.id }},
{{ price.full_label|tojson }},
{{ price.amount }},
{{ (price.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING)|lower }}
)'
{% if price.sold_out %}disabled{% endif %} {% if price.sold_out %}disabled{% endif %}
> >
{% if price.product.icon %} {% if price.product.icon %}
-21
View File
@@ -278,27 +278,6 @@ class TestEboutic(TestCase):
) )
assert Basket.objects.count() == 2 assert Basket.objects.count() == 2
def test_refill_limit(self):
"""Test that an eboutic basket cannot refill an account above the limit."""
self.client.force_login(self.subscriber)
product = product_recipe.make(
product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING,
counters=[self.eboutic],
)
price = price_recipe.make(
product=product,
groups=[self.group_cotiz],
amount=settings.SITH_ACCOUNT_MAX_MONEY // 10,
)
response = self.submit_basket([BasketItem(price.id, 10)])
assert Basket.objects.count() == 1
assertRedirects(response, reverse("eboutic:checkout", kwargs={"basket_id": 1}))
response = self.submit_basket([BasketItem(price.id, 11)])
assert Basket.objects.count() == 1
assert response.status_code == 200 # no redirect = form validation failed
def test_create_basket(self): def test_create_basket(self):
self.client.force_login(self.new_customer) self.client.force_login(self.new_customer)
assertRedirects( assertRedirects(
+6 -4
View File
@@ -66,7 +66,9 @@ if TYPE_CHECKING:
class BaseEbouticBasketForm(BaseBasketForm): class BaseEbouticBasketForm(BaseBasketForm):
min_result_balance = None # user can pay by card, so no minimum enforced def _check_enough_money(self, *args, **kwargs):
# Disable money check
...
EbouticBasketForm = forms.formset_factory( EbouticBasketForm = forms.formset_factory(
@@ -86,15 +88,15 @@ class EbouticMainView(LoginRequiredMixin, FormView):
form_class = EbouticBasketForm form_class = EbouticBasketForm
def get_form_kwargs(self): def get_form_kwargs(self):
return super().get_form_kwargs() | { kwargs = super().get_form_kwargs()
kwargs["form_kwargs"] = {
"customer": self.customer, "customer": self.customer,
"counter": get_eboutic(), "counter": get_eboutic(),
"form_kwargs": {
"allowed_prices": { "allowed_prices": {
price.id: price for price in self.prices if not price.sold_out price.id: price for price in self.prices if not price.sold_out
}
}, },
} }
return kwargs
def form_valid(self, formset): def form_valid(self, formset):
if len(formset) == 0: if len(formset) == 0:
+1 -19
View File
@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-06-10 20:18+0200\n" "POT-Creation-Date: 2026-06-05 13:39+0200\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -3217,10 +3217,6 @@ msgstr "Vous êtes déjà connecté à ce comptoir."
msgid "You are already logged in another counter." msgid "You are already logged in another counter."
msgstr "Vous êtes déjà connecté à un autre comptoir." msgstr "Vous êtes déjà connecté à un autre comptoir."
#: counter/forms.py
msgid "You are already logged on another device"
msgstr "Vous êtes déjà connecté sur un autre appareil"
#: counter/forms.py #: counter/forms.py
msgid "Regular barmen" msgid "Regular barmen"
msgstr "Barmen réguliers" msgstr "Barmen réguliers"
@@ -3310,11 +3306,6 @@ msgstr "Saisie de produit dupliquée"
msgid "Not enough money" msgid "Not enough money"
msgstr "Solde insuffisant" msgstr "Solde insuffisant"
#: counter/forms.py counter/models.py
#, python-format
msgid "There cannot be more than %(money)d€ on an AE account"
msgstr "Il ne peut pas y avoir plus de %(money)d€ sur un compte AE"
#: counter/forms.py #: counter/forms.py
#, python-format #, python-format
msgid "" msgid ""
@@ -4437,15 +4428,6 @@ msgstr "Payer avec un compte AE"
msgid "The online shop of the association." msgid "The online shop of the association."
msgstr "La boutique en ligne de l'association." msgstr "La boutique en ligne de l'association."
#: eboutic/templates/eboutic/eboutic_main.jinja
#, python-format
msgid ""
"You cannot purchase the current basket, because it would put your AE account "
"balance above the %(limit)s€ limit"
msgstr ""
"Vous ne pouvez pas finaliser le panier actuel, parce que le solde de votre "
"compte AE passerait au-dessus de la limite de %(limit)s€."
#: eboutic/templates/eboutic/eboutic_main.jinja #: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Clear" msgid "Clear"
msgstr "Vider" msgstr "Vider"
+2 -2
View File
@@ -23,7 +23,7 @@ dependencies = [
"django-ninja>=1.6.2,<2.0.0", "django-ninja>=1.6.2,<2.0.0",
"django-ninja-extra>=0.31.4", "django-ninja-extra>=0.31.4",
"Pillow>=12.2.0,<13.0.0", "Pillow>=12.2.0,<13.0.0",
"aemark>=0.1.1", "mistune>=3.2.1,<4.0.0",
"django-jinja<3.0.0,>=2.11.0", "django-jinja<3.0.0,>=2.11.0",
"cryptography>=48.0.0,<49.0.0", "cryptography>=48.0.0,<49.0.0",
"django-phonenumber-field>=8.4.0,<9.0.0", "django-phonenumber-field>=8.4.0,<9.0.0",
@@ -45,7 +45,7 @@ dependencies = [
"pydantic-extra-types>=2.11.1,<3.0.0", "pydantic-extra-types>=2.11.1,<3.0.0",
"ical>=12.0.0,<14.0.0", "ical>=12.0.0,<14.0.0",
"redis[hiredis]>=3.4.0,<8.0.0", "redis[hiredis]>=3.4.0,<8.0.0",
"environs[django]>=6.0.5,<16", "environs[django]>=15.0.1,<16",
"requests>=2.34.2,<3.0.0", "requests>=2.34.2,<3.0.0",
"honcho>=2.0.0", "honcho>=2.0.0",
"psutil>=7.2.2,<8.0.0", "psutil>=7.2.2,<8.0.0",
-6
View File
@@ -503,12 +503,6 @@ SITH_ACCOUNT_INACTIVITY_DELTA = relativedelta(years=2)
SITH_ACCOUNT_DUMP_DELTA = timedelta(days=30) SITH_ACCOUNT_DUMP_DELTA = timedelta(days=30)
"""timedelta between the warning mail and the actual account dump""" """timedelta between the warning mail and the actual account dump"""
SITH_ACCOUNT_MAX_MONEY = 250 # €
"""Maximum amount of money a sith account can hold.
This amount is defined by the AE's Terms and Conditions of Sale.
"""
# Defines which product type is the refilling type, # Defines which product type is the refilling type,
# and thus increases the account amount # and thus increases the account amount
SITH_COUNTER_PRODUCTTYPE_REFILLING = env.int( SITH_COUNTER_PRODUCTTYPE_REFILLING = env.int(
Generated
+11 -67
View File
@@ -6,71 +6,6 @@ resolution-markers = [
"python_full_version < '3.13'", "python_full_version < '3.13'",
] ]
[[package]]
name = "aemark"
version = "0.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/11/97/35c298a8557d58f10402f40cc04d8bccba8dd2ab5c89bbbb41ce82f9ae29/aemark-0.1.1.tar.gz", hash = "sha256:01c19374553a1fc4ec5b5b0609a28dea8e724395c4383e2c41e292ee9bf1b9b2", size = 32558, upload-time = "2026-06-19T10:10:17.016Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/0c/b35426c7d1d52b6089d8d4fa8b50e1ca965475a42f000e4855468ea1889f/aemark-0.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:d79173c2c5311eea1f5043c24bfb7368ece6df1d041a3cbd51bbc54a3ca8f630", size = 467866, upload-time = "2026-06-19T10:09:09.99Z" },
{ url = "https://files.pythonhosted.org/packages/ec/b3/8f85ca830a2d3aae2f37383b3eab8d382e438f8c1f764517eaf10d1fd026/aemark-0.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3d3facf829907cadabf2154a86c562fa05b8528f418f933d4517f8b6192bab0f", size = 455182, upload-time = "2026-06-19T10:09:11.172Z" },
{ url = "https://files.pythonhosted.org/packages/76/f2/9ff2fe99851e7c7c30da7d7791f3a21868f046b5f2bd326dd52e80d65aca/aemark-0.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dbb356e5c208a8f0f4b725117a8075c6fc54911fc62fc7f974934402faa0bab7", size = 544038, upload-time = "2026-06-19T10:09:12.308Z" },
{ url = "https://files.pythonhosted.org/packages/ae/09/354c635e6f1bcc525ed8923f5f0ed7b7a95c12a5fa211ed4d827c16de41d/aemark-0.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76615cd0fa97dd020aac3748c8fb9dda585d7eea0e151361f872628a1e70ee5a", size = 494693, upload-time = "2026-06-19T10:09:13.422Z" },
{ url = "https://files.pythonhosted.org/packages/a3/fb/cba45091950f393d909aa4df8df903e795c7b9ba64c208c03aa949b41f4f/aemark-0.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7d79a1246166babf5266abd45c39cc584f30cef2a0b6f4cb38384ee63f559c62", size = 597809, upload-time = "2026-06-19T10:09:14.617Z" },
{ url = "https://files.pythonhosted.org/packages/53/06/9e403d55750f0c8797379a02bfc39931ccbb97622105b50e84cd6d9e863e/aemark-0.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:802234cbfd60ccd985ce4d6320217f3280319579590a6dd22383d1481130ad00", size = 654480, upload-time = "2026-06-19T10:09:16.078Z" },
{ url = "https://files.pythonhosted.org/packages/da/97/9feb3169899d444578f39d1caeab3d92b7e8d629d6a24d44bbda929bc4d0/aemark-0.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b957e390ecd3d9ab22be61bf95214d33673fb2ed1a09617e1b443a8bba1a8cbc", size = 516859, upload-time = "2026-06-19T10:09:17.335Z" },
{ url = "https://files.pythonhosted.org/packages/b8/b4/f842b159b46ce4fd4271afaa9107517d5f69574c8ff057f9bd65f0b5805b/aemark-0.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6947de45713b228190a7bddb52895a5c0b402ff00ff18acb967de7dceded1fa2", size = 518199, upload-time = "2026-06-19T10:09:18.327Z" },
{ url = "https://files.pythonhosted.org/packages/01/4f/1e1c1d68a4dbe9aea6b13e7e33bbf3ddd229a86a1fc75b7c2c71d48eb06a/aemark-0.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b633b0a171716424a84b44841c2f43f799da3fe04e3bd2ac356e587f333d98d", size = 720953, upload-time = "2026-06-19T10:09:19.527Z" },
{ url = "https://files.pythonhosted.org/packages/f5/e8/9b20d96bff485a0e5dabc610d1b7a47373d161b53f82d260b27ca2104b63/aemark-0.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:abd49b2cf5af61cf2b8c2a6e45a87df055b003a45fd10a94a6022857af15a115", size = 771507, upload-time = "2026-06-19T10:09:20.742Z" },
{ url = "https://files.pythonhosted.org/packages/ce/8d/dd924c4bf790670a198b64b8d99e0d8b3f8a3462d96b241a759edb2bdfa5/aemark-0.1.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0d48f411e544724cb4c5b5932369785dc1c97d32e9f04cf664607f1e67a33874", size = 737232, upload-time = "2026-06-19T10:09:21.797Z" },
{ url = "https://files.pythonhosted.org/packages/15/3a/587f3b177ff1af11d91f5c5ba01acfe9ad4875ab7c72d73b4c2a3b090afe/aemark-0.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:66727dc7da3da45ae1c0405d9069d0edd6e5c48fce696ea5e327dce46fbcdb3a", size = 769368, upload-time = "2026-06-19T10:09:23.026Z" },
{ url = "https://files.pythonhosted.org/packages/d6/cb/9ac41a31e16c66f33bf9366004baeabeab04b0d8f9e2e6867447d6946c74/aemark-0.1.1-cp312-cp312-win32.whl", hash = "sha256:bc4f9e31f97454b70cb0b5b7de25252a59a0e92faef886eda74eb1f65f8c973d", size = 382527, upload-time = "2026-06-19T10:09:24.273Z" },
{ url = "https://files.pythonhosted.org/packages/01/bc/c629780bc72973cc1cc3a4ca91720daa8edf37bbc2a2c29ceeea797375ca/aemark-0.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:332b51d6ff927a182d16d4da40930d860abe4a99d99d3219816a5442f7ebecf7", size = 394288, upload-time = "2026-06-19T10:09:25.479Z" },
{ url = "https://files.pythonhosted.org/packages/81/9b/6f6238f0d3c09e1472294b24c3938b97db27ff0952c642b7d3ef12ea4f9d/aemark-0.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:28919d11340c54e541fe31fbadd297686c670054bb93e02c5ecd0a5225ce8f8f", size = 382863, upload-time = "2026-06-19T10:09:26.457Z" },
{ url = "https://files.pythonhosted.org/packages/af/1f/bebf99bd5eda377347923448115007389952f29e15895208527af00921ef/aemark-0.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:93a69b7e3be6b00ffcd38be8970c9fe6d99a57844da43fef5205fa834d99fe66", size = 468060, upload-time = "2026-06-19T10:09:27.404Z" },
{ url = "https://files.pythonhosted.org/packages/05/c8/24b15087d37128bd7bacada063560d269f6c86ebec58df61ec95b27d78b4/aemark-0.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bb9bf8095b4965a1a8566ca5ea2795fc96f035e36bfd9e472bd2ed3718f01dc3", size = 455308, upload-time = "2026-06-19T10:09:28.466Z" },
{ url = "https://files.pythonhosted.org/packages/28/33/b1b3e8e2f667f6d6e38112bdf65f32fa19f9d7b8544feb735aba124c1a10/aemark-0.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d4d1ab6c2bd01a1ec9018c60a2c35ddc1a3d3e37dbba459cb92087f302eb4f7", size = 544214, upload-time = "2026-06-19T10:09:29.64Z" },
{ url = "https://files.pythonhosted.org/packages/27/e7/abcfc081d5562d004bc953b8986f25cf500df625ce9874223659bc36b11e/aemark-0.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d296b232d52e795a436b1af211d700903eb6d562567a03d0c79db88188b9936", size = 494722, upload-time = "2026-06-19T10:09:30.974Z" },
{ url = "https://files.pythonhosted.org/packages/bb/66/0d00cff512be2e06df7f25e62830644846738db2057253f59affbc572f67/aemark-0.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aaa6d0b261396b615d45b4ea0b6419625d113cae2d5d0a8e52c08f70494eb943", size = 598196, upload-time = "2026-06-19T10:09:32.044Z" },
{ url = "https://files.pythonhosted.org/packages/dd/ae/feacddee57fc9c57bfd9d4ec86f6c6626af3cb4a07220eb527404fc8dcda/aemark-0.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4bee927e236005b414dcc274d26be867f02a8de96dcd462a0d9c6944c367fbf0", size = 654718, upload-time = "2026-06-19T10:09:33.073Z" },
{ url = "https://files.pythonhosted.org/packages/7e/da/00b2bc275c4916a148581dad67914a114554a292964b30ce7c5091251acb/aemark-0.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d39be7d7a5658cebde6e89b3723f82946937589af344e200afa9778e3060e1d", size = 516971, upload-time = "2026-06-19T10:09:34.143Z" },
{ url = "https://files.pythonhosted.org/packages/e3/7b/c21ae7ee4d3b3f6992f4767953e58db1bd0485fcf7f43fdba47f723d3ebd/aemark-0.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:109315067b14a20e83fde0a5178098731b4f5cd86cd7905f827458946fad1246", size = 518272, upload-time = "2026-06-19T10:09:35.187Z" },
{ url = "https://files.pythonhosted.org/packages/69/f8/977f20abc0955b927e60c2b457f3879e3bcde7502054c9b063436e4092b5/aemark-0.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:512eda328fc5494e76af82b59f270214cc50f171c3e08800e80067e9b345038c", size = 721198, upload-time = "2026-06-19T10:09:36.227Z" },
{ url = "https://files.pythonhosted.org/packages/9a/9e/7ee511429869911a8633d6d172cad26030579c43723c2e610453af1ef06d/aemark-0.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2a644c5630de531fc75a5d63b056a95aedbc6ef4e7883cc29327ac951d535faf", size = 771590, upload-time = "2026-06-19T10:09:37.661Z" },
{ url = "https://files.pythonhosted.org/packages/6e/90/8b76cfff288bf7aed4173298e453f2add446c8d18abefa3f3abc72924e7f/aemark-0.1.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c4122ccfba4d5d3a3cb42e6697051543a26ba7128ab7cd4eb6ed3fe5131d8a22", size = 737238, upload-time = "2026-06-19T10:09:38.861Z" },
{ url = "https://files.pythonhosted.org/packages/93/d9/3c06cccc62e2aee921e635a00d595f8550b2c0270472779116a6a9be55ff/aemark-0.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1d5f5e22603f97ba2b643426527cb3c19d79463b0bb64fbe03ac2d9e57dc993a", size = 769243, upload-time = "2026-06-19T10:09:40.046Z" },
{ url = "https://files.pythonhosted.org/packages/64/2e/8579bf4f4abb1a54195f5f619d4d7d5cd34fe66c7b6ee587d4b5b7c24725/aemark-0.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:e37b06a2cfdab748f280dfc229e37c4c3a82c98b4f49d0d02893a0785c1a8796", size = 394367, upload-time = "2026-06-19T10:09:41.257Z" },
{ url = "https://files.pythonhosted.org/packages/0f/08/0678635f86109fffdb847f899b4409e6b99fbf967472efdeff3c57e1674c/aemark-0.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:2993588ec27439cc5adc7917851f9d04ee3c95283f1290fe78379d90fbc25247", size = 383114, upload-time = "2026-06-19T10:09:42.474Z" },
{ url = "https://files.pythonhosted.org/packages/38/94/b34c102620515afef1a088ae9a82d4abe332ac3075be375d0be70edc95d5/aemark-0.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d0e69cd9da806d2b94ec016544d97a325fcb22477b60c9bf1236521b6978bea1", size = 468275, upload-time = "2026-06-19T10:09:43.686Z" },
{ url = "https://files.pythonhosted.org/packages/45/45/c879bb7b354a4e47b8c0b6b20ac95606ccf75512e81c6327487c7a7c2d7a/aemark-0.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20b902d032885f765ce762fa25dbd59fee9f8aa7b827ecc9f16c58496e01e723", size = 455415, upload-time = "2026-06-19T10:09:44.885Z" },
{ url = "https://files.pythonhosted.org/packages/ce/12/b7bc6d238c0a1c22095d6aa960de2a4c53c73ceea725cca0a007ac75c291/aemark-0.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304667c3a0baa3f7b1cb1170e48cd35001c9aee177af2bf80680f614dd471aeb", size = 543093, upload-time = "2026-06-19T10:09:46.223Z" },
{ url = "https://files.pythonhosted.org/packages/f2/fa/09cdf04f9859141a149661d709f4d35bc330a98926513f0002c400dff2da/aemark-0.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cd161530d9b80640a55fbcfe16289c6e8cb615ae1046ade11e112d3bfa182036", size = 495041, upload-time = "2026-06-19T10:09:47.252Z" },
{ url = "https://files.pythonhosted.org/packages/5a/74/7ecbd5a4f5120cbb90ea6e2837a36bfc3cf943a6e17472d36ca91b33c518/aemark-0.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea4b3c171bf1d9feaef1ff12321bf94f9e3eb7db38be8f66b408f1d0959f8b2", size = 597685, upload-time = "2026-06-19T10:09:48.348Z" },
{ url = "https://files.pythonhosted.org/packages/29/b4/171ceb88b503fff6740e46c42d1d17f7db861b7e1e56901f74e11a02fb11/aemark-0.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd8503d1bc5199e1484311b6d65ee380fd22aeb8ec367651c0e39c4d9f27c87f", size = 654372, upload-time = "2026-06-19T10:09:49.428Z" },
{ url = "https://files.pythonhosted.org/packages/3d/2b/ceed2a269503af5912932a233218c184ba464ec0d4b1a389eccdd760e760/aemark-0.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3aba427b898c2430bacfe4ded62bef90bb5353b452a1e9d0add6179ca851df4", size = 515791, upload-time = "2026-06-19T10:09:50.546Z" },
{ url = "https://files.pythonhosted.org/packages/3e/38/12f9b5107e9afcab320a84d9d46300cad5bfaf2e90a6a3d9cea248adbcdb/aemark-0.1.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5d568d8e99ac0730ab5030cf34987eb22e7b9d50e44964233c161e756f527e6c", size = 519423, upload-time = "2026-06-19T10:09:51.634Z" },
{ url = "https://files.pythonhosted.org/packages/b0/2d/f8d4fae48934362be4ebcb79b6682d991e823ee8e4de6ef25532307c0d9f/aemark-0.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34530bd2b4e59f6a5e0c4fd715ab34496b0bb5d00b4288b3ee93db43c88a810f", size = 720464, upload-time = "2026-06-19T10:09:52.849Z" },
{ url = "https://files.pythonhosted.org/packages/27/2d/db1dc2c2024ac6be28f312aeefb5d1b15b85b2ec329f9cac4bf334b1f358/aemark-0.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:faa4b36ad06ebd5f52f49b96ae440330e4220c5343dc69e7b1b92ff30c612089", size = 772108, upload-time = "2026-06-19T10:09:54.157Z" },
{ url = "https://files.pythonhosted.org/packages/d9/87/3f2e6b63e3f3b5df70a63bc1d43e213b7b02937447ab6fc94c4ff730a817/aemark-0.1.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fffc0c89abde3dbdf506131e1b1416e499e4543031b558d82cc2c0cd0de450ac", size = 737662, upload-time = "2026-06-19T10:09:55.545Z" },
{ url = "https://files.pythonhosted.org/packages/05/17/0e033216483a8ae5d0dde9ae8c0f78b464b62aea70aec9ee5a171aef5e68/aemark-0.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d54c91b7fc7df65553069ea8db6ec8c70c5685735130b177de95b6f436bca81", size = 768312, upload-time = "2026-06-19T10:09:56.803Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/82fe9178afe8aea4c172a8563694afe3519ea29e04d9ca4ccdf3fabdbc17/aemark-0.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:78c706e546fe8899e7a507820b9d63f8938a64158bbd0d8d344f86daaf15a4d8", size = 391756, upload-time = "2026-06-19T10:09:57.916Z" },
{ url = "https://files.pythonhosted.org/packages/21/ca/9eabfb99fddb3a9266336674120fd8df1ae58d5651f584cabd86a806cba6/aemark-0.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:35399ce8ce8e15e8d2d8a8d007c9b8dfc2a92a923493429e8657b046bcafb366", size = 381522, upload-time = "2026-06-19T10:09:59.082Z" },
{ url = "https://files.pythonhosted.org/packages/fa/be/dbfad8d80a2f037b769166453b73606e8d6aa672de076717a50fad6a8f22/aemark-0.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:581fd7dbf57e37e006f23d45e7e401f32c83907a4bb6dee845cab0f586cf9e79", size = 542373, upload-time = "2026-06-19T10:10:00.197Z" },
{ url = "https://files.pythonhosted.org/packages/8f/0c/cffd4911980af1391057fd1e12473fc915c3b51c5c543253e6eb325755a3/aemark-0.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a804d3ffae6bb6d4673e67536f96df4cf0cd48795ffa75b6d9bbaa469d11382d", size = 494187, upload-time = "2026-06-19T10:10:01.321Z" },
{ url = "https://files.pythonhosted.org/packages/75/54/620599105646977be2d3766964a24aa45a35dc5aff4838f6edb3a948aea7/aemark-0.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:67dcf05f6f9b0ffb73d3f7baeb4ada87d6b5dc26ec56aca3527abfa7916f05df", size = 596882, upload-time = "2026-06-19T10:10:02.384Z" },
{ url = "https://files.pythonhosted.org/packages/f2/d8/d0a192801ef68eac915e9157006f4f53a10ed7c0b0b46ac9e372218df9b1/aemark-0.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27a61f80c10ee12ca33c4c3e07304f4b84cdb8245ea274b558a970ceb6767817", size = 652553, upload-time = "2026-06-19T10:10:03.668Z" },
{ url = "https://files.pythonhosted.org/packages/37/fa/de4969f930ed199d3e395fbff1821a50928ca47567b161b9f21c5eaabcfb/aemark-0.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba20a42edeb94c99c693e54d80c4c4e68f33e53c3e68422499bf3dca6429254a", size = 514859, upload-time = "2026-06-19T10:10:04.8Z" },
{ url = "https://files.pythonhosted.org/packages/49/ad/e8db07f9d0a1a3c314e654ab852a1f95074f0aa2c8922a48fa8dc54ce453/aemark-0.1.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:201208eb9feb93ed1e7ec1217395ed5292ab80b5b903fbfe71cedd636bb87eea", size = 517850, upload-time = "2026-06-19T10:10:06.047Z" },
{ url = "https://files.pythonhosted.org/packages/72/58/8273730c1e1b3bc622bce10d23dc5e1a6309e48de47c13514a4b5fd3d67b/aemark-0.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:813b76ae8668415a58cdf7c78eff2fe0fa1a26725257adf123d715d6ff4a92b1", size = 719901, upload-time = "2026-06-19T10:10:07.423Z" },
{ url = "https://files.pythonhosted.org/packages/dc/1f/2d6f5192fd52ced126af5851c7919b574cce36827195a2eb7c6fad38a54d/aemark-0.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:14fec671ca08a4ef55305bfaf267a1671629735f2b281658adf8622280f641e2", size = 770928, upload-time = "2026-06-19T10:10:08.731Z" },
{ url = "https://files.pythonhosted.org/packages/49/2f/c0fb323da8d12b18d020949aa15636c97b7ac2cd71ab05b51ad0fbc70af1/aemark-0.1.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:24d4c41d58afbda86332f6c1c6cb6ceccfb087428aaa834ad8c95c55920c017c", size = 737035, upload-time = "2026-06-19T10:10:10.016Z" },
{ url = "https://files.pythonhosted.org/packages/93/2f/d17b3f892753a6c61f30ff97ba23d32d07bfa758feeddfac9d256d86246e/aemark-0.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3d4a21593fbf6ce2eb44ed746fc9d98ee2a1c21b5e7d2956c9ffed0a51fa8dca", size = 767584, upload-time = "2026-06-19T10:10:11.21Z" },
{ url = "https://files.pythonhosted.org/packages/ec/20/7dd827e7f6548d40737ebe90440f50c0229ea4c4a34f6704573d990877cb/aemark-0.1.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8ef394c8c73956adcd978bf0193cd31fc77bed3f35711c6b222fd2baa870737", size = 515792, upload-time = "2026-06-19T10:10:12.478Z" },
{ url = "https://files.pythonhosted.org/packages/ad/49/c93aef2c5e514ecdb4025668ed6fb2e3ae1d274d00e379e2cf8840f8b04c/aemark-0.1.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64cc5b3ec2d5b2f95fe9fc2b2b4d50fae55f2aa0c100f809692cd62265ea6007", size = 519595, upload-time = "2026-06-19T10:10:13.517Z" },
{ url = "https://files.pythonhosted.org/packages/42/24/a368d11633b6c9a6f74fb14231d638c94ab5704cee83f8cbf10290ff4822/aemark-0.1.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e4911414cc18d46f3409cb4e138254844a168ae38f86541ebf30d3478df3ceb", size = 514888, upload-time = "2026-06-19T10:10:14.547Z" },
{ url = "https://files.pythonhosted.org/packages/89/71/a1112c305a70e59669679a60930104a5a67732264626b906dae8609d89b9/aemark-0.1.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9d39cd7d7772b3c122d346a768542e85116795a1a1886bcf47d0074d636d9eb0", size = 518017, upload-time = "2026-06-19T10:10:15.941Z" },
]
[[package]] [[package]]
name = "alabaster" name = "alabaster"
version = "1.0.0" version = "1.0.0"
@@ -1334,6 +1269,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" },
] ]
[[package]]
name = "mistune"
version = "3.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/84/620cc3f7e3adf6f5067e10f4dbae71295d8f9e16d5d3f9ef97c40f2f592c/mistune-3.2.1.tar.gz", hash = "sha256:7c8e5501d38bac1582e067e46c8343f17d57ea1aaa735823f3aba1fd59c88a28", size = 98003, upload-time = "2026-05-03T14:33:22.312Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/7f/a946aa4f8752b37102b41e64dca18a1976ac705c3a0d1dfe74d820a02552/mistune-3.2.1-py3-none-any.whl", hash = "sha256:78cdb0ba5e938053ccf63651b352508d2efa9411dc8810bfb05f2dc5140c0048", size = 53749, upload-time = "2026-05-03T14:33:20.551Z" },
]
[[package]] [[package]]
name = "mkdocs" name = "mkdocs"
version = "1.6.1" version = "1.6.1"
@@ -2133,7 +2077,6 @@ name = "sith"
version = "3" version = "3"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aemark" },
{ name = "celery", extra = ["redis"] }, { name = "celery", extra = ["redis"] },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "dict2xml" }, { name = "dict2xml" },
@@ -2155,6 +2098,7 @@ dependencies = [
{ name = "ical", version = "13.2.5", 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 = "jinja2" },
{ name = "libsass" }, { name = "libsass" },
{ name = "mistune" },
{ name = "phonenumbers" }, { name = "phonenumbers" },
{ name = "pillow" }, { name = "pillow" },
{ name = "psutil" }, { name = "psutil" },
@@ -2201,7 +2145,6 @@ tests = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aemark", specifier = ">=0.1.1" },
{ name = "celery", extras = ["redis"], specifier = ">=5.6.3,<8" }, { name = "celery", extras = ["redis"], specifier = ">=5.6.3,<8" },
{ name = "cryptography", specifier = ">=48.0.0,<49.0.0" }, { name = "cryptography", specifier = ">=48.0.0,<49.0.0" },
{ name = "dict2xml", specifier = ">=1.7.8,<2.0.0" }, { name = "dict2xml", specifier = ">=1.7.8,<2.0.0" },
@@ -2222,6 +2165,7 @@ requires-dist = [
{ name = "ical", specifier = ">=12.0.0,<14.0.0" }, { name = "ical", specifier = ">=12.0.0,<14.0.0" },
{ name = "jinja2", specifier = ">=3.1.6,<4.0.0" }, { name = "jinja2", specifier = ">=3.1.6,<4.0.0" },
{ name = "libsass", specifier = ">=0.23.0,<1.0.0" }, { name = "libsass", specifier = ">=0.23.0,<1.0.0" },
{ name = "mistune", specifier = ">=3.2.1,<4.0.0" },
{ name = "phonenumbers", specifier = ">=9.0.32,<10.0.0" }, { name = "phonenumbers", specifier = ">=9.0.32,<10.0.0" },
{ name = "pillow", specifier = ">=12.2.0,<13.0.0" }, { name = "pillow", specifier = ">=12.2.0,<13.0.0" },
{ name = "psutil", specifier = ">=7.2.2,<8.0.0" }, { name = "psutil", specifier = ">=7.2.2,<8.0.0" },