feat(payments, vibes_auth, core): introduce decimal fields, 2FA, and admin OTP

- Refactored monetary fields across models to use `DecimalField` for improved precision.
- Implemented two-factor authentication (2FA) for admin logins with OTP codes.
- Added ability to generate admin OTP via management commands.
- Updated Docker Compose override for dev-specific port bindings.
- Included template for 2FA OTP verification to enhance security.

Additional changes:
- Upgraded and downgraded various dependencies (e.g., django-celery-beat and yarl).
- Replaced float-based calculations with decimal for consistent rounding behavior.
- Improved admin user management commands for activation and OTP generation.
This commit is contained in:
Egor Pavlovich Gorbunov 2026-03-03 00:42:21 +03:00
parent a5a9c70080
commit ad320235d6
32 changed files with 635 additions and 104 deletions

View file

@ -0,0 +1,10 @@
# Debug-only port mappings. Auto-loaded by Docker Compose in development.
# Do NOT deploy this file to production.
services:
database:
ports:
- "5432:5432"
prometheus:
ports:
- "9090:9090"

View file

@ -34,8 +34,6 @@ services:
restart: always
volumes:
- postgres-data:/var/lib/postgresql/data/
ports:
- "5432:5432"
env_file:
- .env
logging: *default-logging
@ -213,8 +211,6 @@ services:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./monitoring/web.yml:/etc/prometheus/web.yml:ro
- prometheus-data:/prometheus
ports:
- "9090:9090"
command:
- --config.file=/etc/prometheus/prometheus.yml
- --web.config.file=/etc/prometheus/web.yml

View file

@ -7,6 +7,7 @@ from django.core.exceptions import BadRequest
from django.db.models import (
Avg,
Case,
DecimalField,
Exists,
FloatField,
IntegerField,
@ -178,7 +179,7 @@ class ProductFilter(FilterSet):
price_order=Coalesce(
Max("stocks__price"),
Value(0.0),
output_field=FloatField(),
output_field=DecimalField(max_digits=12, decimal_places=2),
)
)

View file

