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:
parent
a5a9c70080
commit
ad320235d6
32 changed files with 635 additions and 104 deletions
10
docker-compose.override.yml
Normal file
10
docker-compose.override.yml
Normal 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"
|
||||||
|
|
@ -34,8 +34,6 @@ services:
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data/
|
- postgres-data:/var/lib/postgresql/data/
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
|
|
@ -213,8 +211,6 @@ services:
|
||||||
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
- ./monitoring/web.yml:/etc/prometheus/web.yml:ro
|
- ./monitoring/web.yml:/etc/prometheus/web.yml:ro
|
||||||
- prometheus-data:/prometheus
|
- prometheus-data:/prometheus
|
||||||
ports:
|
|
||||||
- "9090:9090"
|
|
||||||
command:
|
command:
|
||||||
- --config.file=/etc/prometheus/prometheus.yml
|
- --config.file=/etc/prometheus/prometheus.yml
|
||||||
- --web.config.file=/etc/prometheus/web.yml
|
- --web.config.file=/etc/prometheus/web.yml
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from django.core.exceptions import BadRequest
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
Avg,
|
Avg,
|
||||||
Case,
|
Case,
|
||||||
|
DecimalField,
|
||||||
Exists,
|
Exists,
|
||||||
FloatField,
|
FloatField,
|
||||||
IntegerField,
|
IntegerField,
|
||||||
|
|
@ -178,7 +179,7 @@ class ProductFilter(FilterSet):
|
||||||
price_order=Coalesce(
|
price_order=Coalesce(
|
||||||
Max("stocks__price"),
|
Max("stocks__price"),
|
||||||
Value(0.0),
|
Value(0.0),
|
||||||
output_field=FloatField(),
|
output_field=DecimalField(max_digits=12, decimal_places=2),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.messages import permission_denied_message
|
||||||
from engine.core.utils.nominatim import fetch_address_suggestions
|
from engine.core.utils.nominatim import fetch_address_suggestions
|
||||||
from engine.payments.graphene.object_types import TransactionType
|
from engine.payments.graphene.object_types import TransactionType
|
||||||
|
from schon.utils.ratelimit import graphql_ratelimit
|
||||||
from schon.utils.renderers import camelize
|
from schon.utils.renderers import camelize
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -202,6 +203,7 @@ class BuyOrder(Mutation):
|
||||||
transaction = Field(TransactionType, required=False)
|
transaction = Field(TransactionType, required=False)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@graphql_ratelimit(rate="10/h")
|
||||||
def mutate(
|
def mutate(
|
||||||
parent,
|
parent,
|
||||||
info,
|
info,
|
||||||
|
|
@ -364,6 +366,7 @@ class BuyUnregisteredOrder(Mutation):
|
||||||
transaction = Field(TransactionType, required=False)
|
transaction = Field(TransactionType, required=False)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@graphql_ratelimit(rate="10/h")
|
||||||
def mutate(
|
def mutate(
|
||||||
parent,
|
parent,
|
||||||
info,
|
info,
|
||||||
|
|
@ -491,6 +494,7 @@ class BuyWishlist(Mutation):
|
||||||
transaction = Field(TransactionType, required=False)
|
transaction = Field(TransactionType, required=False)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@graphql_ratelimit(rate="10/h")
|
||||||
def mutate(parent, info, wishlist_uuid, force_balance=False, force_payment=False):
|
def mutate(parent, info, wishlist_uuid, force_balance=False, force_payment=False):
|
||||||
user = info.context.user
|
user = info.context.user
|
||||||
try:
|
try:
|
||||||
|
|
@ -547,6 +551,7 @@ class BuyProduct(Mutation):
|
||||||
transaction = Field(TransactionType, required=False)
|
transaction = Field(TransactionType, required=False)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@graphql_ratelimit(rate="10/h")
|
||||||
def mutate(
|
def mutate(
|
||||||
parent,
|
parent,
|
||||||
info,
|
info,
|
||||||
|
|
@ -586,6 +591,7 @@ class FeedbackProductAction(Mutation):
|
||||||
feedback = Field(FeedbackType, required=False)
|
feedback = Field(FeedbackType, required=False)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@graphql_ratelimit(rate="10/h")
|
||||||
def mutate(parent, info, order_product_uuid, action, comment=None, rating=None):
|
def mutate(parent, info, order_product_uuid, action, comment=None, rating=None):
|
||||||
user = info.context.user
|
user = info.context.user
|
||||||
try:
|
try:
|
||||||
|
|
@ -685,6 +691,7 @@ class ContactUs(Mutation):
|
||||||
error = String()
|
error = String()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@graphql_ratelimit(rate="2/h")
|
||||||
def mutate(parent, info, email, name, subject, message, phone_number=None):
|
def mutate(parent, info, email, name, subject, message, phone_number=None):
|
||||||
try:
|
try:
|
||||||
contact_us_email.delay(
|
contact_us_email.delay(
|
||||||
|
|
|
||||||
|
|
@ -494,7 +494,7 @@ class OrderType(DjangoObjectType):
|
||||||
description = _("orders")
|
description = _("orders")
|
||||||
|
|
||||||
def resolve_total_price(self: Order, _info) -> float:
|
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:
|
def resolve_total_quantity(self: Order, _info) -> int:
|
||||||
return self.total_quantity
|
return self.total_quantity
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -2,6 +2,7 @@ import datetime
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
from decimal import Decimal
|
||||||
from typing import TYPE_CHECKING, Any, Iterable, Self
|
from typing import TYPE_CHECKING, Any, Iterable, Self
|
||||||
|
|
||||||
from constance import config
|
from constance import config
|
||||||
|
|
@ -586,8 +587,10 @@ class Stock(NiceModel):
|
||||||
help_text=_("the vendor supplying this product stock"),
|
help_text=_("the vendor supplying this product stock"),
|
||||||
verbose_name=_("associated vendor"),
|
verbose_name=_("associated vendor"),
|
||||||
)
|
)
|
||||||
price = FloatField(
|
price = DecimalField(
|
||||||
default=0.0,
|
max_digits=12,
|
||||||
|
decimal_places=2,
|
||||||
|
default=0,
|
||||||
help_text=_("final price to the customer after markups"),
|
help_text=_("final price to the customer after markups"),
|
||||||
verbose_name=_("selling price"),
|
verbose_name=_("selling price"),
|
||||||
)
|
)
|
||||||
|
|
@ -600,8 +603,10 @@ class Stock(NiceModel):
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
)
|
)
|
||||||
purchase_price = FloatField(
|
purchase_price = DecimalField(
|
||||||
default=0.0,
|
max_digits=12,
|
||||||
|
decimal_places=2,
|
||||||
|
default=0,
|
||||||
help_text=_("the price paid to the vendor for this product"),
|
help_text=_("the price paid to the vendor for this product"),
|
||||||
verbose_name=_("vendor purchase price"),
|
verbose_name=_("vendor purchase price"),
|
||||||
)
|
)
|
||||||
|
|
@ -1472,12 +1477,15 @@ class Order(NiceModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_price(self) -> float:
|
def total_price(self) -> Decimal:
|
||||||
total = self.order_products.exclude(status__in=FAILED_STATUSES).aggregate(
|
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"]
|
)["total"]
|
||||||
|
|
||||||
return round(total or 0.0, 2)
|
return (total or Decimal("0.00")).quantize(Decimal("0.01"))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_quantity(self) -> int:
|
def total_quantity(self) -> int:
|
||||||
|
|
@ -1971,7 +1979,9 @@ class OrderProduct(NiceModel):
|
||||||
|
|
||||||
is_publicly_visible = False
|
is_publicly_visible = False
|
||||||
|
|
||||||
buy_price = FloatField(
|
buy_price = DecimalField(
|
||||||
|
max_digits=12,
|
||||||
|
decimal_places=2,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
help_text=_("the price paid by the customer for this product at purchase time"),
|
help_text=_("the price paid by the customer for this product at purchase time"),
|
||||||
|
|
|
||||||
|
|
@ -435,4 +435,4 @@ class OrderDetailSerializer(ModelSerializer):
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_total_price(self, obj: Order) -> float:
|
def get_total_price(self, obj: Order) -> float:
|
||||||
return obj.total_price
|
return obj.total_price # ty: ignore[invalid-return-type]
|
||||||
|
|
|
||||||
|
|
@ -307,4 +307,4 @@ class OrderSimpleSerializer(ModelSerializer):
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_total_price(self, obj: Order) -> float:
|
def get_total_price(self, obj: Order) -> float:
|
||||||
return obj.total_price
|
return obj.total_price # ty: ignore[invalid-return-type]
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ sitemap_detail.__doc__ = _( # ty:ignore[invalid-assignment]
|
||||||
|
|
||||||
|
|
||||||
_graphql_validation_rules = [QueryDepthLimitRule]
|
_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)
|
_graphql_validation_rules.append(NoSchemaIntrospectionCustomRule)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from rest_framework.exceptions import PermissionDenied
|
||||||
from engine.core.utils.messages import permission_denied_message
|
from engine.core.utils.messages import permission_denied_message
|
||||||
from engine.payments.graphene.object_types import TransactionType
|
from engine.payments.graphene.object_types import TransactionType
|
||||||
from engine.payments.models import Transaction
|
from engine.payments.models import Transaction
|
||||||
|
from schon.utils.ratelimit import graphql_ratelimit
|
||||||
|
|
||||||
|
|
||||||
class Deposit(Mutation):
|
class Deposit(Mutation):
|
||||||
|
|
@ -13,6 +14,7 @@ class Deposit(Mutation):
|
||||||
|
|
||||||
transaction = graphene.Field(TransactionType)
|
transaction = graphene.Field(TransactionType)
|
||||||
|
|
||||||
|
@graphql_ratelimit(rate="10/h")
|
||||||
def mutate(self, info, amount):
|
def mutate(self, info, amount):
|
||||||
if info.context.user.is_authenticated:
|
if info.context.user.is_authenticated:
|
||||||
transaction = Transaction.objects.create(
|
transaction = Transaction.objects.create(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
BooleanField,
|
BooleanField,
|
||||||
Case,
|
Case,
|
||||||
|
DecimalField,
|
||||||
F,
|
F,
|
||||||
Manager,
|
Manager,
|
||||||
Q,
|
Q,
|
||||||
|
|
@ -22,14 +23,16 @@ class GatewayQuerySet(QuerySet):
|
||||||
Sum(
|
Sum(
|
||||||
"transactions__amount", filter=Q(transactions__created__date=today)
|
"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(
|
monthly_sum=Coalesce(
|
||||||
Sum(
|
Sum(
|
||||||
"transactions__amount",
|
"transactions__amount",
|
||||||
filter=Q(transactions__created__date__gte=current_month_start),
|
filter=Q(transactions__created__date__gte=current_month_start),
|
||||||
),
|
),
|
||||||
Value(0.0),
|
Value(0),
|
||||||
|
output_field=DecimalField(max_digits=12, decimal_places=2),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -5,7 +5,7 @@ from django.contrib.postgres.indexes import GinIndex
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
CASCADE,
|
CASCADE,
|
||||||
CharField,
|
CharField,
|
||||||
FloatField,
|
DecimalField,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Index,
|
Index,
|
||||||
JSONField,
|
JSONField,
|
||||||
|
|
@ -24,7 +24,7 @@ from schon.utils.misc import create_object
|
||||||
|
|
||||||
|
|
||||||
class Transaction(NiceModel):
|
class Transaction(NiceModel):
|
||||||
amount = FloatField(null=False, blank=False)
|
amount = DecimalField(max_digits=12, decimal_places=2, null=False, blank=False)
|
||||||
balance = ForeignKey(
|
balance = ForeignKey(
|
||||||
"payments.Balance",
|
"payments.Balance",
|
||||||
on_delete=CASCADE,
|
on_delete=CASCADE,
|
||||||
|
|
@ -59,8 +59,6 @@ class Transaction(NiceModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
def save(self, **kwargs): # ty: ignore[invalid-method-override]
|
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)
|
super().save(**kwargs)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
@ -74,7 +72,9 @@ class Transaction(NiceModel):
|
||||||
|
|
||||||
|
|
||||||
class Balance(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(
|
user = OneToOneField(
|
||||||
to=settings.AUTH_USER_MODEL,
|
to=settings.AUTH_USER_MODEL,
|
||||||
on_delete=CASCADE,
|
on_delete=CASCADE,
|
||||||
|
|
@ -92,8 +92,6 @@ class Balance(NiceModel):
|
||||||
verbose_name_plural = _("balances")
|
verbose_name_plural = _("balances")
|
||||||
|
|
||||||
def save(self, **kwargs): # ty: ignore[invalid-method-override]
|
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)
|
super().save(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -118,11 +116,21 @@ class Gateway(NiceModel):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
integration_path = CharField(max_length=255, null=True, blank=True)
|
integration_path = CharField(max_length=255, null=True, blank=True)
|
||||||
minimum_transaction_amount = FloatField(
|
minimum_transaction_amount = DecimalField(
|
||||||
null=False, blank=False, default=0, verbose_name=_("minimum transaction amount")
|
max_digits=12,
|
||||||
|
decimal_places=2,
|
||||||
|
null=False,
|
||||||
|
blank=False,
|
||||||
|
default=0,
|
||||||
|
verbose_name=_("minimum transaction amount"),
|
||||||
)
|
)
|
||||||
maximum_transaction_amount = FloatField(
|
maximum_transaction_amount = DecimalField(
|
||||||
null=False, blank=False, default=0, verbose_name=_("maximum transaction amount")
|
max_digits=12,
|
||||||
|
decimal_places=2,
|
||||||
|
null=False,
|
||||||
|
blank=False,
|
||||||
|
default=0,
|
||||||
|
verbose_name=_("maximum transaction amount"),
|
||||||
)
|
)
|
||||||
daily_limit = PositiveIntegerField(
|
daily_limit = PositiveIntegerField(
|
||||||
null=False,
|
null=False,
|
||||||
|
|
|
||||||
|
|
@ -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 rest_framework.serializers import ModelSerializer, Serializer
|
||||||
|
|
||||||
from engine.payments.models import Transaction
|
from engine.payments.models import Transaction
|
||||||
|
|
||||||
|
|
||||||
class DepositSerializer(Serializer):
|
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):
|
class TransactionSerializer(ModelSerializer):
|
||||||
|
|
@ -32,5 +40,5 @@ class TransactionProcessSerializer(ModelSerializer):
|
||||||
|
|
||||||
|
|
||||||
class LimitsSerializer(Serializer):
|
class LimitsSerializer(Serializer):
|
||||||
min_amount = FloatField(read_only=True)
|
min_amount = DecimalField(max_digits=12, decimal_places=2, read_only=True)
|
||||||
max_amount = FloatField(read_only=True)
|
max_amount = DecimalField(max_digits=12, decimal_places=2, read_only=True)
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ from engine.vibes_auth.emailing.tasks import (
|
||||||
)
|
)
|
||||||
from engine.vibes_auth.forms import UserForm
|
from engine.vibes_auth.forms import UserForm
|
||||||
from engine.vibes_auth.models import (
|
from engine.vibes_auth.models import (
|
||||||
|
AdminOTPCode,
|
||||||
BlacklistedToken,
|
BlacklistedToken,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChatThread,
|
ChatThread,
|
||||||
|
|
@ -403,6 +404,17 @@ class OutstandingTokenAdmin(BaseOutstandingTokenAdmin, ModelAdmin):
|
||||||
pass
|
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.register(User, UserAdmin)
|
||||||
|
|
||||||
admin.site.unregister(BaseGroup)
|
admin.site.unregister(BaseGroup)
|
||||||
|
|
|
||||||
102
engine/vibes_auth/admin_site.py
Normal file
102
engine/vibes_auth/admin_site.py
Normal 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)
|
||||||
|
|
@ -11,4 +11,12 @@ class VibesAuthConfig(AppConfig):
|
||||||
hide = False
|
hide = False
|
||||||
|
|
||||||
def ready(self) -> None:
|
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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.utils.emailing import send_reset_password_email_task
|
||||||
from engine.vibes_auth.validators import is_valid_email, is_valid_phone_number
|
from engine.vibes_auth.validators import is_valid_email, is_valid_phone_number
|
||||||
|
from schon.utils.ratelimit import graphql_ratelimit
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -49,6 +50,7 @@ class CreateUser(Mutation):
|
||||||
|
|
||||||
success = Boolean()
|
success = Boolean()
|
||||||
|
|
||||||
|
@graphql_ratelimit(rate="5/h")
|
||||||
def mutate(
|
def mutate(
|
||||||
self,
|
self,
|
||||||
info,
|
info,
|
||||||
|
|
@ -220,6 +222,7 @@ class ObtainJSONWebToken(Mutation):
|
||||||
refresh_token = String(required=True)
|
refresh_token = String(required=True)
|
||||||
access_token = String(required=True)
|
access_token = String(required=True)
|
||||||
|
|
||||||
|
@graphql_ratelimit(rate="10/h")
|
||||||
def mutate(self, info, email, password):
|
def mutate(self, info, email, password):
|
||||||
serializer = TokenObtainPairSerializer(
|
serializer = TokenObtainPairSerializer(
|
||||||
data={"email": email, "password": password}, retrieve_user=False
|
data={"email": email, "password": password}, retrieve_user=False
|
||||||
|
|
@ -244,6 +247,7 @@ class RefreshJSONWebToken(Mutation):
|
||||||
user = Field(UserType)
|
user = Field(UserType)
|
||||||
refresh_token = String()
|
refresh_token = String()
|
||||||
|
|
||||||
|
@graphql_ratelimit(rate="10/h")
|
||||||
def mutate(self, info, refresh_token):
|
def mutate(self, info, refresh_token):
|
||||||
serializer = TokenRefreshSerializer(
|
serializer = TokenRefreshSerializer(
|
||||||
data={"refresh": refresh_token}, retrieve_user=False
|
data={"refresh": refresh_token}, retrieve_user=False
|
||||||
|
|
@ -294,6 +298,7 @@ class ActivateUser(Mutation):
|
||||||
|
|
||||||
success = Boolean()
|
success = Boolean()
|
||||||
|
|
||||||
|
@graphql_ratelimit(rate="5/h")
|
||||||
def mutate(self, info, uid, token):
|
def mutate(self, info, uid, token):
|
||||||
try:
|
try:
|
||||||
token = urlsafe_base64_decode(token).decode()
|
token = urlsafe_base64_decode(token).decode()
|
||||||
|
|
@ -323,6 +328,7 @@ class ResetPassword(Mutation):
|
||||||
|
|
||||||
success = Boolean()
|
success = Boolean()
|
||||||
|
|
||||||
|
@graphql_ratelimit(rate="4/h")
|
||||||
def mutate(self, info, email):
|
def mutate(self, info, email):
|
||||||
try:
|
try:
|
||||||
user = User.objects.get(email=email)
|
user = User.objects.get(email=email)
|
||||||
|
|
@ -345,6 +351,7 @@ class ConfirmResetPassword(Mutation):
|
||||||
|
|
||||||
success = Boolean()
|
success = Boolean()
|
||||||
|
|
||||||
|
@graphql_ratelimit(rate="5/h")
|
||||||
def mutate(self, info, uid, token, password, confirm_password):
|
def mutate(self, info, uid, token, password, confirm_password):
|
||||||
try:
|
try:
|
||||||
if not compare_digest(password, confirm_password):
|
if not compare_digest(password, confirm_password):
|
||||||
|
|
@ -376,16 +383,28 @@ class ConfirmResetPassword(Mutation):
|
||||||
raise BadRequest(_(f"something went wrong: {e!s}")) from e
|
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 UploadAvatar(Mutation):
|
||||||
class Arguments:
|
class Arguments:
|
||||||
file = Upload(required=True)
|
file = Upload(required=True)
|
||||||
|
|
||||||
user = Field(UserType)
|
user = Field(UserType)
|
||||||
|
|
||||||
|
@graphql_ratelimit(rate="3/h")
|
||||||
def mutate(self, info, file):
|
def mutate(self, info, file):
|
||||||
if not info.context.user.is_authenticated:
|
if not info.context.user.is_authenticated:
|
||||||
raise PermissionDenied(permission_denied_message)
|
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:
|
try:
|
||||||
info.context.user.avatar = file
|
info.context.user.avatar = file
|
||||||
info.context.user.save()
|
info.context.user.save()
|
||||||
|
|
|
||||||
0
engine/vibes_auth/management/__init__.py
Normal file
0
engine/vibes_auth/management/__init__.py
Normal file
0
engine/vibes_auth/management/commands/__init__.py
Normal file
0
engine/vibes_auth/management/commands/__init__.py
Normal file
26
engine/vibes_auth/management/commands/activate_user.py
Normal file
26
engine/vibes_auth/management/commands/activate_user.py
Normal 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.'))
|
||||||
25
engine/vibes_auth/management/commands/get_otp.py
Normal file
25
engine/vibes_auth/management/commands/get_otp.py
Normal 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.")
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -94,7 +94,9 @@ class User(AbstractUser, NiceModel):
|
||||||
default=False,
|
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(
|
activation_token_created = DateTimeField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
|
|
@ -148,17 +150,25 @@ class User(AbstractUser, NiceModel):
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self._state.adding and self.activation_token_created is None:
|
if self._state.adding and self.activation_token_created is None:
|
||||||
self.activation_token_created = timezone.now()
|
self.activation_token_created = timezone.now()
|
||||||
|
if self._state.adding and not self.activation_token:
|
||||||
|
self.refresh_activation_token()
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def refresh_activation_token(self) -> None:
|
def refresh_activation_token(self) -> str:
|
||||||
"""Generate a fresh activation token and update its timestamp."""
|
"""Generate a fresh activation token, store its hash, return raw token."""
|
||||||
self.activation_token = uuid4()
|
import hashlib
|
||||||
|
|
||||||
|
raw_token = str(uuid4())
|
||||||
|
self.activation_token = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||||
self.activation_token_created = timezone.now()
|
self.activation_token_created = timezone.now()
|
||||||
|
return raw_token
|
||||||
|
|
||||||
def check_token(self, token) -> bool:
|
def check_token(self, token) -> bool:
|
||||||
|
import hashlib
|
||||||
from datetime import timedelta
|
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
|
return False
|
||||||
if self.activation_token_created:
|
if self.activation_token_created:
|
||||||
if timezone.now() > self.activation_token_created + timedelta(hours=24):
|
if timezone.now() > self.activation_token_created + timedelta(hours=24):
|
||||||
|
|
@ -174,6 +184,25 @@ class User(AbstractUser, NiceModel):
|
||||||
verbose_name_plural = _("users")
|
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):
|
class ChatThread(NiceModel):
|
||||||
user = ForeignKey(
|
user = ForeignKey(
|
||||||
User, null=True, blank=True, on_delete=SET_NULL, related_name="chat_threads"
|
User, null=True, blank=True, on_delete=SET_NULL, related_name="chat_threads"
|
||||||
|
|
|
||||||
31
engine/vibes_auth/templates/admin/verify_otp.html
Normal file
31
engine/vibes_auth/templates/admin/verify_otp.html
Normal 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 %}
|
||||||
|
|
@ -19,6 +19,9 @@ def send_verification_email_task(user_pk: str) -> tuple[bool, str]:
|
||||||
user = User.objects.get(pk=user_pk)
|
user = User.objects.get(pk=user_pk)
|
||||||
user.refresh_from_db()
|
user.refresh_from_db()
|
||||||
|
|
||||||
|
raw_token = user.refresh_activation_token()
|
||||||
|
user.save(update_fields=["activation_token", "activation_token_created"])
|
||||||
|
|
||||||
activate(user.language)
|
activate(user.language)
|
||||||
|
|
||||||
email_subject = _(f"{settings.PROJECT_NAME} | Activate Account")
|
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,
|
"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))}"
|
"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,
|
"project_name": settings.PROJECT_NAME,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
36
engine/vibes_auth/utils/otp.py
Normal file
36
engine/vibes_auth/utils/otp.py
Normal 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)
|
||||||
|
|
@ -139,8 +139,6 @@ class UserViewSet(
|
||||||
pending = User.objects.get(
|
pending = User.objects.get(
|
||||||
email=email, is_active=False, is_verified=False
|
email=email, is_active=False, is_verified=False
|
||||||
)
|
)
|
||||||
pending.refresh_activation_token()
|
|
||||||
pending.save()
|
|
||||||
send_verification_email_task.delay(user_pk=str(pending.uuid))
|
send_verification_email_task.delay(user_pk=str(pending.uuid))
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ dependencies = [
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
worker = [
|
worker = [
|
||||||
"celery==5.6.2",
|
"celery==5.6.2",
|
||||||
"django-celery-beat==2.8.1",
|
"django-celery-beat==2.9.0",
|
||||||
"django-celery-results==2.6.0",
|
"django-celery-results==2.6.0",
|
||||||
]
|
]
|
||||||
linting = [
|
linting = [
|
||||||
|
|
|
||||||
28
schon/utils/ratelimit.py
Normal file
28
schon/utils/ratelimit.py
Normal 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
125
uv.lock
|
|
@ -634,14 +634,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cron-descriptor"
|
name = "cron-descriptor"
|
||||||
version = "2.0.6"
|
version = "1.4.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
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" }
|
||||||
{ 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" }
|
|
||||||
wheels = [
|
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]]
|
[[package]]
|
||||||
|
|
@ -766,7 +763,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-celery-beat"
|
name = "django-celery-beat"
|
||||||
version = "2.8.1"
|
version = "2.9.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "celery" },
|
{ name = "celery" },
|
||||||
|
|
@ -776,9 +773,9 @@ dependencies = [
|
||||||
{ name = "python-crontab" },
|
{ name = "python-crontab" },
|
||||||
{ name = "tzdata" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
|
|
@ -3460,7 +3457,7 @@ requires-dist = [
|
||||||
{ name = "cryptography", specifier = "==46.0.5" },
|
{ name = "cryptography", specifier = "==46.0.5" },
|
||||||
{ name = "django", specifier = "==5.2.11" },
|
{ name = "django", specifier = "==5.2.11" },
|
||||||
{ name = "django-cacheops", specifier = "==7.2" },
|
{ 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-celery-results", marker = "extra == 'worker'", specifier = "==2.6.0" },
|
||||||
{ name = "django-constance", specifier = "==4.3.4" },
|
{ name = "django-constance", specifier = "==4.3.4" },
|
||||||
{ name = "django-cors-headers", specifier = "==4.9.0" },
|
{ name = "django-cors-headers", specifier = "==4.9.0" },
|
||||||
|
|
@ -4102,64 +4099,70 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yarl"
|
name = "yarl"
|
||||||
version = "1.22.0"
|
version = "1.23.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "multidict" },
|
{ name = "multidict" },
|
||||||
{ name = "propcache" },
|
{ 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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
|
[[package]]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue