feat: add avatars

This commit is contained in:
2026-05-14 23:46:26 +03:00
parent 20ddb196ff
commit d426b02d25
26 changed files with 857 additions and 162 deletions

View File

@@ -20,4 +20,6 @@ dependencies = [
"orjson==3.11.7",
"bcrypt==5.0.0",
"faststream[rabbit]==0.6.6",
"aiobotocore==3.7.0",
"pillow==12.2.0",
]

View File

@@ -36,4 +36,8 @@ class IUserRepository(ABC):
@abstractmethod
async def email_exists(self, email: str) -> bool:
raise NotImplementedError
raise NotImplementedError
@abstractmethod
async def set_avatar_link(self, user_id: str, avatar_link: str) -> UserEntity:
raise NotImplementedError

View File

@@ -1,5 +1,6 @@
from src.application.commands.get_me import GetMeCommand
from src.application.commands.set_phone import SetPhoneCommand
from src.application.commands.set_avatar import SetAvatarCommand
from src.application.commands.set_encrypted_mnemonic_start import SetEncryptedMnemonicStartCommand
from src.application.commands.set_encrypted_mnemonic_complete import SetEncryptedMnemonicCompleteCommand
from src.application.commands.update_bank_details_start import UpdateBankDetailsStartCommand

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
from datetime import datetime, timezone
from PIL import UnidentifiedImageError
from ulid import ULID
from botocore.exceptions import ClientError
from src.application.abstractions import IUnitOfWork
from src.application.contracts import ICache, ILogger, IS3
from src.application.domain.entities import UserEntity
from src.application.domain.exceptions import BadRequestException, ServiceUnavailableException
from src.infrastructure.config import settings
from src.infrastructure.database.decorators import transactional
from src.infrastructure.media.webp import image_bytes_to_webp
class SetAvatarCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger, cache: ICache, s3: IS3):
self._unit_of_work = unit_of_work
self._logger = logger
self._cache = cache
self._s3 = s3
async def __call__(self, user_id: str, image_bytes: bytes) -> tuple[UserEntity, int]:
try:
webp_bytes = image_bytes_to_webp(image_bytes)
except UnidentifiedImageError as exc:
raise BadRequestException(message='Unsupported or corrupt image') from exc
except Exception as exc:
self._logger.exception(str(exc))
raise BadRequestException(message='Could not process image') from exc
pid = user_id.replace('/', '').replace('.', '_')
name_id = str(ULID())
ts = int(datetime.now(timezone.utc).timestamp() * 1000)
prefix = settings.S3_AVATAR_KEY_PREFIX.strip().strip('/')
fname = f'{name_id}_{pid}_{ts}.webp'
object_key = f'{prefix}/{fname}' if prefix else fname
try:
url = await self._s3.upload_bytes(key=object_key, body=webp_bytes, content_type='image/webp')
except ClientError as exc:
self._logger.exception(str(exc))
raise ServiceUnavailableException(message='S3 upload failed') from exc
user = await self._save_avatar_link(user_id, url)
await self._cache.set_user(user_id, user)
self._logger.info(f'Avatar set for user_id={user_id} key={object_key}')
return user, len(webp_bytes)
@transactional
async def _save_avatar_link(self, user_id: str, avatar_link: str) -> UserEntity:
return await self._unit_of_work.user_repository.set_avatar_link(user_id, avatar_link)

View File

@@ -3,4 +3,5 @@ from src.application.contracts.i_jwt_service import IJwtService
from src.application.contracts.i_csrf_service import ICsrfService
from src.application.contracts.i_cache import ICache
from src.application.contracts.i_hash_service import IHashService
from src.application.contracts.i_queue_messanger import IQueueMessanger
from src.application.contracts.i_queue_messanger import IQueueMessanger
from src.application.contracts.i_s3 import IS3

View File

@@ -0,0 +1,9 @@
from __future__ import annotations
from typing import Protocol, runtime_checkable
@runtime_checkable
class IS3(Protocol):
async def upload_bytes(self, *, key: str, body: bytes, content_type: str) -> str:
...

View File

@@ -20,6 +20,7 @@ class UserEntity:
passport_data: str | None = None
inn: str | None = None
erc20: str | None = None
avatar_link: str | None = None
kyc_verified: bool | None = None
is_deleted: bool | None = None

View File

@@ -42,6 +42,7 @@ class KeydbCache(ICache):
'passport_data': user.passport_data,
'inn': user.inn,
'erc20': user.erc20,
'avatar_link': user.avatar_link,
'kyc_verified': user.kyc_verified,
'is_deleted': user.is_deleted,
'created_at': user.created_at.isoformat() if user.created_at else None,

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from functools import lru_cache
from typing import Any, List, Literal
from typing import Any, List, Literal, Mapping
from urllib.parse import quote
from dotenv import find_dotenv, load_dotenv
@@ -39,6 +39,7 @@ class Settings(BaseSettings):
VAULT_DOCS_SECRET_PATH: str = 'docs'
VAULT_JWT_KID_PATH: str = 'jwt/kid'
VAULT_JWT_KIDS_PREFIX: str = 'jwt/kids'
VAULT_S3_SECRET_PATH: str = 's3/avatars'
DATABASE_URL_DIRECT: str | None = Field(default=None, validation_alias='DATABASE_URL')
DATABASE_HOST: str = ''
@@ -89,6 +90,15 @@ class Settings(BaseSettings):
RABBIT_CONNECT_TIMEOUT: int = 5
RABBIT_EMAIL_CODE_QUEUE: str = 'email.verification_code'
S3_BUCKET: str = ''
S3_REGION: str = 'us-east-1'
S3_ACCESS_KEY_ID: str = ''
S3_SECRET_ACCESS_KEY: str = ''
S3_ENDPOINT_URL: str = ''
S3_PUBLIC_BASE_URL: str = ''
S3_REGRU_PUBLIC_WEBSITE_HOST: bool = True
S3_AVATAR_KEY_PREFIX: str = 'avatars'
LOG_LEVEL: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO'
LOG_FORMAT: Literal['JSON', 'TEXT'] = 'JSON'
@@ -99,7 +109,78 @@ class Settings(BaseSettings):
return str(value)
return ''
def _reset_s3_config(self) -> None:
object.__setattr__(self, 'S3_BUCKET', '')
object.__setattr__(self, 'S3_ACCESS_KEY_ID', '')
object.__setattr__(self, 'S3_SECRET_ACCESS_KEY', '')
object.__setattr__(self, 'S3_ENDPOINT_URL', '')
object.__setattr__(self, 'S3_PUBLIC_BASE_URL', '')
object.__setattr__(self, 'S3_REGION', 'us-east-1')
object.__setattr__(self, 'S3_REGRU_PUBLIC_WEBSITE_HOST', True)
object.__setattr__(self, 'S3_AVATAR_KEY_PREFIX', 'avatars')
@staticmethod
def _vault_kv(mapping: Mapping[str, Any], *keys: str) -> Any:
for k in keys:
if k in mapping and mapping[k] is not None:
return mapping[k]
return None
def _apply_s3_from_vault_secret(self, s3: dict[str, Any]) -> None:
bucket_name = (
self._vault_kv(s3, 'bucket_name', 'BUCKET_NAME', 'bucket')
or self._vault_kv(s3, 'S3_BUCKET', 'bucketName')
)
endpoint_url = (
self._vault_kv(s3, 's3_endpoint_url', 'S3_ENDPOINT_URL', 'endpoint_url', 'ENDPOINT_URL')
or self._vault_kv(s3, 'endpoint')
)
ak = (
self._vault_kv(s3, 's3_access_key_id', 'S3_ACCESS_KEY_ID', 'ACCESS_KEY_ID', 'access_key_id')
or self._vault_kv(s3, 'AWS_ACCESS_KEY_ID')
)
sk = (
self._vault_kv(s3, 's3_secret_access_key', 'S3_SECRET_ACCESS_KEY', 'SECRET_ACCESS_KEY')
or self._vault_kv(s3, 'AWS_SECRET_ACCESS_KEY')
)
if bucket_name is None or str(bucket_name).strip() == '':
raise ValueError('Vault S3 secret must contain bucket_name')
if endpoint_url is None or str(endpoint_url).strip() == '':
raise ValueError('Vault S3 secret must contain s3_endpoint_url')
if ak is None or str(ak).strip() == '':
raise ValueError('Vault S3 secret must contain s3_access_key_id')
if sk is None or str(sk).strip() == '':
raise ValueError('Vault S3 secret must contain s3_secret_access_key')
object.__setattr__(self, 'S3_BUCKET', str(bucket_name).strip())
object.__setattr__(self, 'S3_ENDPOINT_URL', str(endpoint_url).strip())
object.__setattr__(self, 'S3_ACCESS_KEY_ID', str(ak).strip())
object.__setattr__(self, 'S3_SECRET_ACCESS_KEY', str(sk).strip())
region = (
self._vault_kv(s3, 's3_region', 'S3_REGION', 'region')
)
if region is not None and str(region).strip() != '':
object.__setattr__(self, 'S3_REGION', str(region).strip())
public_base = (
self._vault_kv(s3, 's3_public_base_url', 'S3_PUBLIC_BASE_URL', 'public_base_url')
or self._vault_kv(s3, 'public_url')
)
if public_base is not None and str(public_base).strip() != '':
object.__setattr__(self, 'S3_PUBLIC_BASE_URL', str(public_base).strip())
prefix = self._vault_kv(s3, 'avatar_key_prefix', 'S3_AVATAR_KEY_PREFIX', 's3_avatar_key_prefix')
if prefix is not None and str(prefix).strip() != '':
object.__setattr__(self, 'S3_AVATAR_KEY_PREFIX', str(prefix).strip())
rf = (
self._vault_kv(s3, 's3_reg_ru_public_website_host', 'S3_REGRU_PUBLIC_WEBSITE_HOST')
)
if rf is not None:
v = str(rf).strip().lower()
if v in {'1', 'true', 'yes', 'on'}:
object.__setattr__(self, 'S3_REGRU_PUBLIC_WEBSITE_HOST', True)
elif v in {'0', 'false', 'no', 'off'}:
object.__setattr__(self, 'S3_REGRU_PUBLIC_WEBSITE_HOST', False)
def model_post_init(self, __context: Any) -> None:
self._reset_s3_config()
if not self.VAULT_ROLE_ID.strip() or not self.VAULT_SECRET_ID.strip():
if not self.DATABASE_URL:
raise ValueError(
@@ -164,6 +245,16 @@ class Settings(BaseSettings):
if p is not None:
object.__setattr__(self, 'DOCS_PASSWORD', str(p))
s3_rel_path = self.VAULT_S3_SECRET_PATH.strip()
if s3_rel_path:
try:
s3_secret_data = client.read_secret(s3_rel_path)
except Exception as exc:
raise ValueError(
f'Vault S3 secret not readable at mount {self.VAULT_MOUNT_POINT}/{s3_rel_path}: {exc!r}'
) from exc
self._apply_s3_from_vault_secret(s3_secret_data)
if not self.DATABASE_URL:
raise ValueError('Database URL could not be built from Vault database secret')

View File

@@ -23,6 +23,7 @@ class UserModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin, SoftDeleteMixin
passport_data: Mapped[str | None] = mapped_column(String(255), nullable=True)
inn: Mapped[str | None] = mapped_column(String(12), nullable=True)
erc20: Mapped[str | None] = mapped_column(String(255), nullable=True)
avatar_link: Mapped[str | None] = mapped_column(String(2048), nullable=True, default=None)
kyc_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='false', default=False)
kyc_verified_at: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View File

@@ -44,6 +44,7 @@ class UserRepository(IUserRepository):
passport_data=user.passport_data,
inn=user.inn,
erc20=user.erc20,
avatar_link=user.avatar_link,
kyc_verified_at=user.kyc_verified_at,
kyc_verified=user.kyc_verified,
is_deleted=user.is_deleted,
@@ -88,6 +89,9 @@ class UserRepository(IUserRepository):
async def set_encrypted_mnemonic(self, user_id: str, encrypted_mnemonic: str) -> UserEntity:
return await self._update_field(user_id, encrypted_mnemonic=encrypted_mnemonic)
async def set_avatar_link(self, user_id: str, avatar_link: str) -> UserEntity:
return await self._update_field(user_id, avatar_link=avatar_link)
async def get_password_hash(self, user_id: str) -> str:
try:
user = await self._get_active_user(user_id)

View File

@@ -0,0 +1,18 @@
from __future__ import annotations
from io import BytesIO
from PIL import Image
def image_bytes_to_webp(raw: bytes, *, quality: int = 82) -> bytes:
im = Image.open(BytesIO(raw))
if im.mode == 'P':
im = im.convert('RGBA')
elif im.mode == 'LA':
im = im.convert('RGBA')
elif im.mode not in ('RGBA', 'RGB'):
im = im.convert('RGB')
out = BytesIO()
im.save(out, format='WEBP', quality=quality)
return out.getvalue()

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
from aiobotocore.session import get_session
class S3Service:
def __init__(
self,
*,
bucket: str,
region: str,
access_key_id: str | None,
secret_access_key: str | None,
public_base_url: str | None,
endpoint_url: str | None,
use_reg_ru_website_public_host: bool,
):
self._bucket = bucket
self._region = region or 'us-east-1'
self._access_key_id = access_key_id
self._secret_access_key = secret_access_key
pb = (public_base_url or '').strip().rstrip('/')
self._public_base_url = pb if pb else None
self._endpoint_url = endpoint_url.strip().rstrip('/') if endpoint_url and endpoint_url.strip() else None
self._use_reg_ru_website_public_host = use_reg_ru_website_public_host
def _object_url(self, key: str) -> str:
if self._public_base_url:
return f'{self._public_base_url}/{key}'
endpoint = self._endpoint_url
if endpoint:
if (
self._use_reg_ru_website_public_host
and 's3.regru.cloud' in endpoint.lower()
):
return f'https://{self._bucket}.website.regru.cloud/{key}'
return f'{endpoint}/{self._bucket}/{key}'
region = self._region
if region == 'us-east-1':
host = 's3.amazonaws.com'
else:
host = f's3.{region}.amazonaws.com'
return f'https://{self._bucket}.{host}/{key}'
async def upload_bytes(self, *, key: str, body: bytes, content_type: str) -> str:
session = get_session()
kw: dict[str, object] = {'region_name': self._region}
aid = self._access_key_id
sk = self._secret_access_key
ep = self._endpoint_url
if aid:
kw['aws_access_key_id'] = aid
if sk:
kw['aws_secret_access_key'] = sk
if ep:
kw['endpoint_url'] = ep
async with session.create_client('s3', **kw) as client:
await client.put_object(
Bucket=self._bucket,
Key=key,
Body=body,
ContentType=content_type,
)
return self._object_url(key)

View File

@@ -64,12 +64,3 @@ class VaultClient:
return self.read_secret(path)
except (hvac.exceptions.InvalidPath, hvac.exceptions.Forbidden, hvac.exceptions.Unauthorized):
return {}
result: dict[str, Any] = {}
for path in paths:
if not path:
continue
try:
result.update(self.read_secret(path))
except (hvac.exceptions.InvalidPath, hvac.exceptions.Forbidden, hvac.exceptions.Unauthorized):
continue
return result

View File