@ -25,6 +25,7 @@ from engine.core.utils.emailing import contact_us_email
from engine.core.utils.messages import permission_denied_message
from engine.core.utils.nominatim import fetch_address_suggestions
from engine.payments.graphene.object_types import TransactionType
from schon.utils.ratelimit import graphql_ratelimit
from schon.utils.renderers import camelize
logger = logging.getLogger(__name__)
@ -202,6 +203,7 @@ class BuyOrder(Mutation):
transaction = Field(TransactionType, required=False)
@staticmethod
@graphql_ratelimit(rate="10/h")
def mutate(
parent,
info,
@ -364,6 +366,7 @@ class BuyUnregisteredOrder(Mutation):
transaction = Field(TransactionType, required=False)
@staticmethod
@graphql_ratelimit(rate="10/h")
def mutate(
parent,
info,
@ -491,6 +494,7 @@ class BuyWishlist(Mutation):
transaction = Field(TransactionType, required=False)
@staticmethod
@graphql_ratelimit(rate="10/h")
def mutate(parent, info, wishlist_uuid, force_balance=False, force_payment=False):
user = info.context.user
try:
@ -547,6 +551,7 @@ class BuyProduct(Mutation):
transaction = Field(TransactionType, required=False)
@staticmethod
@graphql_ratelimit(rate="10/h")
def mutate(
parent,
info,
@ -586,6 +591,7 @@ class FeedbackProductAction(Mutation):
feedback = Field(FeedbackType, required=False)
@staticmethod
@graphql_ratelimit(rate="10/h")
def mutate(parent, info, order_product_uuid, action, comment=None, rating=None):
user = info.context.user
try:
@ -685,6 +691,7 @@ class ContactUs(Mutation):
error = String()
@staticmethod
@graphql_ratelimit(rate="2/h")
def mutate(parent, info, email, name, subject, message, phone_number=None):
try:
contact_us_email.delay(

View file

@ -494,7 +494,7 @@ class OrderType(DjangoObjectType):
description = _("orders")
def resolve_total_price(self: Order, _info) -> float:
return self.total_price
return self.total_price # ty: ignore[invalid-return-type]
def resolve_total_quantity(self: Order, _info) -> int:
return self.total_quantity

View file

@ -0,0 +1,46 @@
# Generated by Django 5.2.11 on 2026-03-02 21:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0058_product_video_alter_address_api_response_and_more"),
]
operations = [
migrations.AlterField(
model_name="orderproduct",
name="buy_price",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="the price paid by the customer for this product at purchase time",
max_digits=12,
null=True,
verbose_name="purchase price at order time",
),
),
migrations.AlterField(
model_name="stock",
name="price",
field=models.DecimalField(
decimal_places=2,
default=0,
help_text="final price to the customer after markups",
max_digits=12,
verbose_name="selling price",
),
),
migrations.AlterField(
model_name="stock",
name="purchase_price",
field=models.DecimalField(
decimal_places=2,
default=0,
help_text="the price paid to the vendor for this product",
max_digits=12,
verbose_name="vendor purchase price",
),
),
]

View file

@ -2,6 +2,7 @@ import datetime
import json
import logging
from contextlib import suppress
from decimal import Decimal
from typing import TYPE_CHECKING, Any, Iterable, Self
from constance import config
@ -586,8 +587,10 @@ class Stock(NiceModel):
help_text=_("the vendor supplying this product stock"),
verbose_name=_("associated vendor"),
)
price = FloatField(
default=0.0,
price = DecimalField(
max_digits=12,
decimal_places=2,
default=0,
help_text=_("final price to the customer after markups"),
verbose_name=_("selling price"),
)
@ -600,8 +603,10 @@ class Stock(NiceModel):
blank=True,
null=True,
)
purchase_price = FloatField(
default=0.0,
purchase_price = DecimalField(
max_digits=12,
decimal_places=2,
default=0,
help_text=_("the price paid to the vendor for this product"),
verbose_name=_("vendor purchase price"),
)
@ -1472,12 +1477,15 @@ class Order(NiceModel):
)
@property
def total_price(self) -> float:
def total_price(self) -> Decimal:
total = self.order_products.exclude(status__in=FAILED_STATUSES).aggregate(
total=Sum(F("buy_price") * F("quantity"), output_field=FloatField())
total=Sum(
F("buy_price") * F("quantity"),
output_field=DecimalField(max_digits=12, decimal_places=2),
)
)["total"]
return round(total or 0.0, 2)
return (total or Decimal("0.00")).quantize(Decimal("0.01"))
@property
def total_quantity(self) -> int:
@ -1971,7 +1979,9 @@ class OrderProduct(NiceModel):
is_publicly_visible = False
buy_price = FloatField(
buy_price = DecimalField(
max_digits=12,
decimal_places=2,
blank=True,
null=True,
help_text=_("the price paid by the customer for this product at purchase time"),

View file

@ -435,4 +435,4 @@ class OrderDetailSerializer(ModelSerializer):
]
def get_total_price(self, obj: Order) -> float:
return obj.total_price
return obj.total_price # ty: ignore[invalid-return-type]

View file

@ -307,4 +307,4 @@ class OrderSimpleSerializer(ModelSerializer):
]
def get_total_price(self, obj: Order) -> float:
return obj.total_price
return obj.total_price # ty: ignore[invalid-return-type]

View file

@ -125,7 +125,7 @@ sitemap_detail.__doc__ = _( # ty:ignore[invalid-assignment]
_graphql_validation_rules = [QueryDepthLimitRule]
if getenv("GRAPHQL_INTROSPECTION", "").lower() in ("1", "true", "yes"):
if getenv("GRAPHQL_INTROSPECTION", "").lower() not in ("1", "true", "yes"):
_graphql_validation_rules.append(NoSchemaIntrospectionCustomRule)

View file

@ -5,6 +5,7 @@ from rest_framework.exceptions import PermissionDenied
from engine.core.utils.messages import permission_denied_message
from engine.payments.graphene.object_types import TransactionType
from engine.payments.models import Transaction
from schon.utils.ratelimit import graphql_ratelimit
class Deposit(Mutation):
@ -13,6 +14,7 @@ class Deposit(Mutation):
transaction = graphene.Field(TransactionType)
@graphql_ratelimit(rate="10/h")
def mutate(self, info, amount):
if info.context.user.is_authenticated:
transaction = Transaction.objects.create(

View file

@ -1,6 +1,7 @@
from django.db.models import (
BooleanField,
Case,
DecimalField,
F,
Manager,
Q,
@ -22,14 +23,16 @@ class GatewayQuerySet(QuerySet):
Sum(
"transactions__amount", filter=Q(transactions__created__date=today)
),
Value(0.0),
Value(0),
output_field=DecimalField(max_digits=12, decimal_places=2),
),
monthly_sum=Coalesce(
Sum(
"transactions__amount",
filter=Q(transactions__created__date__gte=current_month_start),
),
Value(0.0),
Value(0),
output_field=DecimalField(max_digits=12, decimal_places=2),
),
)

View file

@ -0,0 +1,42 @@
# Generated by Django 5.2.11 on 2026-03-02 21:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("payments", "0006_transaction_payments_tr_created_95e595_idx"),
]
operations = [
migrations.AlterField(
model_name="balance",
name="amount",
field=models.DecimalField(decimal_places=2, default=0, max_digits=12),
),
migrations.AlterField(
model_name="gateway",
name="maximum_transaction_amount",
field=models.DecimalField(
decimal_places=2,
default=0,
max_digits=12,
verbose_name="maximum transaction amount",
),
),
migrations.AlterField(
model_name="gateway",
name="minimum_transaction_amount",
field=models.DecimalField(
decimal_places=2,
default=0,
max_digits=12,
verbose_name="minimum transaction amount",
),
),
migrations.AlterField(
model_name="transaction",
name="amount",
field=models.DecimalField(decimal_places=2, max_digits=12),
),
]

View file

@ -5,7 +5,7 @@ from django.contrib.postgres.indexes import GinIndex
from django.db.models import (
CASCADE,
CharField,
FloatField,
DecimalField,
ForeignKey,
Index,
JSONField,
@ -24,7 +24,7 @@ from schon.utils.misc import create_object
class Transaction(NiceModel):
amount = FloatField(null=False, blank=False)
amount = DecimalField(max_digits=12, decimal_places=2, null=False, blank=False)
balance = ForeignKey(
"payments.Balance",
on_delete=CASCADE,
@ -59,8 +59,6 @@ class Transaction(NiceModel):
)
def save(self, **kwargs): # ty: ignore[invalid-method-override]
if len(str(self.amount).split(".")[1]) > 2:
self.amount = round(self.amount, 2)
super().save(**kwargs)
return self
@ -74,7 +72,9 @@ class Transaction(NiceModel):
class Balance(NiceModel):
amount = FloatField(null=False, blank=False, default=0)
amount = DecimalField(
max_digits=12, decimal_places=2, null=False, blank=False, default=0
)
user = OneToOneField(
to=settings.AUTH_USER_MODEL,
on_delete=CASCADE,
@ -92,8 +92,6 @@ class Balance(NiceModel):
verbose_name_plural = _("balances")
def save(self, **kwargs): # ty: ignore[invalid-method-override]
if self.amount != 0.0 and len(str(self.amount).split(".")[1]) > 2:
self.amount = round(self.amount, 2)
super().save(**kwargs)
@ -118,11 +116,21 @@ class Gateway(NiceModel):
),
)
integration_path = CharField(max_length=255, null=True, blank=True)
minimum_transaction_amount = FloatField(
null=False, blank=False, default=0, verbose_name=_("minimum transaction amount")
minimum_transaction_amount = DecimalField(
max_digits=12,
decimal_places=2,
null=False,
blank=False,
default=0,
verbose_name=_("minimum transaction amount"),
)
maximum_transaction_amount = FloatField(
null=False, blank=False, default=0, verbose_name=_("maximum transaction amount")
maximum_transaction_amount = DecimalField(
max_digits=12,
decimal_places=2,
null=False,
blank=False,
default=0,
verbose_name=_("maximum transaction amount"),
)
daily_limit = PositiveIntegerField(
null=False,

View file

@ -1,11 +1,19 @@
from rest_framework.fields import FloatField, JSONField, SerializerMethodField
from decimal import Decimal
from rest_framework.fields import DecimalField, JSONField, SerializerMethodField
from rest_framework.serializers import ModelSerializer, Serializer
from engine.payments.models import Transaction
class DepositSerializer(Serializer):
amount = FloatField(required=True)
amount = DecimalField(
max_digits=12,
decimal_places=2,
required=True,
min_value=Decimal("0.01"),
max_value=Decimal("999999.99"),
)
class TransactionSerializer(ModelSerializer):
@ -32,5 +40,5 @@ class TransactionProcessSerializer(ModelSerializer):
class LimitsSerializer(Serializer):
min_amount = FloatField(read_only=True)
max_amount = FloatField(read_only=True)
min_amount = DecimalField(max_digits=12, decimal_places=2, read_only=True)
max_amount = DecimalField(max_digits=12, decimal_places=2, read_only=True)

View file

@ -46,6 +46,7 @@ from engine.vibes_auth.emailing.tasks import (
)
from engine.vibes_auth.forms import UserForm
from engine.vibes_auth.models import (
AdminOTPCode,
BlacklistedToken,
ChatMessage,
ChatThread,
@ -403,6 +404,17 @@ class OutstandingTokenAdmin(BaseOutstandingTokenAdmin, ModelAdmin):
pass
@register(AdminOTPCode)
class AdminOTPCodeAdmin(ModelAdmin):
list_display = ("user", "code", "is_used", "created")
list_filter = ("is_used",)
search_fields = ("user__email",)
readonly_fields = ("user", "code", "is_used", "created", "modified")
def has_add_permission(self, request):
return False
admin.site.register(User, UserAdmin)
admin.site.unregister(BaseGroup)

View file

@ -0,0 +1,102 @@
import logging
from django import forms
from django.contrib import messages
from django.contrib.auth import authenticate, login
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import render
from django.urls import path, reverse
from django.utils.translation import gettext_lazy as _
from unfold.sites import UnfoldAdminSite
from engine.vibes_auth.utils.otp import generate_otp_code, send_admin_otp_email_task
logger = logging.getLogger(__name__)
class OTPVerifyForm(forms.Form):
code = forms.CharField(
max_length=6,
min_length=6,
label=_("Verification code"),
widget=forms.TextInput(
attrs={
"autofocus": True,
"autocomplete": "one-time-code",
"inputmode": "numeric",
"pattern": "[0-9]*",
}
),
)
class SchonAdminSite(UnfoldAdminSite):
def login(self, request: HttpRequest, extra_context=None) -> HttpResponse:
if request.method == "POST":
email = request.POST.get("username", "")
password = request.POST.get("password", "")
user = authenticate(request, username=email, password=password)
if user is not None and user.is_staff: # ty: ignore[unresolved-attribute]
code = generate_otp_code(user)
send_admin_otp_email_task.delay(user_pk=str(user.pk), code=code)
request.session["_2fa_user_id"] = str(user.pk)
messages.info(
request, _("A verification code has been sent to your email.")
)
return HttpResponseRedirect(reverse("admin:verify-otp"))
return super().login(request, extra_context)
def extra_urls(self):
return [
path(
"verify-otp/",
self.verify_otp_view,
name="verify-otp",
),
]
def verify_otp_view(self, request: HttpRequest) -> HttpResponse:
from engine.vibes_auth.models import AdminOTPCode, User
user_pk = request.session.get("_2fa_user_id")
if not user_pk:
return HttpResponseRedirect(reverse("admin:login"))
try:
user = User.objects.get(pk=user_pk)
except User.DoesNotExist:
return HttpResponseRedirect(reverse("admin:login"))
form = OTPVerifyForm()
error = None
if request.method == "POST":
form = OTPVerifyForm(request.POST)
if form.is_valid():
code = form.cleaned_data["code"]
otp = (
AdminOTPCode.objects.filter(user=user, code=code, is_used=False)
.order_by("-created")
.first()
)
if otp and otp.is_valid():
otp.is_used = True
otp.save(update_fields=["is_used", "modified"])
del request.session["_2fa_user_id"]
login(request, user)
return HttpResponseRedirect(reverse("admin:index"))
else:
error = _("Invalid or expired code. Please try again.")
context = {
**self.each_context(request),
"form": form,
"error": error,
"title": _("Two-factor authentication"),
"site_title": self.site_title,
"site_header": self.site_header,
}
return render(request, "admin/verify_otp.html", context)

View file

@ -11,4 +11,12 @@ class VibesAuthConfig(AppConfig):
hide = False
def ready(self) -> None:
from django.contrib import admin # noqa: E402
from django.contrib.admin import sites # noqa: E402
import engine.vibes_auth.signals # noqa: F401
from engine.vibes_auth.admin_site import SchonAdminSite # noqa: E402
site = SchonAdminSite()
admin.site = site
sites.site = site

View file

@ -26,6 +26,7 @@ from engine.vibes_auth.serializers import (
)
from engine.vibes_auth.utils.emailing import send_reset_password_email_task
from engine.vibes_auth.validators import is_valid_email, is_valid_phone_number
from schon.utils.ratelimit import graphql_ratelimit
logger = logging.getLogger(__name__)
@ -49,6 +50,7 @@ class CreateUser(Mutation):
success = Boolean()
@graphql_ratelimit(rate="5/h")
def mutate(
self,
info,
@ -220,6 +222,7 @@ class ObtainJSONWebToken(Mutation):
refresh_token = String(required=True)
access_token = String(required=True)
@graphql_ratelimit(rate="10/h")
def mutate(self, info, email, password):
serializer = TokenObtainPairSerializer(
data={"email": email, "password": password}, retrieve_user=False
@ -244,6 +247,7 @@ class RefreshJSONWebToken(Mutation):
user = Field(UserType)
refresh_token = String()
@graphql_ratelimit(rate="10/h")
def mutate(self, info, refresh_token):
serializer = TokenRefreshSerializer(
data={"refresh": refresh_token}, retrieve_user=False
@ -294,6 +298,7 @@ class ActivateUser(Mutation):
success = Boolean()
@graphql_ratelimit(rate="5/h")
def mutate(self, info, uid, token):
try:
token = urlsafe_base64_decode(token).decode()
@ -323,6 +328,7 @@ class ResetPassword(Mutation):
success = Boolean()
@graphql_ratelimit(rate="4/h")
def mutate(self, info, email):
try:
user = User.objects.get(email=email)
@ -345,6 +351,7 @@ class ConfirmResetPassword(Mutation):
success = Boolean()
@graphql_ratelimit(rate="5/h")
def mutate(self, info, uid, token, password, confirm_password):
try:
if not compare_digest(password, confirm_password):
@ -376,16 +383,28 @@ class ConfirmResetPassword(Mutation):
raise BadRequest(_(f"something went wrong: {e!s}")) from e
ALLOWED_IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"}
MAX_AVATAR_SIZE = 5 * 1024 * 1024 # 5 MB
class UploadAvatar(Mutation):
class Arguments:
file = Upload(required=True)
user = Field(UserType)
@graphql_ratelimit(rate="3/h")
def mutate(self, info, file):
if not info.context.user.is_authenticated:
raise PermissionDenied(permission_denied_message)
ext = file.name.rsplit(".", 1)[-1].lower() if "." in file.name else ""
if ext not in ALLOWED_IMAGE_EXTENSIONS:
raise BadRequest(_("only image files are allowed (jpg, png, gif, webp)"))
if file.size > MAX_AVATAR_SIZE:
raise BadRequest(_("file size must not exceed 5 MB"))
try:
info.context.user.avatar = file
info.context.user.save()

View file

View file

@ -0,0 +1,26 @@
from django.core.management.base import BaseCommand, CommandError
from engine.vibes_auth.models import User
class Command(BaseCommand):
help = "Directly activate a user account (for when SMTP is not configured)"
def add_arguments(self, parser):
parser.add_argument("email", type=str, help="Email of the user to activate")
def handle(self, *args, **options):
email = options["email"]
try:
user = User.objects.get(email=email)
except User.DoesNotExist as e:
raise CommandError(f'User "{email}" does not exist.') from e
if user.is_active and user.is_verified:
self.stdout.write(f'User "{email}" is already active and verified.')
return
user.is_active = True
user.is_verified = True
user.save(update_fields=["is_active", "is_verified", "modified"])
self.stdout.write(self.style.SUCCESS(f'User "{email}" has been activated.'))

View file

@ -0,0 +1,25 @@
from django.core.management.base import BaseCommand, CommandError
from engine.vibes_auth.models import User
from engine.vibes_auth.utils.otp import generate_otp_code
class Command(BaseCommand):
help = "Generate a fresh admin OTP code for a user (for when SMTP is down)"
def add_arguments(self, parser):
parser.add_argument("email", type=str, help="Email of the staff user")
def handle(self, *args, **options):
email = options["email"]
try:
user = User.objects.get(email=email)
except User.DoesNotExist as e:
raise CommandError(f'User "{email}" does not exist.') from e
if not user.is_staff:
raise CommandError(f'User "{email}" is not a staff member.')
code = generate_otp_code(user)
self.stdout.write(f"OTP code for {email}: {code}")
self.stdout.write("Valid for 5 minutes.")

View file

@ -0,0 +1,78 @@
# Generated by Django 5.2.11 on 2026-03-02 21:39
import uuid
import django.db.models.deletion
import django_extensions.db.fields
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("vibes_auth", "0011_alter_user_attributes_alter_user_phone_number"),
]
operations = [
migrations.AlterField(
model_name="user",
name="activation_token",
field=models.CharField(
blank=True, default="", max_length=128, verbose_name="activation token"
),
),
migrations.CreateModel(
name="AdminOTPCode",
fields=[
(
"uuid",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="unique id is used to surely identify any database object",
primary_key=True,
serialize=False,
verbose_name="unique id",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="if set to false, this object can't be seen by users without needed permission",
verbose_name="is active",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True,
help_text="when the object first appeared on the database",
verbose_name="created",
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
),
),
("code", models.CharField(max_length=6)),
("is_used", models.BooleanField(default=False)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="otp_codes",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "admin OTP code",
"verbose_name_plural": "admin OTP codes",
},
),
]

View file

@ -94,7 +94,9 @@ class User(AbstractUser, NiceModel):
default=False,
)
activation_token = UUIDField(default=uuid4, verbose_name=_("activation token"))
activation_token = CharField(
max_length=128, blank=True, default="", verbose_name=_("activation token")
)
activation_token_created = DateTimeField(
null=True,
blank=True,
@ -148,17 +150,25 @@ class User(AbstractUser, NiceModel):
def save(self, *args, **kwargs):
if self._state.adding and self.activation_token_created is None:
self.activation_token_created = timezone.now()
if self._state.adding and not self.activation_token:
self.refresh_activation_token()
super().save(*args, **kwargs)
def refresh_activation_token(self) -> None:
"""Generate a fresh activation token and update its timestamp."""
self.activation_token = uuid4()
def refresh_activation_token(self) -> str:
"""Generate a fresh activation token, store its hash, return raw token."""
import hashlib
raw_token = str(uuid4())
self.activation_token = hashlib.sha256(raw_token.encode()).hexdigest()
self.activation_token_created = timezone.now()
return raw_token
def check_token(self, token) -> bool:
import hashlib
from datetime import timedelta
if str(token) != str(self.activation_token):
hashed = hashlib.sha256(str(token).encode()).hexdigest()
if hashed != self.activation_token:
return False
if self.activation_token_created:
if timezone.now() > self.activation_token_created + timedelta(hours=24):
@ -174,6 +184,25 @@ class User(AbstractUser, NiceModel):
verbose_name_plural = _("users")
class AdminOTPCode(NiceModel):
user = ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=CASCADE,
related_name="otp_codes",
)
code = CharField(max_length=6)
is_used = BooleanField(default=False)
def is_valid(self) -> bool:
from datetime import timedelta
return not self.is_used and timezone.now() < self.created + timedelta(minutes=5)
class Meta:
verbose_name = _("admin OTP code")
verbose_name_plural = _("admin OTP codes")
class ChatThread(NiceModel):
user = ForeignKey(
User, null=True, blank=True, on_delete=SET_NULL, related_name="chat_threads"

View file

@ -0,0 +1,31 @@
{% extends 'unfold/layouts/unauthenticated.html' %}
{% load i18n unfold %}
{% block title %}
{{ title }} | {{ site_title }}
{% endblock %}
{% block content %}
{% include "unfold/helpers/unauthenticated_title.html" with title=site_title subtitle=_('Two-factor authentication') %}
{% include "unfold/helpers/messages.html" %}
{% if error %}
<div class="flex flex-col gap-4 mb-8 *:mb-0">
{% include "unfold/helpers/messages/error.html" with errors=error %}
</div>
{% endif %}
<form method="post">
{% csrf_token %}
{% include "unfold/helpers/field.html" with field=form.code %}
<div class="flex flex-col gap-3 mt-6">
{% component "unfold/components/button.html" with submit=1 variant="primary" class="w-full" %}
{% translate 'Verify' %} <span class="material-symbols-outlined relative right-0 transition-all group-hover:-right-1 text-sm">check</span>
{% endcomponent %}
</div>
</form>
{% endblock %}

View file

@ -19,6 +19,9 @@ def send_verification_email_task(user_pk: str) -> tuple[bool, str]:
user = User.objects.get(pk=user_pk)
user.refresh_from_db()
raw_token = user.refresh_activation_token()
user.save(update_fields=["activation_token", "activation_token_created"])
activate(user.language)
email_subject = _(f"{settings.PROJECT_NAME} | Activate Account")
@ -27,7 +30,7 @@ def send_verification_email_task(user_pk: str) -> tuple[bool, str]:
{
"user_first_name": user.first_name,
"activation_link": f"https://{settings.STOREFRONT_DOMAIN}/{user.language}/activate-user?uid={urlsafe_base64_encode(force_bytes(user.uuid))}"
f"&token={urlsafe_base64_encode(force_bytes(user.activation_token))}",
f"&token={urlsafe_base64_encode(force_bytes(raw_token))}",
"project_name": settings.PROJECT_NAME,
},
)

View file

@ -0,0 +1,36 @@
import secrets
from celery.app import shared_task
from constance import config
from django.conf import settings
from django.core.mail import EmailMessage
from engine.core.utils import get_dynamic_email_connection
def generate_otp_code(user) -> str:
from engine.vibes_auth.models import AdminOTPCode
AdminOTPCode.objects.filter(user=user, is_used=False).update(is_used=True)
code = f"{secrets.randbelow(1000000):06d}"
AdminOTPCode.objects.create(user=user, code=code)
return code
@shared_task(queue="default")
def send_admin_otp_email_task(user_pk: str, code: str) -> tuple[bool, str]:
from engine.vibes_auth.models import User
try:
user = User.objects.get(pk=user_pk)
email = EmailMessage(
subject=f"{settings.PROJECT_NAME} | Admin Login Code",
body=f"Your admin login code: {code}\n\nValid for 5 minutes.",
from_email=f"{settings.PROJECT_NAME} <{config.EMAIL_FROM}>",
to=[user.email],
connection=get_dynamic_email_connection(),
)
email.send()
except Exception as e:
return False, str(e)
return True, str(user.uuid)

View file

@ -139,8 +139,6 @@ class UserViewSet(
pending = User.objects.get(
email=email, is_active=False, is_verified=False
)
pending.refresh_activation_token()
pending.save()
send_verification_email_task.delay(user_pk=str(pending.uuid))
return Response(
{

View file

@ -79,7 +79,7 @@ dependencies = [
[project.optional-dependencies]
worker = [
"celery==5.6.2",
"django-celery-beat==2.8.1",
"django-celery-beat==2.9.0",
"django-celery-results==2.6.0",
]
linting = [

28
schon/utils/ratelimit.py Normal file
View file

@ -0,0 +1,28 @@
from functools import wraps
from django.conf import settings
from django_ratelimit.core import is_ratelimited
from schon.utils.misc import RatelimitedError
def graphql_ratelimit(key="ip", rate="10/h", group=None):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
info = args[1] if len(args) > 1 else kwargs.get("info")
request = info.context # ty: ignore[possibly-missing-attribute]
actual_rate = rate if not settings.DEBUG else "888/h"
if is_ratelimited(
request=request,
group=group or f"graphql:{func.__qualname__}",
key=key,
rate=actual_rate,
increment=True,
):
raise RatelimitedError()
return func(*args, **kwargs)
return wrapper
return decorator

125
uv.lock
View file

@ -634,14 +634,11 @@ wheels = [
[[package]]
name = "cron-descriptor"
version = "2.0.6"
version = "1.4.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7c/31/0b21d1599656b2ffa6043e51ca01041cd1c0f6dacf5a3e2b620ed120e7d8/cron_descriptor-2.0.6.tar.gz", hash = "sha256:e39d2848e1d8913cfb6e3452e701b5eec662ee18bea8cc5aa53ee1a7bb217157", size = 49456, upload-time = "2025-09-03T16:30:22.434Z" }
sdist = { url = "https://files.pythonhosted.org/packages/02/83/70bd410dc6965e33a5460b7da84cf0c5a7330a68d6d5d4c3dfdb72ca117e/cron_descriptor-1.4.5.tar.gz", hash = "sha256:f51ce4ffc1d1f2816939add8524f206c376a42c87a5fca3091ce26725b3b1bca", size = 30666, upload-time = "2024-08-24T18:16:48.654Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/21/cc/361326a54ad92e2e12845ad15e335a4e14b8953665007fb514d3393dfb0f/cron_descriptor-2.0.6-py3-none-any.whl", hash = "sha256:3a1c0d837c0e5a32e415f821b36cf758eb92d510e6beff8fbfe4fa16573d93d6", size = 74446, upload-time = "2025-09-03T16:30:21.397Z" },
{ url = "https://files.pythonhosted.org/packages/88/20/2cfe598ead23a715a00beb716477cfddd3e5948cf203c372d02221e5b0c6/cron_descriptor-1.4.5-py3-none-any.whl", hash = "sha256:736b3ae9d1a99bc3dbfc5b55b5e6e7c12031e7ba5de716625772f8b02dcd6013", size = 50370, upload-time = "2024-08-24T18:16:46.783Z" },
]
[[package]]
@ -766,7 +763,7 @@ wheels = [
[[package]]
name = "django-celery-beat"
version = "2.8.1"
version = "2.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "celery" },
@ -776,9 +773,9 @@ dependencies = [
{ name = "python-crontab" },
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/11/0c8b412869b4fda72828572068312b10aafe7ccef7b41af3633af31f9d4b/django_celery_beat-2.8.1.tar.gz", hash = "sha256:dfad0201c0ac50c91a34700ef8fa0a10ee098cc7f3375fe5debed79f2204f80a", size = 175802, upload-time = "2025-05-13T06:58:29.246Z" }
sdist = { url = "https://files.pythonhosted.org/packages/05/45/fc97bc1d9af8e7dc07f1e37044d9551a30e6793249864cef802341e2e3a8/django_celery_beat-2.9.0.tar.gz", hash = "sha256:92404650f52fcb44cf08e2b09635cb1558327c54b1a5d570f0e2d3a22130934c", size = 177667, upload-time = "2026-02-28T16:45:34.749Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/e5/3a0167044773dee989b498e9a851fc1663bea9ab879f1179f7b8a827ac10/django_celery_beat-2.8.1-py3-none-any.whl", hash = "sha256:da2b1c6939495c05a551717509d6e3b79444e114a027f7b77bf3727c2a39d171", size = 104833, upload-time = "2025-05-13T06:58:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/71/ae/9befa7ae37f5e5c41be636a254fcf47ff30dd5c88bd115070e252f6b9162/django_celery_beat-2.9.0-py3-none-any.whl", hash = "sha256:4a9e5ebe26d6f8d7215e1fc5c46e466016279dc102435a28141108649bdf2157", size = 105013, upload-time = "2026-02-28T16:45:32.822Z" },
]
[[package]]
@ -3460,7 +3457,7 @@ requires-dist = [
{ name = "cryptography", specifier = "==46.0.5" },
{ name = "django", specifier = "==5.2.11" },
{ name = "django-cacheops", specifier = "==7.2" },
{ name = "django-celery-beat", marker = "extra == 'worker'", specifier = "==2.8.1" },
{ name = "django-celery-beat", marker = "extra == 'worker'", specifier = "==2.9.0" },
{ name = "django-celery-results", marker = "extra == 'worker'", specifier = "==2.6.0" },
{ name = "django-constance", specifier = "==4.3.4" },
{ name = "django-cors-headers", specifier = "==4.9.0" },
@ -4102,64 +4099,70 @@ wheels = [
[[package]]
name = "yarl"
version = "1.22.0"
version = "1.23.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "multidict" },
{ name = "propcache" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" }
sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" },
{ url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" },
{ url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" },
{ url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" },
{ url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" },
{ url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" },
{ url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" },
{ url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" },
{ url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" },
{ url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" },
{ url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" },
{ url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" },
{ url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" },
{ url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" },
{ url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" },
{ url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" },
{ url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" },
{ url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" },
{ url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" },
{ url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" },
{ url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" },
{ url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" },
{ url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" },
{ url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" },
{ url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" },
{ url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" },
{ url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" },
{ url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" },
{ url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" },
{ url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" },
{ url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" },
{ url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" },
{ url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" },
{ url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" },
{ url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" },
{ url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" },
{ url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" },
{ url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" },
{ url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" },
{ url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" },
{ url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" },
{ url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" },
{ url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" },
{ url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" },
{ url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" },
{ url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" },
{ url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" },
{ url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" },
{ url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" },
{ url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" },
{ url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" },
{ url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" },
{ url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" },
{ url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" },
{ url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" },
{ url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" },
{ url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" },
{ url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" },
{ url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" },
{ url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" },
{ url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" },
{ url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" },
{ url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" },
{ url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" },
{ url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" },
{ url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" },
{ url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" },
{ url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" },
{ url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" },
{ url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" },
{ url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" },
{ url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" },
{ url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" },
{ url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" },
{ url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" },
{ url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" },
{ url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" },
{ url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" },
{ url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" },
{ url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" },
{ url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" },
{ url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" },
{ url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" },
{ url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" },
{ url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" },
{ url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" },
{ url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" },
{ url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" },
{ url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" },
{ url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" },
{ url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" },
{ url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" },
{ url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" },
{ url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" },
{ url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" },
{ url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" },
]
[[package]]