feat: add avatars
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
54
src/application/commands/set_avatar.py
Normal file
54
src/application/commands/set_avatar.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
9
src/application/contracts/i_s3.py
Normal file
9
src/application/contracts/i_s3.py
Normal 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:
|
||||
...
|
||||
@@ -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
|
||||
|
||||
1
src/infrastructure/cache/keydb_client.py
vendored
1
src/infrastructure/cache/keydb_client.py
vendored
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
18
src/infrastructure/media/webp.py
Normal file
18
src/infrastructure/media/webp.py
Normal 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()
|
||||
64
src/infrastructure/storage/s3_service.py
Normal file
64
src/infrastructure/storage/s3_service.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
32
src/presentation/dependencies/s3_storage.py
Normal file
32
src/presentation/dependencies/s3_storage.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
# },
|
||||
# )
|
||||
|
||||
@@ -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
|
||||
|
||||
19
src/presentation/schemas/api_errors.py
Normal file
19
src/presentation/schemas/api_errors.py
Normal 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='Список ошибок валидации тела или параметров запроса'
|
||||
)
|
||||
69
src/presentation/schemas/avatar.py
Normal file
69
src/presentation/schemas/avatar.py
Normal 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}
|
||||
59
src/presentation/schemas/me_public.py
Normal file
59
src/presentation/schemas/me_public.py
Normal 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, байты',
|
||||
)
|
||||
1
src/presentation/serializers/__init__.py
Normal file
1
src/presentation/serializers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.presentation.serializers.me_user import me_user_payload, me_user_public
|
||||
13
src/presentation/serializers/me_user.py
Normal file
13
src/presentation/serializers/me_user.py
Normal 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
195
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user