@@ -1,6 +1,7 @@
from src.presentation.dependencies.commands import (
get_get_me_command,
get_set_phone_command,
get_set_avatar_command,
get_set_encrypted_mnemonic_start_command,
get_set_encrypted_mnemonic_complete_command,
get_update_bank_details_start_command,

View File

@@ -1,11 +1,12 @@
from fastapi import Depends
from src.application.abstractions import IUnitOfWork
from src.application.commands import GetMeCommand, SetPhoneCommand, SetEncryptedMnemonicStartCommand, SetEncryptedMnemonicCompleteCommand, UpdateBankDetailsStartCommand, UpdateBankDetailsCompleteCommand, ChangePasswordStartCommand, ChangePasswordCompleteCommand, ChangeEmailStartCommand, ChangeEmailConfirmOldCommand, ChangeEmailCompleteCommand
from src.application.contracts import ILogger, ICache, IQueueMessanger, IHashService
from src.application.commands import GetMeCommand, SetPhoneCommand, SetAvatarCommand, SetEncryptedMnemonicStartCommand, SetEncryptedMnemonicCompleteCommand, UpdateBankDetailsStartCommand, UpdateBankDetailsCompleteCommand, ChangePasswordStartCommand, ChangePasswordCompleteCommand, ChangeEmailStartCommand, ChangeEmailConfirmOldCommand, ChangeEmailCompleteCommand
from src.application.contracts import ILogger, ICache, IQueueMessanger, IHashService, IS3
from src.presentation.dependencies.cache import get_cache
from src.presentation.dependencies.logger import get_logger
from src.presentation.dependencies.queue_messanger import get_rabbit
from src.presentation.dependencies.security import get_hash_service
from src.presentation.dependencies.s3_storage import get_s3_storage
from src.presentation.dependencies.unit_of_work import get_unit_of_work
@@ -25,6 +26,15 @@ def get_set_phone_command(
return SetPhoneCommand(logger=logger, unit_of_work=unit_of_work, cache=cache)
def get_set_avatar_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
cache: ICache = Depends(get_cache),
s3: IS3 = Depends(get_s3_storage),
) -> SetAvatarCommand:
return SetAvatarCommand(unit_of_work=unit_of_work, logger=logger, cache=cache, s3=s3)
def get_set_encrypted_mnemonic_start_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from src.application.contracts import IS3
from src.application.domain.exceptions import ServiceUnavailableException
from src.infrastructure.config import settings
from src.infrastructure.storage.s3_service import S3Service
_s3singleton: IS3 | None = None
def get_s3_storage() -> IS3:
global _s3singleton
if _s3singleton is not None:
return _s3singleton
if not settings.S3_BUCKET.strip():
raise ServiceUnavailableException(message='S3 is not configured')
endpoint = settings.S3_ENDPOINT_URL.strip()
pub = settings.S3_PUBLIC_BASE_URL.strip()
if not pub and not endpoint:
raise ServiceUnavailableException(message='Set S3_ENDPOINT_URL (or S3_PUBLIC_BASE_URL for a custom CDN base)')
ak = settings.S3_ACCESS_KEY_ID.strip() or None
sk = settings.S3_SECRET_ACCESS_KEY.strip() or None
_s3singleton = S3Service(
bucket=settings.S3_BUCKET.strip(),
region=settings.S3_REGION.strip() or 'us-east-1',
access_key_id=ak,
secret_access_key=sk,
public_base_url=pub if pub else None,
endpoint_url=endpoint if endpoint else None,
use_reg_ru_website_public_host=settings.S3_REGRU_PUBLIC_WEBSITE_HOST,
)
return _s3singleton

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Request, Depends
from fastapi import APIRouter, Depends, Request
from fastapi.responses import ORJSONResponse
from starlette import status
from src.application.commands.get_me import GetMeCommand
@@ -8,37 +8,42 @@ from src.presentation.decorators import require_access_token
from src.presentation.dependencies.commands import get_get_me_command
from src.presentation.dependencies.logger import get_logger
from src.presentation.decorators import csrf_protect
from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload
from src.presentation.schemas.me_public import MeUserPublicResponse
from src.presentation.serializers import me_user_public
account_router = APIRouter()
@account_router.get(path='/', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
@account_router.get(
path='/',
response_class=ORJSONResponse,
status_code=status.HTTP_200_OK,
response_model=MeUserPublicResponse,
summary='Текущий пользователь',
description='Возвращает профиль авторизованного пользователя. Защита CSRF: cookie csrf_token и заголовок X-CSRF-Token с тем же значением.',
responses={
status.HTTP_401_UNAUTHORIZED: {
'description': 'Не передан или неверен access token.',
'model': ApiErrorPayload,
},
status.HTTP_403_FORBIDDEN: {
'description': 'Ошибка проверки CSRF (нет пары cookie/заголовка, несовпадение или просрочен токен).',
'model': ApiErrorPayload,
},
status.HTTP_422_UNPROCESSABLE_ENTITY: {
'description': 'Ошибка валидации входных данных (например, заголовков).',
'model': ApiValidationErrorsPayload,
},
},
)
@csrf_protect()
async def me(
request: Request,
auth: AuthContext = Depends(require_access_token),
command: GetMeCommand = Depends(get_get_me_command),
logger: ILogger = Depends(get_logger),
):
) -> MeUserPublicResponse:
user = await command(user_id=auth.user_id)
logger.info(f'Get user: {user.id}')
return ORJSONResponse(
status_code=status.HTTP_200_OK,
content={
'id': user.id,
'email': user.email,
'first_name': user.first_name,
'middle_name': user.middle_name,
'last_name': user.last_name,
'birth_date': str(user.birth_date) if user.birth_date else None,
'encrypted_mnemonic': user.encrypted_mnemonic,
'phone': user.phone,
'passport_data': user.passport_data,
'inn': user.inn,
'erc20': user.erc20,
'kyc_verified': user.kyc_verified,
'is_deleted': user.is_deleted,
'created_at': user.created_at.isoformat() if user.created_at else None,
'updated_at': user.updated_at.isoformat() if user.updated_at else None,
'kyc_verified_at': user.kyc_verified_at.isoformat() if user.kyc_verified_at else None,
}
)
return me_user_public(user)

View File

@@ -1,27 +1,50 @@
from fastapi import APIRouter, Request, Depends
from fastapi import APIRouter, Depends, Request
from fastapi.responses import ORJSONResponse
from starlette import status
from src.application.commands import SetPhoneCommand, SetEncryptedMnemonicStartCommand, SetEncryptedMnemonicCompleteCommand, UpdateBankDetailsStartCommand, UpdateBankDetailsCompleteCommand, ChangePasswordStartCommand, ChangePasswordCompleteCommand, ChangeEmailStartCommand, ChangeEmailConfirmOldCommand, ChangeEmailCompleteCommand
from src.application.commands import SetPhoneCommand, SetAvatarCommand
from src.application.domain.dto import AuthContext
from src.presentation.decorators import require_access_token
from src.presentation.dependencies import (
get_set_avatar_command,
get_set_phone_command,
get_set_encrypted_mnemonic_start_command,
get_set_encrypted_mnemonic_complete_command,
get_update_bank_details_start_command,
get_update_bank_details_complete_command,
get_change_password_start_command,
get_change_password_complete_command,
get_change_email_start_command,
get_change_email_confirm_old_command,
get_change_email_complete_command,
)
from src.presentation.schemas import SetPhoneRequest, EncryptedMnemonicConfirmRequest, BankConfirmRequest, ChangePasswordConfirmRequest, ChangeEmailConfirmOldRequest, ChangeEmailCompleteRequest
from src.presentation.schemas import SetAvatarRequest, SetPhoneRequest
from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload
from src.presentation.schemas.me_public import SetAvatarPublicResponse
from src.presentation.serializers import me_user_public
account_settings_router = APIRouter(prefix='/settings')
_SET_AVATAR_ERROR_RESPONSES: dict[int, dict[str, object]] = {
status.HTTP_400_BAD_REQUEST: {
'description': 'Битый или неподдерживаемый формат изображения, либо Base64 ошибочный.',
'model': ApiErrorPayload,
},
status.HTTP_401_UNAUTHORIZED: {
'description': 'Не передан или неверен access token.',
'model': ApiErrorPayload,
},
status.HTTP_404_NOT_FOUND: {
'description': 'Учётная запись не найдена.',
'model': ApiErrorPayload,
},
status.HTTP_422_UNPROCESSABLE_ENTITY: {
'description': 'Тело запроса не соответствует схеме (например, неверный Base64 или превышен размер).',
'model': ApiValidationErrorsPayload,
},
status.HTTP_500_INTERNAL_SERVER_ERROR: {
'description': 'Внутренняя ошибка сервера; клиенту отдаётся обобщённое сообщение.',
'model': ApiErrorPayload,
},
status.HTTP_503_SERVICE_UNAVAILABLE: {
'description': 'S3 не сконфигурирован, ошибка записи в хранилище или временная недоступность сервиса.',
'model': ApiErrorPayload,
},
}
@account_settings_router.patch(path='/phone', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
async def set_phone(
request: Request,
@@ -33,118 +56,143 @@ async def set_phone(
return ORJSONResponse(status_code=status.HTTP_200_OK, content={'phone': user.phone})
@account_settings_router.post(path='/encrypted-mnemonic/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
async def encrypted_mnemonic_start(
@account_settings_router.patch(
path='/avatar',
response_class=ORJSONResponse,
status_code=status.HTTP_200_OK,
response_model=SetAvatarPublicResponse,
summary='Обновить аватар',
description=(
'Принимает фото в Base64, сохраняет как WebP в объектном хранилище и записывает публичный URL в профиль.'
),
response_description=(
'Профиль пользователя в том же формате, что и GET /me, плюс размер сохранённого WebP.'
),
responses=_SET_AVATAR_ERROR_RESPONSES,
)
async def set_avatar(
request: Request,
body: SetAvatarRequest,
auth: AuthContext = Depends(require_access_token),
command: SetEncryptedMnemonicStartCommand = Depends(get_set_encrypted_mnemonic_start_command),
):
result = await command(user_id=auth.user_id)
return {'success': result}
command: SetAvatarCommand = Depends(get_set_avatar_command),
) -> SetAvatarPublicResponse:
user, webp_size = await command(user_id=auth.user_id, image_bytes=body.decoded_bytes)
pub = me_user_public(user)
return SetAvatarPublicResponse(**pub.model_dump(), webp_size_bytes=webp_size)
@account_settings_router.post(path='/encrypted-mnemonic/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
async def encrypted_mnemonic_complete(
request: Request,
body: EncryptedMnemonicConfirmRequest,
auth: AuthContext = Depends(require_access_token),
command: SetEncryptedMnemonicCompleteCommand = Depends(get_set_encrypted_mnemonic_complete_command),
):
user = await command(
user_id=auth.user_id,
code=body.code,
encrypted_mnemonic=body.encrypted_mnemonic,
)
return {'encrypted_mnemonic': user.encrypted_mnemonic}
@account_settings_router.post(path='/email/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
async def change_email_start(
request: Request,
auth: AuthContext = Depends(require_access_token),
command: ChangeEmailStartCommand = Depends(get_change_email_start_command),
):
result = await command(user_id=auth.user_id)
return {'success': result}
@account_settings_router.post(path='/email/confirm-old', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
async def change_email_confirm_old(
request: Request,
body: ChangeEmailConfirmOldRequest,
auth: AuthContext = Depends(require_access_token),
command: ChangeEmailConfirmOldCommand = Depends(get_change_email_confirm_old_command),
):
result = await command(user_id=auth.user_id, code=body.code, new_email=body.new_email)
return {'success': result}
@account_settings_router.post(path='/email/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
async def change_email_complete(
request: Request,
body: ChangeEmailCompleteRequest,
auth: AuthContext = Depends(require_access_token),
command: ChangeEmailCompleteCommand = Depends(get_change_email_complete_command),
):
result = await command(user_id=auth.user_id, code=body.code)
return {'success': result}
@account_settings_router.post(path='/password/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
async def change_password_start(
request: Request,
auth: AuthContext = Depends(require_access_token),
command: ChangePasswordStartCommand = Depends(get_change_password_start_command),
):
result = await command(user_id=auth.user_id)
return {'success': result}
@account_settings_router.post(path='/password/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
async def change_password_complete(
request: Request,
body: ChangePasswordConfirmRequest,
auth: AuthContext = Depends(require_access_token),
command: ChangePasswordCompleteCommand = Depends(get_change_password_complete_command),
):
result = await command(
user_id=auth.user_id,
code=body.code,
new_password=body.new_password,
confirm_password=body.confirm_password,
)
return {'success': result}
@account_settings_router.post(path='/bank/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
async def bank_details_start(
request: Request,
auth: AuthContext = Depends(require_access_token),
command: UpdateBankDetailsStartCommand = Depends(get_update_bank_details_start_command),
):
result = await command(user_id=auth.user_id)
return {'success': result}
@account_settings_router.post(path='/bank/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
async def bank_details_complete(
request: Request,
body: BankConfirmRequest,
auth: AuthContext = Depends(require_access_token),
command: UpdateBankDetailsCompleteCommand = Depends(get_update_bank_details_complete_command),
):
user = await command(
user_id=auth.user_id,
code=body.code,
passport_data=body.passport_data,
inn=body.inn,
erc20=body.erc20,
)
return ORJSONResponse(
status_code=status.HTTP_200_OK,
content={
'passport_data': user.passport_data,
'inn': user.inn,
'erc20': user.erc20,
},
)
#
# @account_settings_router.post(path='/encrypted-mnemonic/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
# async def encrypted_mnemonic_start(
# request: Request,
# auth: AuthContext = Depends(require_access_token),
# command: SetEncryptedMnemonicStartCommand = Depends(get_set_encrypted_mnemonic_start_command),
# ):
# result = await command(user_id=auth.user_id)
# return {'success': result}
#
#
# @account_settings_router.post(path='/encrypted-mnemonic/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
# async def encrypted_mnemonic_complete(
# request: Request,
# body: EncryptedMnemonicConfirmRequest,
# auth: AuthContext = Depends(require_access_token),
# command: SetEncryptedMnemonicCompleteCommand = Depends(get_set_encrypted_mnemonic_complete_command),
# ):
# user = await command(
# user_id=auth.user_id,
# code=body.code,
# encrypted_mnemonic=body.encrypted_mnemonic,
# )
# return {'encrypted_mnemonic': user.encrypted_mnemonic}
#
#
# @account_settings_router.post(path='/email/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
# async def change_email_start(
# request: Request,
# auth: AuthContext = Depends(require_access_token),
# command: ChangeEmailStartCommand = Depends(get_change_email_start_command),
# ):
# result = await command(user_id=auth.user_id)
# return {'success': result}
#
#
# @account_settings_router.post(path='/email/confirm-old', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
# async def change_email_confirm_old(
# request: Request,
# body: ChangeEmailConfirmOldRequest,
# auth: AuthContext = Depends(require_access_token),
# command: ChangeEmailConfirmOldCommand = Depends(get_change_email_confirm_old_command),
# ):
# result = await command(user_id=auth.user_id, code=body.code, new_email=body.new_email)
# return {'success': result}
#
#
# @account_settings_router.post(path='/email/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
# async def change_email_complete(
# request: Request,
# body: ChangeEmailCompleteRequest,
# auth: AuthContext = Depends(require_access_token),
# command: ChangeEmailCompleteCommand = Depends(get_change_email_complete_command),
# ):
# result = await command(user_id=auth.user_id, code=body.code)
# return {'success': result}
#
#
# @account_settings_router.post(path='/password/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
# async def change_password_start(
# request: Request,
# auth: AuthContext = Depends(require_access_token),
# command: ChangePasswordStartCommand = Depends(get_change_password_start_command),
# ):
# result = await command(user_id=auth.user_id)
# return {'success': result}
#
#
# @account_settings_router.post(path='/password/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
# async def change_password_complete(
# request: Request,
# body: ChangePasswordConfirmRequest,
# auth: AuthContext = Depends(require_access_token),
# command: ChangePasswordCompleteCommand = Depends(get_change_password_complete_command),
# ):
# result = await command(
# user_id=auth.user_id,
# code=body.code,
# new_password=body.new_password,
# confirm_password=body.confirm_password,
# )
# return {'success': result}
#
#
# @account_settings_router.post(path='/bank/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
# async def bank_details_start(
# request: Request,
# auth: AuthContext = Depends(require_access_token),
# command: UpdateBankDetailsStartCommand = Depends(get_update_bank_details_start_command),
# ):
# result = await command(user_id=auth.user_id)
# return {'success': result}
#
#
# @account_settings_router.post(path='/bank/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
# async def bank_details_complete(
# request: Request,
# body: BankConfirmRequest,
# auth: AuthContext = Depends(require_access_token),
# command: UpdateBankDetailsCompleteCommand = Depends(get_update_bank_details_complete_command),
# ):
# user = await command(
# user_id=auth.user_id,
# code=body.code,
# passport_data=body.passport_data,
# inn=body.inn,
# erc20=body.erc20,
# )
# return ORJSONResponse(
# status_code=status.HTTP_200_OK,
# content={
# 'passport_data': user.passport_data,
# 'inn': user.inn,
# 'erc20': user.erc20,
# },
# )

View File

@@ -1,3 +1,4 @@
from src.presentation.schemas.avatar import SetAvatarRequest
from src.presentation.schemas.phone import SetPhoneRequest
from src.presentation.schemas.bank import BankUpdateRequest, BankConfirmRequest
from src.presentation.schemas.encrypted_mnemonic import EncryptedMnemonicConfirmRequest

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from pydantic import BaseModel, Field
class ApiErrorPayload(BaseModel):
detail: str = Field(description='Текстовое описание ошибки для клиента')
class ValidationErrorDetailItem(BaseModel):
loc: list[str | int] = Field(description='Путь к полю, вызвавшему ошибку')
msg: str = Field(description='Сообщение')
type: str = Field(description='Тип ошибки валидации')
class ApiValidationErrorsPayload(BaseModel):
detail: list[ValidationErrorDetailItem] = Field(
description='Список ошибок валидации тела или параметров запроса'
)

View File

@@ -0,0 +1,69 @@
from __future__ import annotations
from typing import Any
import base64
import binascii
from pydantic import BaseModel, Field, model_validator
AVATAR_MAX_BYTES = 10 * 1024 * 1024
_IMAGE_SIGNATURES = (
b'\xff\xd8\xff',
b'\x89PNG\r\n\x1a\n',
b'GIF87a',
b'GIF89a',
)
def _avatar_payload_to_bytes(photo_base64: str) -> bytes:
s = photo_base64.strip()
if not s:
raise ValueError('photo_base64 must not be empty')
if s.startswith('data:'):
parts = s.split(',', 1)
if len(parts) != 2:
raise ValueError('Invalid data URL')
s = parts[1].strip()
try:
data = base64.b64decode(s, validate=True)
except binascii.Error as exc:
raise ValueError('Invalid base64') from exc
if len(data) > AVATAR_MAX_BYTES:
raise ValueError(f'Photo must not exceed {AVATAR_MAX_BYTES} bytes')
if len(data) < 12:
raise ValueError('Photo data is too small')
ok = False
for sig in _IMAGE_SIGNATURES:
if data.startswith(sig):
ok = True
break
if not ok and data.startswith(b'RIFF') and len(data) > 12 and data[8:12] == b'WEBP':
ok = True
if not ok:
raise ValueError('Photo must be JPEG, PNG, GIF or WebP')
return data
class SetAvatarRequest(BaseModel):
photo_base64: str = Field(
...,
description='Изображение JPEG, PNG, GIF или WebP в Base64; допустим data URL (data:image/...;base64,...). Максимум 10 МБ после декодирования.',
)
decoded_bytes: bytes = Field(exclude=True)
@model_validator(mode='before')
@classmethod
def _decode_input(cls, data: Any):
if not isinstance(data, dict):
return data
raw = data.get('photo_base64')
if raw is None:
return data
if not isinstance(raw, str):
raise ValueError('photo_base64 must be a string')
stripped = raw.strip()
decoded = _avatar_payload_to_bytes(stripped)
return {'photo_base64': stripped, 'decoded_bytes': decoded}

View File

@@ -0,0 +1,59 @@
from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, Field
from src.application.domain.entities import UserEntity
class MeUserPublicResponse(BaseModel):
model_config = ConfigDict(from_attributes=False)
id: str | None = Field(None, description='Идентификатор пользователя')
email: str | None = Field(None, description='Email')
first_name: str | None = Field(None, description='Имя')
middle_name: str | None = Field(None, description='Отчество')
last_name: str | None = Field(None, description='Фамилия')
birth_date: date | None = Field(None, description='Дата рождения')
encrypted_mnemonic: str | None = Field(None, description='Шифрованная мнемоника')
phone: str | None = Field(None, description='Телефон')
passport_data: str | None = Field(None, description='Паспортные данные')
inn: str | None = Field(None, description='ИНН')
erc20: str | None = Field(None, description='ERC-20 адрес')
avatar_link: str | None = Field(None, description='HTTPS-ссылка на текущий аватар в хранилище')
kyc_verified: bool | None = Field(None, description='Признак пройденного KYC')
is_deleted: bool | None = Field(None, description='Удалён ли аккаунт')
created_at: datetime | None = Field(None, description='Время создания записи')
updated_at: datetime | None = Field(None, description='Время последнего обновления')
kyc_verified_at: datetime | None = Field(None, description='Время подтверждения KYC')
@classmethod
def from_user(cls, user: UserEntity) -> MeUserPublicResponse:
return cls(
id=user.id,
email=user.email,
first_name=user.first_name,
middle_name=user.middle_name,
last_name=user.last_name,
birth_date=user.birth_date,
encrypted_mnemonic=user.encrypted_mnemonic,
phone=user.phone,
passport_data=user.passport_data,
inn=user.inn,
erc20=user.erc20,
avatar_link=user.avatar_link,
kyc_verified=user.kyc_verified,
is_deleted=user.is_deleted,
created_at=user.created_at,
updated_at=user.updated_at,
kyc_verified_at=user.kyc_verified_at,
)
class SetAvatarPublicResponse(MeUserPublicResponse):
webp_size_bytes: int = Field(
...,
ge=0,
description='Размер сохранённого файла аватара в формате WebP, байты',
)

View File

@@ -0,0 +1 @@
from src.presentation.serializers.me_user import me_user_payload, me_user_public

View File

@@ -0,0 +1,13 @@
from __future__ import annotations
from src.application.domain.entities import UserEntity
from src.presentation.schemas.me_public import MeUserPublicResponse
def me_user_public(user: UserEntity) -> MeUserPublicResponse:
return MeUserPublicResponse.from_user(user)
def me_user_payload(user: UserEntity) -> dict:
return me_user_public(user).model_dump(mode='json')

195
uv.lock generated
View File

@@ -15,6 +15,76 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/24/59a1644995f7a0245588a1761d4b515fc63b7a6038310ead2b07eb44cd8b/aio_pika-9.6.1-py3-none-any.whl", hash = "sha256:0fda50fbbdeb6c5b7399730a2286751074dfe6e52a20119a71aef112d4863fd1", size = 52022, upload-time = "2026-02-23T15:41:51.357Z" },
]
[[package]]
name = "aiobotocore"
version = "3.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "aioitertools" },
{ name = "botocore" },
{ name = "jmespath" },
{ name = "multidict" },
{ name = "python-dateutil" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/75/42cce839c2ec263ff74b10b650fe36b066fbb124cbee6f247eac0983e1ab/aiobotocore-3.7.0.tar.gz", hash = "sha256:c64d871ed5491a6571948dd48eabd185b46c6c23b64e3afd0c059fc7593ada30", size = 127054, upload-time = "2026-05-09T10:02:52.332Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/5f/85535dfb3cfd6442d66d1df1694062c5d6df02f895329e7e120b2a3d2b8b/aiobotocore-3.7.0-py3-none-any.whl", hash = "sha256:680bde7c64679a821a9312641b759d9497f790ba8b2e88c6959e6273ee765b8e", size = 89539, upload-time = "2026-05-09T10:02:50.389Z" },
]
[[package]]
name = "aiohappyeyeballs"
version = "2.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" },
]
[[package]]
name = "aiohttp"
version = "3.13.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
{ name = "aiosignal" },
{ name = "attrs" },
{ name = "frozenlist" },
{ name = "multidict" },
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" },
{ url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" },
{ url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" },
{ url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" },
{ url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" },
{ url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" },
{ url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" },
{ url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" },
{ url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" },
{ url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" },
{ url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" },
{ url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" },
{ url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" },
{ url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" },
{ url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" },
]
[[package]]
name = "aioitertools"
version = "0.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload-time = "2025-11-06T22:17:07.609Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" },
]
[[package]]
name = "aiormq"
version = "6.9.3"
@@ -28,6 +98,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/9b/be86ea5a73010b437bb5f9511d1fcbb828cdf8eddde0c6f2b38006b9ce92/aiormq-6.9.3-py3-none-any.whl", hash = "sha256:fe2e9f7c99d24dde5f7e1ca8a7da2dc5bab9ae5758fd7599b60d34b6b278926e", size = 27939, upload-time = "2026-02-22T21:04:48.208Z" },
]
[[package]]
name = "aiosignal"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "frozenlist" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
@@ -87,6 +170,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" },
]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "bcrypt"
version = "5.0.0"
@@ -130,6 +222,7 @@ name = "bit-users"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "aiobotocore" },
{ name = "apscheduler" },
{ name = "asyncpg" },
{ name = "bcrypt" },
@@ -140,6 +233,7 @@ dependencies = [
{ name = "hvac" },
{ name = "itsdangerous" },
{ name = "orjson" },
{ name = "pillow" },
{ name = "pydantic-settings" },
{ name = "python-jose" },
{ name = "python-ulid" },
@@ -150,6 +244,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "aiobotocore", specifier = "==3.7.0" },
{ name = "apscheduler", specifier = "==3.11.2" },
{ name = "asyncpg", specifier = "==0.31.0" },
{ name = "bcrypt", specifier = "==5.0.0" },
@@ -160,6 +255,7 @@ requires-dist = [
{ name = "hvac", specifier = "==2.4.0" },
{ name = "itsdangerous", specifier = "==2.2.0" },
{ name = "orjson", specifier = "==3.11.7" },
{ name = "pillow", specifier = "==12.2.0" },
{ name = "pydantic-settings", specifier = "==2.12.0" },
{ name = "python-jose", specifier = "==3.5.0" },
{ name = "python-ulid", specifier = "==3.1.0" },
@@ -168,6 +264,20 @@ requires-dist = [
{ name = "uvloop", marker = "sys_platform != 'win32'", specifier = "==0.22.1" },
]
[[package]]
name = "botocore"
version = "1.43.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/28/79/2f4be1896db3db7ccf44504253a175d56b6bd6b669619edc5147d1aa21ea/botocore-1.43.0.tar.gz", hash = "sha256:e933b31a2d644253e1d029d7d39e99ba41b87e29300534f189744cc438cdf928", size = 15286817, upload-time = "2026-04-29T22:07:31.723Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/4b/afc1fef8a43bafb139f57f73bbd70df82807af5934321e8112ae50668827/botocore-1.43.0-py3-none-any.whl", hash = "sha256:cc5b15eaec3c6eac05d8012cb5ef17ebe891beb88a16ca13c374bfaece1241e6", size = 14970102, upload-time = "2026-04-29T22:07:27Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
@@ -299,6 +409,31 @@ rabbit = [
{ name = "aio-pika" },
]
[[package]]
name = "frozenlist"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" },
{ url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" },
{ url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" },
{ url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" },
{ url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" },
{ url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" },
{ url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" },
{ url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" },
{ url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" },
{ url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" },
{ url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" },
{ url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" },
{ url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" },
{ url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" },
{ url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" },
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]
[[package]]
name = "granian"
version = "2.6.1"
@@ -367,6 +502,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jmespath"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]
[[package]]
name = "multidict"
version = "6.7.1"
@@ -426,6 +570,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/8d/c1e93296e109a320e508e38118cf7d1fc2a4d1c2ec64de78565b3c445eb5/pamqp-3.3.0-py2.py3-none-any.whl", hash = "sha256:c901a684794157ae39b52cbf700db8c9aae7a470f13528b9d7b4e5f7202f8eb0", size = 33848, upload-time = "2024-01-12T20:37:21.359Z" },
]
[[package]]
name = "pillow"
version = "12.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
{ url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
{ url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
{ url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
{ url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
{ url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
{ url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
{ url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
]
[[package]]
name = "propcache"
version = "0.4.1"
@@ -517,6 +680,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"
@@ -692,6 +867,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
]
[[package]]
name = "wrapt"
version = "2.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" },
{ url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" },
{ url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" },
{ url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" },
{ url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" },
{ url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" },
{ url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" },
{ url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" },
{ url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" },
{ url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" },
]
[[package]]
name = "yarl"
version = "1.23.0"