From 284a5fa468171aaf9280922d645e98154d3955e7 Mon Sep 17 00:00:00 2001 From: Noloquideus Date: Wed, 3 Jun 2026 13:49:16 +0300 Subject: [PATCH] init --- .gitignore | 145 +++++ Dockerfile | 25 + docker-compose.yml | 57 ++ pyproject.toml | 20 + src/application/abstractions/__init__.py | 1 + .../abstractions/i_unit_of_work.py | 25 + .../abstractions/repositories/__init__.py | 3 + .../repositories/i_legal_entity_repository.py | 9 + .../i_purchase_request_repository.py | 37 ++ .../repositories/i_session_repository.py | 59 ++ .../repositories/i_user_repository.py | 11 + src/application/commands/__init__.py | 5 + .../commands/change_email_complete.py | 63 +++ .../commands/change_email_confirm_old.py | 145 +++++ .../commands/change_email_start.py | 126 +++++ .../commands/change_password_complete.py | 81 +++ .../commands/change_password_start.py | 126 +++++ src/application/commands/delete_avatar.py | 56 ++ .../commands/forgot_password_complete.py | 83 +++ .../commands/forgot_password_start.py | 132 +++++ src/application/commands/get_me.py | 17 + .../commands/legal_entity_guard.py | 18 + .../commands/purchase_request_commands.py | 79 +++ src/application/commands/set_avatar.py | 100 ++++ .../set_encrypted_mnemonic_complete.py | 68 +++ .../commands/set_encrypted_mnemonic_start.py | 130 +++++ src/application/commands/set_phone.py | 18 + .../commands/update_bank_details_complete.py | 76 +++ .../commands/update_bank_details_start.py | 126 +++++ src/application/contracts/__init__.py | 7 + src/application/contracts/i_cache.py | 30 + src/application/contracts/i_csrf_service.py | 26 + src/application/contracts/i_hash_service.py | 12 + src/application/contracts/i_jwt_service.py | 10 + src/application/contracts/i_logger.py | 68 +++ .../contracts/i_queue_messanger.py | 40 ++ src/application/contracts/i_s3.py | 17 + src/application/domain/dto/__init__.py | 2 + src/application/domain/dto/keys.py | 20 + src/application/domain/dto/token.py | 18 + src/application/domain/entities/__init__.py | 5 + .../domain/entities/legal_entity.py | 24 + .../domain/entities/purchase_request.py | 24 + src/application/domain/entities/session.py | 20 + src/application/domain/entities/user.py | 31 ++ src/application/domain/enums/__init__.py | 2 + src/application/domain/enums/account_type.py | 6 + src/application/domain/enums/log_format.py | 7 + src/application/domain/enums/log_level.py | 54 ++ src/application/domain/exceptions/__init__.py | 11 + .../exceptions/application_exceptions.py | 59 ++ src/application/domain/password_policy.py | 21 + src/infrastructure/cache/__init__.py | 2 + src/infrastructure/cache/client.py | 16 + src/infrastructure/cache/keydb_client.py | 52 ++ src/infrastructure/config/__init__.py | 1 + src/infrastructure/config/settings.py | 311 +++++++++++ src/infrastructure/context_vars/__init__.py | 1 + src/infrastructure/context_vars/trace_id.py | 4 + src/infrastructure/database/__init__.py | 1 + src/infrastructure/database/context.py | 22 + .../database/decorators/__init__.py | 1 + .../database/decorators/transactional.py | 15 + .../database/models/__init__.py | 6 + src/infrastructure/database/models/base.py | 19 + .../database/models/legal_entity.py | 32 ++ .../database/models/mixins/__init__.py | 3 + .../database/models/mixins/audit.py | 16 + .../database/models/mixins/soft_delete.py | 6 + .../database/models/mixins/ulid.py | 8 + .../database/models/purchase_request.py | 33 ++ .../database/models/sessions.py | 50 ++ src/infrastructure/database/models/user.py | 31 ++ .../database/repositories/__init__.py | 3 + .../repositories/legal_entity_repository.py | 49 ++ .../purchase_request_repository.py | 115 ++++ .../repositories/session_repository.py | 198 +++++++ .../database/repositories/user_repository.py | 58 ++ src/infrastructure/database/unit_of_work.py | 61 +++ src/infrastructure/logger/__init__.py | 28 + src/infrastructure/logger/logger.py | 129 +++++ src/infrastructure/media/webp.py | 18 + src/infrastructure/messanger/__init__.py | 1 + src/infrastructure/messanger/rabbit_client.py | 72 +++ src/infrastructure/security/__init__.py | 3 + src/infrastructure/security/csrf.py | 81 +++ src/infrastructure/security/hash.py | 17 + src/infrastructure/security/jwt.py | 109 ++++ src/infrastructure/storage/s3_service.py | 125 +++++ src/infrastructure/utils/__init__.py | 1 + src/infrastructure/utils/instance_id.py | 14 + src/infrastructure/vault/__init__.py | 4 + src/infrastructure/vault/client.py | 66 +++ src/infrastructure/vault/keys.py | 111 ++++ src/infrastructure/vault/scheduler.py | 23 + src/infrastructure/vault/utils.py | 17 + src/main.py | 140 +++++ src/presentation/decorators/__init__.py | 4 + src/presentation/decorators/auth.py | 36 ++ src/presentation/decorators/cache.py | 46 ++ src/presentation/decorators/csrf.py | 61 +++ src/presentation/decorators/rate_limit.py | 171 ++++++ src/presentation/dependencies/__init__.py | 7 + src/presentation/dependencies/cache.py | 12 + src/presentation/dependencies/commands.py | 31 ++ src/presentation/dependencies/logger.py | 7 + .../dependencies/queue_messanger.py | 8 + src/presentation/dependencies/s3_storage.py | 32 ++ src/presentation/dependencies/security.py | 25 + src/presentation/dependencies/unit_of_work.py | 10 + src/presentation/handlers/__init__.py | 4 + .../handlers/application_handler.py | 17 + .../handlers/http_exception_handler.py | 11 + .../handlers/unhandled_handler.py | 12 + .../handlers/validation_handler.py | 10 + src/presentation/middleware/__init__.py | 2 + .../middleware/security_headers.py | 51 ++ src/presentation/middleware/trace_id.py | 135 +++++ src/presentation/routing/__init__.py | 3 + src/presentation/routing/account.py | 55 ++ src/presentation/routing/account_settings.py | 384 +++++++++++++ src/presentation/routing/deals.py | 7 + src/presentation/routing/devices.py | 11 + src/presentation/routing/purchase_requests.py | 68 +++ src/presentation/schemas/__init__.py | 10 + src/presentation/schemas/api_errors.py | 19 + src/presentation/schemas/avatar.py | 69 +++ src/presentation/schemas/bank.py | 116 ++++ src/presentation/schemas/email.py | 35 ++ .../schemas/encrypted_mnemonic.py | 23 + src/presentation/schemas/me_public.py | 59 ++ src/presentation/schemas/password.py | 75 +++ src/presentation/schemas/phone.py | 16 + src/presentation/schemas/purchase_request.py | 35 ++ src/presentation/serializers/__init__.py | 1 + src/presentation/serializers/me_user.py | 13 + .../serializers/purchase_request.py | 24 + uv.lock | 517 ++++++++++++++++++ 138 files changed, 6660 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 pyproject.toml create mode 100644 src/application/abstractions/__init__.py create mode 100644 src/application/abstractions/i_unit_of_work.py create mode 100644 src/application/abstractions/repositories/__init__.py create mode 100644 src/application/abstractions/repositories/i_legal_entity_repository.py create mode 100644 src/application/abstractions/repositories/i_purchase_request_repository.py create mode 100644 src/application/abstractions/repositories/i_session_repository.py create mode 100644 src/application/abstractions/repositories/i_user_repository.py create mode 100644 src/application/commands/__init__.py create mode 100644 src/application/commands/change_email_complete.py create mode 100644 src/application/commands/change_email_confirm_old.py create mode 100644 src/application/commands/change_email_start.py create mode 100644 src/application/commands/change_password_complete.py create mode 100644 src/application/commands/change_password_start.py create mode 100644 src/application/commands/delete_avatar.py create mode 100644 src/application/commands/forgot_password_complete.py create mode 100644 src/application/commands/forgot_password_start.py create mode 100644 src/application/commands/get_me.py create mode 100644 src/application/commands/legal_entity_guard.py create mode 100644 src/application/commands/purchase_request_commands.py create mode 100644 src/application/commands/set_avatar.py create mode 100644 src/application/commands/set_encrypted_mnemonic_complete.py create mode 100644 src/application/commands/set_encrypted_mnemonic_start.py create mode 100644 src/application/commands/set_phone.py create mode 100644 src/application/commands/update_bank_details_complete.py create mode 100644 src/application/commands/update_bank_details_start.py create mode 100644 src/application/contracts/__init__.py create mode 100644 src/application/contracts/i_cache.py create mode 100644 src/application/contracts/i_csrf_service.py create mode 100644 src/application/contracts/i_hash_service.py create mode 100644 src/application/contracts/i_jwt_service.py create mode 100644 src/application/contracts/i_logger.py create mode 100644 src/application/contracts/i_queue_messanger.py create mode 100644 src/application/contracts/i_s3.py create mode 100644 src/application/domain/dto/__init__.py create mode 100644 src/application/domain/dto/keys.py create mode 100644 src/application/domain/dto/token.py create mode 100644 src/application/domain/entities/__init__.py create mode 100644 src/application/domain/entities/legal_entity.py create mode 100644 src/application/domain/entities/purchase_request.py create mode 100644 src/application/domain/entities/session.py create mode 100644 src/application/domain/entities/user.py create mode 100644 src/application/domain/enums/__init__.py create mode 100644 src/application/domain/enums/account_type.py create mode 100644 src/application/domain/enums/log_format.py create mode 100644 src/application/domain/enums/log_level.py create mode 100644 src/application/domain/exceptions/__init__.py create mode 100644 src/application/domain/exceptions/application_exceptions.py create mode 100644 src/application/domain/password_policy.py create mode 100644 src/infrastructure/cache/__init__.py create mode 100644 src/infrastructure/cache/client.py create mode 100644 src/infrastructure/cache/keydb_client.py create mode 100644 src/infrastructure/config/__init__.py create mode 100644 src/infrastructure/config/settings.py create mode 100644 src/infrastructure/context_vars/__init__.py create mode 100644 src/infrastructure/context_vars/trace_id.py create mode 100644 src/infrastructure/database/__init__.py create mode 100644 src/infrastructure/database/context.py create mode 100644 src/infrastructure/database/decorators/__init__.py create mode 100644 src/infrastructure/database/decorators/transactional.py create mode 100644 src/infrastructure/database/models/__init__.py create mode 100644 src/infrastructure/database/models/base.py create mode 100644 src/infrastructure/database/models/legal_entity.py create mode 100644 src/infrastructure/database/models/mixins/__init__.py create mode 100644 src/infrastructure/database/models/mixins/audit.py create mode 100644 src/infrastructure/database/models/mixins/soft_delete.py create mode 100644 src/infrastructure/database/models/mixins/ulid.py create mode 100644 src/infrastructure/database/models/purchase_request.py create mode 100644 src/infrastructure/database/models/sessions.py create mode 100644 src/infrastructure/database/models/user.py create mode 100644 src/infrastructure/database/repositories/__init__.py create mode 100644 src/infrastructure/database/repositories/legal_entity_repository.py create mode 100644 src/infrastructure/database/repositories/purchase_request_repository.py create mode 100644 src/infrastructure/database/repositories/session_repository.py create mode 100644 src/infrastructure/database/repositories/user_repository.py create mode 100644 src/infrastructure/database/unit_of_work.py create mode 100644 src/infrastructure/logger/__init__.py create mode 100644 src/infrastructure/logger/logger.py create mode 100644 src/infrastructure/media/webp.py create mode 100644 src/infrastructure/messanger/__init__.py create mode 100644 src/infrastructure/messanger/rabbit_client.py create mode 100644 src/infrastructure/security/__init__.py create mode 100644 src/infrastructure/security/csrf.py create mode 100644 src/infrastructure/security/hash.py create mode 100644 src/infrastructure/security/jwt.py create mode 100644 src/infrastructure/storage/s3_service.py create mode 100644 src/infrastructure/utils/__init__.py create mode 100644 src/infrastructure/utils/instance_id.py create mode 100644 src/infrastructure/vault/__init__.py create mode 100644 src/infrastructure/vault/client.py create mode 100644 src/infrastructure/vault/keys.py create mode 100644 src/infrastructure/vault/scheduler.py create mode 100644 src/infrastructure/vault/utils.py create mode 100644 src/main.py create mode 100644 src/presentation/decorators/__init__.py create mode 100644 src/presentation/decorators/auth.py create mode 100644 src/presentation/decorators/cache.py create mode 100644 src/presentation/decorators/csrf.py create mode 100644 src/presentation/decorators/rate_limit.py create mode 100644 src/presentation/dependencies/__init__.py create mode 100644 src/presentation/dependencies/cache.py create mode 100644 src/presentation/dependencies/commands.py create mode 100644 src/presentation/dependencies/logger.py create mode 100644 src/presentation/dependencies/queue_messanger.py create mode 100644 src/presentation/dependencies/s3_storage.py create mode 100644 src/presentation/dependencies/security.py create mode 100644 src/presentation/dependencies/unit_of_work.py create mode 100644 src/presentation/handlers/__init__.py create mode 100644 src/presentation/handlers/application_handler.py create mode 100644 src/presentation/handlers/http_exception_handler.py create mode 100644 src/presentation/handlers/unhandled_handler.py create mode 100644 src/presentation/handlers/validation_handler.py create mode 100644 src/presentation/middleware/__init__.py create mode 100644 src/presentation/middleware/security_headers.py create mode 100644 src/presentation/middleware/trace_id.py create mode 100644 src/presentation/routing/__init__.py create mode 100644 src/presentation/routing/account.py create mode 100644 src/presentation/routing/account_settings.py create mode 100644 src/presentation/routing/deals.py create mode 100644 src/presentation/routing/devices.py create mode 100644 src/presentation/routing/purchase_requests.py create mode 100644 src/presentation/schemas/__init__.py create mode 100644 src/presentation/schemas/api_errors.py create mode 100644 src/presentation/schemas/avatar.py create mode 100644 src/presentation/schemas/bank.py create mode 100644 src/presentation/schemas/email.py create mode 100644 src/presentation/schemas/encrypted_mnemonic.py create mode 100644 src/presentation/schemas/me_public.py create mode 100644 src/presentation/schemas/password.py create mode 100644 src/presentation/schemas/phone.py create mode 100644 src/presentation/schemas/purchase_request.py create mode 100644 src/presentation/serializers/__init__.py create mode 100644 src/presentation/serializers/me_user.py create mode 100644 src/presentation/serializers/purchase_request.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..509ff51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,145 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +generate_password_hash.py +# C extensions +*.so +*.pyd +*.dll + +# Distribution / packaging +.Python +build/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache/ +.pytest_cache/ +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ + +# Type checkers / linters +.mypy_cache/ +.dmypy.json +dmypy.json +.pyre/ +.pytype/ +.ruff_cache/ + +# Jupyter Notebook +.ipynb_checkpoints/ + +# Environments +.env +.env.* +.venv/ +venv/ +ENV/ +env/ +env.bak/ +venv.bak/ + +# Poetry +poetry.lock + +# Pipenv +Pipfile.lock + +# Hatch +.hatch/ + +# pyenv +.python-version + +# Logs +*.log +logs/ + +# Local databases +*.sqlite3 +*.db + +# Secrets / credentials +secrets.json +credentials.json +*.pem +*.key +*.crt + +# OS generated files +.DS_Store +Thumbs.db +Desktop.ini + +# PyCharm / IntelliJ IDEA +.idea/ +*.iml +out/ + +# VS Code (optional) +.vscode/ + +# Temporary files +*.tmp +*.temp +*.swp +*.swo +*~ + +# Sphinx docs +docs/_build/ + +# mkdocs +site/ + +# celery +celerybeat-schedule +celerybeat.pid + +# mypy compiled cache +.mypy_cache/ + +# pyinstaller +*.manifest +*.spec + +# pytest debug +pytestdebug.log + +# Local config overrides +config.local.py +settings.local.py + +# Vault / local dev secrets +.env.vault +vault.token + +.env +.dockerignore +/sql \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..090dad5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm AS builder + +WORKDIR /app + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev + +COPY src ./src + + +FROM ghcr.io/astral-sh/uv:python3.12-bookworm AS runtime + +WORKDIR /app + +COPY --from=builder /app/.venv /app/.venv +COPY --from=builder /app/src /app/src + +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONPATH=/app + +EXPOSE 8001 + +CMD ["sh", "-c", "python -m granian --interface asgi ${APP_MODULE:-src.main:app} --host ${APP_HOST:-0.0.0.0} --port ${APP_PORT:-8001} --workers ${APP_WORKERS:-2} --loop uvloop"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9108966 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +services: + b2b: + container_name: b2b-service + build: + context: . + dockerfile: Dockerfile + ports: + - "8001:8001" + environment: + PYTHONUNBUFFERED: "1" + APP_MODULE: "src.main:app" + APP_HOST: "0.0.0.0" + APP_PORT: "8001" + APP_WORKERS: "2" + env_file: + - .env + depends_on: + b2b_keydb: + condition: service_healthy + restart: no + + b2b_keydb: + image: eqalpha/keydb + container_name: b2b_keydb + restart: no + expose: + - "6379" + volumes: + - b2b_keydb_data:/data + command: + - keydb-server + - --requirepass + - ${REDIS_PASSWORD} + - --dir + - /data + - --appendonly + - "yes" + - --appendfsync + - everysec + - --save + - "900" + - "1" + - --save + - "300" + - "10" + - --save + - "60" + - "10000" + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 5s + timeout: 2s + retries: 20 + + +volumes: + b2b_keydb_data: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6f9a093 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "b2b-service" +version = "0.1.0" +description = "B2B purchase requests API for legal entity client users" +requires-python = "==3.12.*" +dependencies = [ + "apscheduler==3.11.2", + "asyncpg==0.31.0", + "dotenv==0.9.9", + "fastapi==0.128.7", + "pydantic-settings==2.12.0", + "python-jose==3.5.0", + "python-ulid==3.1.0", + "sqlalchemy==2.0.46", + "uvloop==0.22.1; platform_system != 'Windows'", + "granian==2.6.1", + "hvac==2.4.0", + "redis==7.2.0", + "orjson==3.11.7", +] diff --git a/src/application/abstractions/__init__.py b/src/application/abstractions/__init__.py new file mode 100644 index 0000000..76a0e8d --- /dev/null +++ b/src/application/abstractions/__init__.py @@ -0,0 +1 @@ +from src.application.abstractions.i_unit_of_work import IUnitOfWork \ No newline at end of file diff --git a/src/application/abstractions/i_unit_of_work.py b/src/application/abstractions/i_unit_of_work.py new file mode 100644 index 0000000..01fb87d --- /dev/null +++ b/src/application/abstractions/i_unit_of_work.py @@ -0,0 +1,25 @@ +from __future__ import annotations +from typing import Protocol, runtime_checkable +from src.application.abstractions.repositories import ( + IUserRepository, + ILegalEntityRepository, + IPurchaseRequestRepository, +) + + +@runtime_checkable +class IUnitOfWork(Protocol): + async def __aenter__(self) -> "IUnitOfWork": ... + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: ... + + async def commit(self) -> None: ... + async def rollback(self) -> None: ... + + @property + def user_repository(self) -> IUserRepository: ... + + @property + def legal_entity_repository(self) -> ILegalEntityRepository: ... + + @property + def purchase_request_repository(self) -> IPurchaseRequestRepository: ... diff --git a/src/application/abstractions/repositories/__init__.py b/src/application/abstractions/repositories/__init__.py new file mode 100644 index 0000000..fe79fd2 --- /dev/null +++ b/src/application/abstractions/repositories/__init__.py @@ -0,0 +1,3 @@ +from src.application.abstractions.repositories.i_user_repository import IUserRepository +from src.application.abstractions.repositories.i_legal_entity_repository import ILegalEntityRepository +from src.application.abstractions.repositories.i_purchase_request_repository import IPurchaseRequestRepository diff --git a/src/application/abstractions/repositories/i_legal_entity_repository.py b/src/application/abstractions/repositories/i_legal_entity_repository.py new file mode 100644 index 0000000..bd3ab48 --- /dev/null +++ b/src/application/abstractions/repositories/i_legal_entity_repository.py @@ -0,0 +1,9 @@ +from abc import ABC, abstractmethod + +from src.application.domain.entities.legal_entity import LegalEntityEntity + + +class ILegalEntityRepository(ABC): + @abstractmethod + async def get_by_user_id(self, user_id: str) -> LegalEntityEntity | None: + raise NotImplementedError diff --git a/src/application/abstractions/repositories/i_purchase_request_repository.py b/src/application/abstractions/repositories/i_purchase_request_repository.py new file mode 100644 index 0000000..a5176c4 --- /dev/null +++ b/src/application/abstractions/repositories/i_purchase_request_repository.py @@ -0,0 +1,37 @@ +from abc import ABC, abstractmethod +from decimal import Decimal + +from src.application.domain.entities.purchase_request import PurchaseRequestEntity + + +class IPurchaseRequestRepository(ABC): + @abstractmethod + async def create( + self, + *, + organization_id: str, + usdt_amount: Decimal, + comment: str | None, + target_wallet_chain: str | None, + target_wallet_address: str | None, + ) -> PurchaseRequestEntity: + raise NotImplementedError + + @abstractmethod + async def get_by_id(self, request_id: str) -> PurchaseRequestEntity: + raise NotImplementedError + + @abstractmethod + async def list_by_organization( + self, + *, + organization_id: str, + status: str | None, + limit: int, + offset: int, + ) -> list[PurchaseRequestEntity]: + raise NotImplementedError + + @abstractmethod + async def count_by_organization(self, *, organization_id: str, status: str | None) -> int: + raise NotImplementedError diff --git a/src/application/abstractions/repositories/i_session_repository.py b/src/application/abstractions/repositories/i_session_repository.py new file mode 100644 index 0000000..66b40d9 --- /dev/null +++ b/src/application/abstractions/repositories/i_session_repository.py @@ -0,0 +1,59 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from datetime import datetime +from src.application.domain.entities import SessionEntity + + + +class ISessionRepository(ABC): + @abstractmethod + async def get_by_sid(self, sid: str) -> SessionEntity | None: + raise NotImplementedError + + @abstractmethod + async def get_by_user_device(self, user_id: str, device_id: str) -> SessionEntity | None: + raise NotImplementedError + + @abstractmethod + async def upsert_by_device( + self, + user_id: str, + device_id: str, + sid: str, + refresh_jti_hash: str, + refresh_expires_at: datetime, + user_agent: str | None, + ip: str | None, + now: datetime, + ) -> SessionEntity: + raise NotImplementedError + + @abstractmethod + async def revoke_by_sid(self, sid: str, now: datetime) -> None: + raise NotImplementedError + + @abstractmethod + async def rotate_refresh( + self, + sid: str, + new_jti_hash: str, + new_refresh_expires_at: datetime, + now: datetime, + ip: str | None, + user_agent: str | None, + ) -> None: + raise NotImplementedError + + @abstractmethod + async def rotate_refresh_if_match( + self, + *, + sid: str, + old_jti_hash: str, + new_jti_hash: str, + new_refresh_expires_at: datetime, + now: datetime, + ip: str | None, + user_agent: str | None, + ) -> bool: + raise NotImplementedError diff --git a/src/application/abstractions/repositories/i_user_repository.py b/src/application/abstractions/repositories/i_user_repository.py new file mode 100644 index 0000000..96664a9 --- /dev/null +++ b/src/application/abstractions/repositories/i_user_repository.py @@ -0,0 +1,11 @@ +from __future__ import annotations +from abc import ABC +from abc import abstractmethod +from src.application.domain.entities import UserEntity + + +class IUserRepository(ABC): + + @abstractmethod + async def get_user_by_id(self, user_id: str) -> UserEntity: + raise NotImplementedError diff --git a/src/application/commands/__init__.py b/src/application/commands/__init__.py new file mode 100644 index 0000000..5f12e7c --- /dev/null +++ b/src/application/commands/__init__.py @@ -0,0 +1,5 @@ +from src.application.commands.purchase_request_commands import ( + CreatePurchaseRequestCommand, + GetPurchaseRequestCommand, + ListPurchaseRequestsCommand, +) diff --git a/src/application/commands/change_email_complete.py b/src/application/commands/change_email_complete.py new file mode 100644 index 0000000..0ccfbf7 --- /dev/null +++ b/src/application/commands/change_email_complete.py @@ -0,0 +1,63 @@ +from src.application.abstractions import IUnitOfWork +from src.application.contracts import IHashService, ILogger, ICache +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.database.decorators import transactional + + +class ChangeEmailCompleteCommand: + def __init__( + self, + unit_of_work: IUnitOfWork, + hash_service: IHashService, + cache: ICache, + logger: ILogger, + ): + self._unit_of_work = unit_of_work + self._hash_service = hash_service + self._cache = cache + self._logger = logger + + @transactional + async def __call__(self, *, user_id: str, code: str) -> bool: + code = (code or '').strip() + + NEW_USER_PREFIX = 'change_email:new_user:' + NEW_CODE_PREFIX = 'change_email:new_code:' + + new_user_key = f'{NEW_USER_PREFIX}{user_id}' + new_code_key = f'{NEW_CODE_PREFIX}{code}' + + cached_user_id = await self._cache.get(new_code_key) + if not cached_user_id: + self._logger.info(f'Change email complete failed: code not found (user_id={user_id})') + raise ApplicationException(400, 'Invalid or expired code') + + if cached_user_id != user_id: + self._logger.info(f'Change email complete failed: code-user mismatch (user_id={user_id})') + raise ApplicationException(400, 'Invalid or expired code') + + raw_value = await self._cache.get(new_user_key) + if not raw_value: + self._logger.info(f'Change email complete failed: user key missing (user_id={user_id})') + raise ApplicationException(400, 'Invalid or expired code') + + separator_idx = raw_value.index(':') + code_hash = raw_value[:separator_idx] + new_email = raw_value[separator_idx + 1:] + + ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code) + if not ok: + self._logger.info(f'Change email complete failed: code hash mismatch (user_id={user_id})') + raise ApplicationException(400, 'Invalid or expired code') + + user = await self._unit_of_work.user_repository.set_email(user_id=user_id, email=new_email) + await self._cache.set_user(user_id, user) + + try: + await self._cache.delete(new_code_key) + await self._cache.delete(new_user_key) + except Exception as e: + self._logger.warning(f'Change email complete cleanup failed (user_id={user_id}): {e}') + + self._logger.info(f'Email changed for user_id={user_id}') + return True diff --git a/src/application/commands/change_email_confirm_old.py b/src/application/commands/change_email_confirm_old.py new file mode 100644 index 0000000..95d955f --- /dev/null +++ b/src/application/commands/change_email_confirm_old.py @@ -0,0 +1,145 @@ +import secrets +from datetime import datetime, timezone +from ulid import ULID +from src.application.abstractions import IUnitOfWork +from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.config import settings +from src.infrastructure.context_vars import trace_id_var +from src.infrastructure.database.decorators import transactional + + +class ChangeEmailConfirmOldCommand: + def __init__( + self, + hash_service: IHashService, + cache: ICache, + unit_of_work: IUnitOfWork, + logger: ILogger, + messanger: IQueueMessanger, + ): + self._hash_service = hash_service + self._unit_of_work = unit_of_work + self._cache = cache + self._logger = logger + self._messanger = messanger + + @transactional + async def __call__(self, *, user_id: str, code: str, new_email: str) -> bool: + TTL = 300 + MAX_ATTEMPTS = 20 + + OLD_USER_PREFIX = 'change_email:old_user:' + OLD_CODE_PREFIX = 'change_email:old_code:' + NEW_USER_PREFIX = 'change_email:new_user:' + NEW_CODE_PREFIX = 'change_email:new_code:' + + code = (code or '').strip() + old_user_key = f'{OLD_USER_PREFIX}{user_id}' + old_code_key = f'{OLD_CODE_PREFIX}{code}' + + cached_user_id = await self._cache.get(old_code_key) + if not cached_user_id: + self._logger.info(f'Change email confirm-old failed: code not found (user_id={user_id})') + raise ApplicationException(400, 'Invalid or expired code') + + if cached_user_id != user_id: + self._logger.info(f'Change email confirm-old failed: code-user mismatch (user_id={user_id})') + raise ApplicationException(400, 'Invalid or expired code') + + code_hash = await self._cache.get(old_user_key) + if not code_hash: + self._logger.info(f'Change email confirm-old failed: user key missing (user_id={user_id})') + raise ApplicationException(400, 'Invalid or expired code') + + ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code) + if not ok: + self._logger.info(f'Change email confirm-old failed: code hash mismatch (user_id={user_id})') + raise ApplicationException(400, 'Invalid or expired code') + + user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id) + + if user.email and user.email.lower() == new_email.lower(): + self._logger.info(f'Change email confirm-old failed: new email same as current (user_id={user_id})') + raise ApplicationException(400, 'New email must differ from the current one') + + email_taken = await self._unit_of_work.user_repository.email_exists(email=new_email) + if email_taken: + self._logger.info(f'Change email confirm-old failed: new email already taken (user_id={user_id})') + raise ApplicationException(409, 'Email already in use') + + try: + await self._cache.delete(old_code_key) + await self._cache.delete(old_user_key) + except Exception as e: + self._logger.warning(f'Change email confirm-old cleanup failed (user_id={user_id}): {e}') + + trace_id = trace_id_var.get() + if not trace_id or trace_id == 'N/A': + trace_id = None + + new_user_key = f'{NEW_USER_PREFIX}{user_id}' + + for _ in range(MAX_ATTEMPTS): + new_code = f'{secrets.randbelow(1_000_000):06d}' + new_code_key = f'{NEW_CODE_PREFIX}{new_code}' + + new_code_hash = await self._hash_service.hash(new_code) + + reserved = await self._cache.set_nx(new_code_key, user_id, ttl=TTL) + if not reserved: + continue + + saved = await self._cache.set(new_user_key, f'{new_code_hash}:{new_email}', ttl=TTL) + if not saved: + await self._cache.delete(new_code_key) + self._logger.error(f'Change email confirm-old failed: cannot save new code hash for user_id={user_id}') + raise ApplicationException(503, 'Temporary error. Please try again.') + + message_id = str(ULID()) + now = datetime.now(timezone.utc).isoformat() + + metadata = { + 'trace_id': trace_id, + 'source': 'user-service', + 'timestamp': now, + 'message_id': message_id, + } + + payload = { + 'email': new_email, + 'code': new_code, + 'ttl_seconds': TTL, + } + + message = { + 'event': 'change_email_new', + 'payload': payload, + 'metadata': metadata, + } + + self._logger.info(f'Change email new code created for user_id={user_id}') + + try: + await self._messanger.publish_to_queue( + queue=settings.RABBIT_EMAIL_CODE_QUEUE, + message=message, + persist=True, + correlation_id=trace_id, + message_id=message_id, + headers={'trace_id': trace_id} if trace_id else None, + ) + except Exception as exception: + try: + await self._cache.delete(new_user_key) + await self._cache.delete(new_code_key) + except Exception as rollback_err: + self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}') + + self._logger.error(f'Failed to publish change email new code for user_id={user_id}: {str(exception)}') + raise ApplicationException(503, 'Temporary error. Please try again.') + + return True + + self._logger.error(f'Change email confirm-old failed: code space exhausted for user_id={user_id}') + raise ApplicationException(503, 'Temporary error. Please try again.') diff --git a/src/application/commands/change_email_start.py b/src/application/commands/change_email_start.py new file mode 100644 index 0000000..e9047df --- /dev/null +++ b/src/application/commands/change_email_start.py @@ -0,0 +1,126 @@ +import secrets +from datetime import datetime, timezone +from ulid import ULID +from src.application.abstractions import IUnitOfWork +from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.config import settings +from src.infrastructure.context_vars import trace_id_var +from src.infrastructure.database.decorators import transactional + + +class ChangeEmailStartCommand: + def __init__( + self, + hash_service: IHashService, + cache: ICache, + unit_of_work: IUnitOfWork, + logger: ILogger, + messanger: IQueueMessanger, + ): + self._hash_service = hash_service + self._unit_of_work = unit_of_work + self._cache = cache + self._logger = logger + self._messanger = messanger + + @transactional + async def __call__(self, user_id: str) -> bool: + TTL = 300 + LOCK_TTL = 30 + MAX_ATTEMPTS = 20 + + USER_PREFIX = 'change_email:old_user:' + CODE_PREFIX = 'change_email:old_code:' + LOCK_PREFIX = 'change_email:lock:' + + user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id) + + if not user.email: + self._logger.warning(f'User {user_id} does not have an email address') + raise ApplicationException(404, f'User {user_id} does not have an email address') + + trace_id = trace_id_var.get() + if not trace_id or trace_id == 'N/A': + trace_id = None + + lock_key = f'{LOCK_PREFIX}{user_id}' + locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL) + if not locked: + self._logger.info(f'Change email throttled by lock (user_id={user_id})') + raise ApplicationException(429, 'Too many requests. Please wait.') + + try: + user_key = f'{USER_PREFIX}{user_id}' + + existing = await self._cache.get(user_key) + if existing: + self._logger.info(f'Change email denied: code already exists for user_id={user_id}') + raise ApplicationException(429, 'Code already sent. Please wait before retrying.') + + for _ in range(MAX_ATTEMPTS): + code = f'{secrets.randbelow(1_000_000):06d}' + code_key = f'{CODE_PREFIX}{code}' + + code_hash = await self._hash_service.hash(code) + + reserved = await self._cache.set_nx(code_key, user_id, ttl=TTL) + if not reserved: + continue + + saved = await self._cache.set(user_key, code_hash, ttl=TTL) + if not saved: + await self._cache.delete(code_key) + self._logger.error(f'Change email failed: cannot save code hash for user_id={user_id}') + raise ApplicationException(503, 'Temporary error. Please try again.') + + message_id = str(ULID()) + now = datetime.now(timezone.utc).isoformat() + + metadata = { + 'trace_id': trace_id, + 'source': 'user-service', + 'timestamp': now, + 'message_id': message_id, + } + + payload = { + 'email': user.email, + 'code': code, + 'ttl_seconds': TTL, + } + + message = { + 'event': 'change_email_old', + 'payload': payload, + 'metadata': metadata, + } + + self._logger.info(f'Change email old code created for user_id={user_id}') + + try: + await self._messanger.publish_to_queue( + queue=settings.RABBIT_EMAIL_CODE_QUEUE, + message=message, + persist=True, + correlation_id=trace_id, + message_id=message_id, + headers={'trace_id': trace_id} if trace_id else None, + ) + except Exception as exception: + try: + await self._cache.delete(user_key) + await self._cache.delete(code_key) + except Exception as rollback_err: + self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}') + + self._logger.error(f'Failed to publish change email old code for user_id={user_id}: {str(exception)}') + raise ApplicationException(503, 'Temporary error. Please try again.') + + return True + + self._logger.error(f'Change email failed: code space exhausted for user_id={user_id}') + raise ApplicationException(503, 'Temporary error. Please try again.') + + finally: + await self._cache.delete(lock_key) diff --git a/src/application/commands/change_password_complete.py b/src/application/commands/change_password_complete.py new file mode 100644 index 0000000..9dc7367 --- /dev/null +++ b/src/application/commands/change_password_complete.py @@ -0,0 +1,81 @@ +from src.application.abstractions import IUnitOfWork +from src.application.contracts import IHashService, ILogger, ICache +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.database.decorators import transactional + + +class ChangePasswordCompleteCommand: + def __init__( + self, + unit_of_work: IUnitOfWork, + hash_service: IHashService, + cache: ICache, + logger: ILogger, + ): + self._unit_of_work = unit_of_work + self._hash_service = hash_service + self._cache = cache + self._logger = logger + + @transactional + async def __call__( + self, + *, + user_id: str, + code: str, + new_password: str, + confirm_password: str, + ) -> bool: + code = (code or '').strip() + + USER_PREFIX = 'change_password:user:' + CODE_PREFIX = 'change_password:code:' + + user_key = f'{USER_PREFIX}{user_id}' + code_key = f'{CODE_PREFIX}{code}' + + if new_password != confirm_password: + self._logger.info(f'Change password failed: passwords do not match (user_id={user_id})') + raise ApplicationException(400, 'Passwords do not match') + + cached_user_id = await self._cache.get(code_key) + if not cached_user_id: + self._logger.info(f'Change password failed: code not found (user_id={user_id})') + raise ApplicationException(400, 'Invalid or expired code') + + if cached_user_id != user_id: + self._logger.info(f'Change password failed: code-user mismatch (user_id={user_id})') + raise ApplicationException(400, 'Invalid or expired code') + + code_hash = await self._cache.get(user_key) + if not code_hash: + self._logger.info(f'Change password failed: user key missing (user_id={user_id})') + raise ApplicationException(400, 'Invalid or expired code') + + ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code) + if not ok: + self._logger.info(f'Change password failed: code hash mismatch (user_id={user_id})') + raise ApplicationException(400, 'Invalid or expired code') + + current_password_hash = await self._unit_of_work.user_repository.get_password_hash(user_id=user_id) + + is_same = await self._hash_service.verify(hashed_value=current_password_hash, plain_value=new_password) + if is_same: + self._logger.info(f'Change password failed: new password same as current (user_id={user_id})') + raise ApplicationException(400, 'New password must differ from the current one') + + new_password_hash = await self._hash_service.hash(new_password) + user = await self._unit_of_work.user_repository.set_password( + user_id=user_id, + password_hash=new_password_hash, + ) + await self._cache.set_user(user_id, user) + + try: + await self._cache.delete(code_key) + await self._cache.delete(user_key) + except Exception as e: + self._logger.warning(f'Change password cleanup failed (user_id={user_id}): {e}') + + self._logger.info(f'Password changed for user_id={user_id}') + return True diff --git a/src/application/commands/change_password_start.py b/src/application/commands/change_password_start.py new file mode 100644 index 0000000..d3c1138 --- /dev/null +++ b/src/application/commands/change_password_start.py @@ -0,0 +1,126 @@ +import secrets +from datetime import datetime, timezone +from ulid import ULID +from src.application.abstractions import IUnitOfWork +from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.config import settings +from src.infrastructure.context_vars import trace_id_var +from src.infrastructure.database.decorators import transactional + + +class ChangePasswordStartCommand: + def __init__( + self, + hash_service: IHashService, + cache: ICache, + unit_of_work: IUnitOfWork, + logger: ILogger, + messanger: IQueueMessanger, + ): + self._hash_service = hash_service + self._unit_of_work = unit_of_work + self._cache = cache + self._logger = logger + self._messanger = messanger + + @transactional + async def __call__(self, user_id: str) -> bool: + TTL = 300 + LOCK_TTL = 30 + MAX_ATTEMPTS = 20 + + USER_PREFIX = 'change_password:user:' + CODE_PREFIX = 'change_password:code:' + LOCK_PREFIX = 'change_password:lock:' + + user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id) + + if not user.email: + self._logger.warning(f'User {user_id} does not have an email address') + raise ApplicationException(404, f'User {user_id} does not have an email address') + + trace_id = trace_id_var.get() + if not trace_id or trace_id == 'N/A': + trace_id = None + + lock_key = f'{LOCK_PREFIX}{user_id}' + locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL) + if not locked: + self._logger.info(f'Change password throttled by lock (user_id={user_id})') + raise ApplicationException(429, 'Too many requests. Please wait.') + + try: + user_key = f'{USER_PREFIX}{user_id}' + + existing = await self._cache.get(user_key) + if existing: + self._logger.info(f'Change password denied: code already exists for user_id={user_id}') + raise ApplicationException(429, 'Code already sent. Please wait before retrying.') + + for _ in range(MAX_ATTEMPTS): + code = f'{secrets.randbelow(1_000_000):06d}' + code_key = f'{CODE_PREFIX}{code}' + + code_hash = await self._hash_service.hash(code) + + reserved = await self._cache.set_nx(code_key, user_id, ttl=TTL) + if not reserved: + continue + + saved = await self._cache.set(user_key, code_hash, ttl=TTL) + if not saved: + await self._cache.delete(code_key) + self._logger.error(f'Change password failed: cannot save code hash for user_id={user_id}') + raise ApplicationException(503, 'Temporary error. Please try again.') + + message_id = str(ULID()) + now = datetime.now(timezone.utc).isoformat() + + metadata = { + 'trace_id': trace_id, + 'source': 'user-service', + 'timestamp': now, + 'message_id': message_id, + } + + payload = { + 'email': user.email, + 'code': code, + 'ttl_seconds': TTL, + } + + message = { + 'event': 'change_password', + 'payload': payload, + 'metadata': metadata, + } + + self._logger.info(f'Change password code created for user_id={user_id}') + + try: + await self._messanger.publish_to_queue( + queue=settings.RABBIT_EMAIL_CODE_QUEUE, + message=message, + persist=True, + correlation_id=trace_id, + message_id=message_id, + headers={'trace_id': trace_id} if trace_id else None, + ) + except Exception as exception: + try: + await self._cache.delete(user_key) + await self._cache.delete(code_key) + except Exception as rollback_err: + self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}') + + self._logger.error(f'Failed to publish change password email for user_id={user_id}: {str(exception)}') + raise ApplicationException(503, 'Temporary error. Please try again.') + + return True + + self._logger.error(f'Change password failed: code space exhausted for user_id={user_id}') + raise ApplicationException(503, 'Temporary error. Please try again.') + + finally: + await self._cache.delete(lock_key) diff --git a/src/application/commands/delete_avatar.py b/src/application/commands/delete_avatar.py new file mode 100644 index 0000000..d215077 --- /dev/null +++ b/src/application/commands/delete_avatar.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +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.infrastructure.database.decorators import transactional + + +class DeleteAvatarCommand: + 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 + + @transactional + async def _load_user(self, user_id: str) -> UserEntity: + user = await self._unit_of_work.user_repository.get_user_by_id(user_id) + self._logger.debug(f'DeleteAvatar _load_user user_id={user_id} has_avatar_link={bool(user.avatar_link)}') + return user + + async def __call__(self, user_id: str) -> UserEntity: + prior = await self._load_user(user_id) + link = prior.avatar_link + self._logger.info(f'DeleteAvatar start user_id={user_id} had_link={bool(link)}') + if link: + key = self._s3.object_key_from_public_url(link) + self._logger.debug(f'DeleteAvatar parsed_object_key user_id={user_id} has_key={bool(key)}') + if not key: + self._logger.warning( + f'DeleteAvatar could not parse avatar URL for S3 user_id={user_id} link_len={len(link)}' + ) + if key: + self._logger.info(f'DeleteAvatar S3 delete start user_id={user_id} key={key}') + try: + await self._s3.delete_object(key=key) + self._logger.info(f'DeleteAvatar S3 delete done user_id={user_id} key={key}') + except ClientError as exc: + code = exc.response.get('Error', {}).get('Code', '') + if code not in ('NoSuchKey', '404'): + self._logger.warning(f'DeleteAvatar S3 delete failed user_id={user_id} code={code}: {exc}') + else: + self._logger.debug(f'DeleteAvatar S3 object already absent user_id={user_id} code={code}') + user = await self._clear_avatar_link(user_id) + self._logger.debug(f'DeleteAvatar DB cleared user_id={user_id} entity_has_link={bool(user.avatar_link)}') + await self._cache.set_user(user_id, user) + self._logger.debug(f'DeleteAvatar cache updated user_id={user_id}') + self._logger.info(f'Avatar removed user_id={user_id}') + return user + + @transactional + async def _clear_avatar_link(self, user_id: str) -> UserEntity: + self._logger.debug(f'DeleteAvatar DB transaction set_avatar_link user_id={user_id} link=None') + return await self._unit_of_work.user_repository.set_avatar_link(user_id, None) diff --git a/src/application/commands/forgot_password_complete.py b/src/application/commands/forgot_password_complete.py new file mode 100644 index 0000000..e06f49f --- /dev/null +++ b/src/application/commands/forgot_password_complete.py @@ -0,0 +1,83 @@ +from src.application.abstractions import IUnitOfWork +from src.application.contracts import IHashService, ILogger, ICache +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.database.decorators import transactional + + +class ForgotPasswordCompleteCommand: + def __init__( + self, + unit_of_work: IUnitOfWork, + hash_service: IHashService, + cache: ICache, + logger: ILogger, + ): + self._unit_of_work = unit_of_work + self._hash_service = hash_service + self._cache = cache + self._logger = logger + + @staticmethod + def _normalize_email(email: str) -> str: + return email.strip().lower() + + @transactional + async def __call__( + self, + *, + email: str, + code: str, + new_password: str, + confirm_password: str, + ) -> bool: + code = (code or '').strip() + normalized = self._normalize_email(email) + + EMAIL_PREFIX = 'forgot_password:email:' + CODE_PREFIX = 'forgot_password:code:' + + if new_password != confirm_password: + self._logger.info('Forgot password failed: passwords do not match') + raise ApplicationException(400, 'Passwords do not match') + + code_key = f'{CODE_PREFIX}{code}' + cached_email = await self._cache.get(code_key) + if not cached_email: + self._logger.info('Forgot password failed: code not found') + raise ApplicationException(400, 'Invalid or expired code') + + if cached_email != normalized: + self._logger.info('Forgot password failed: code-email mismatch') + raise ApplicationException(400, 'Invalid or expired code') + + email_key = f'{EMAIL_PREFIX}{normalized}' + code_hash = await self._cache.get(email_key) + if not code_hash: + self._logger.info('Forgot password failed: email key missing') + raise ApplicationException(400, 'Invalid or expired code') + + ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code) + if not ok: + self._logger.info('Forgot password failed: code hash mismatch') + raise ApplicationException(400, 'Invalid or expired code') + + user = await self._unit_of_work.user_repository.get_user_by_email(normalized) + if user is None: + self._logger.info('Forgot password failed: user not found after valid code') + raise ApplicationException(400, 'Invalid or expired code') + + new_password_hash = await self._hash_service.hash(new_password) + user = await self._unit_of_work.user_repository.set_password( + user_id=user.id, + password_hash=new_password_hash, + ) + await self._cache.set_user(user.id, user) + + try: + await self._cache.delete(code_key) + await self._cache.delete(email_key) + except Exception as e: + self._logger.warning(f'Forgot password cleanup failed (user_id={user.id}): {e}') + + self._logger.info(f'Password reset via forgot flow for user_id={user.id}') + return True diff --git a/src/application/commands/forgot_password_start.py b/src/application/commands/forgot_password_start.py new file mode 100644 index 0000000..da7f86d --- /dev/null +++ b/src/application/commands/forgot_password_start.py @@ -0,0 +1,132 @@ +import secrets +from datetime import datetime, timezone +from ulid import ULID +from src.application.abstractions import IUnitOfWork +from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.config import settings +from src.infrastructure.context_vars import trace_id_var +from src.infrastructure.database.decorators import transactional + + +class ForgotPasswordStartCommand: + def __init__( + self, + hash_service: IHashService, + cache: ICache, + unit_of_work: IUnitOfWork, + logger: ILogger, + messanger: IQueueMessanger, + ): + self._hash_service = hash_service + self._unit_of_work = unit_of_work + self._cache = cache + self._logger = logger + self._messanger = messanger + + @staticmethod + def _normalize_email(email: str) -> str: + return email.strip().lower() + + @transactional + async def __call__(self, email: str) -> bool: + TTL = 300 + LOCK_TTL = 30 + MAX_ATTEMPTS = 20 + + EMAIL_PREFIX = 'forgot_password:email:' + CODE_PREFIX = 'forgot_password:code:' + LOCK_PREFIX = 'forgot_password:lock:' + + normalized = self._normalize_email(email) + user = await self._unit_of_work.user_repository.get_user_by_email(normalized) + if user is None: + self._logger.info(f'Forgot password start: no user for email hash lookup') + return True + + trace_id = trace_id_var.get() + if not trace_id or trace_id == 'N/A': + trace_id = None + + lock_key = f'{LOCK_PREFIX}{normalized}' + locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL) + if not locked: + self._logger.info(f'Forgot password throttled by lock (user_id={user.id})') + raise ApplicationException(429, 'Too many requests. Please wait.') + + try: + email_key = f'{EMAIL_PREFIX}{normalized}' + + existing = await self._cache.get(email_key) + if existing: + self._logger.info(f'Forgot password denied: code already exists for user_id={user.id}') + raise ApplicationException(429, 'Code already sent. Please wait before retrying.') + + for _ in range(MAX_ATTEMPTS): + code = f'{secrets.randbelow(1_000_000):06d}' + code_key = f'{CODE_PREFIX}{code}' + + code_hash = await self._hash_service.hash(code) + + reserved = await self._cache.set_nx(code_key, normalized, ttl=TTL) + if not reserved: + continue + + saved = await self._cache.set(email_key, code_hash, ttl=TTL) + if not saved: + await self._cache.delete(code_key) + self._logger.error(f'Forgot password failed: cannot save code hash for user_id={user.id}') + raise ApplicationException(503, 'Temporary error. Please try again.') + + message_id = str(ULID()) + now = datetime.now(timezone.utc).isoformat() + + metadata = { + 'trace_id': trace_id, + 'source': 'user-service', + 'timestamp': now, + 'message_id': message_id, + } + + payload = { + 'email': normalized, + 'code': code, + 'ttl_seconds': TTL, + } + + message = { + 'event': 'forgot_password', + 'payload': payload, + 'metadata': metadata, + } + + self._logger.info(f'Forgot password code created for user_id={user.id}') + + try: + await self._messanger.publish_to_queue( + queue=settings.RABBIT_EMAIL_CODE_QUEUE, + message=message, + persist=True, + correlation_id=trace_id, + message_id=message_id, + headers={'trace_id': trace_id} if trace_id else None, + ) + except Exception as exception: + try: + await self._cache.delete(email_key) + await self._cache.delete(code_key) + except Exception as rollback_err: + self._logger.error( + f'Publish failed and rollback cache failed for user_id={user.id}: {str(rollback_err)}' + ) + + self._logger.error(f'Failed to publish forgot password email for user_id={user.id}: {str(exception)}') + raise ApplicationException(503, 'Temporary error. Please try again.') + + return True + + self._logger.error(f'Forgot password failed: code space exhausted for user_id={user.id}') + raise ApplicationException(503, 'Temporary error. Please try again.') + + finally: + await self._cache.delete(lock_key) diff --git a/src/application/commands/get_me.py b/src/application/commands/get_me.py new file mode 100644 index 0000000..7da5eb8 --- /dev/null +++ b/src/application/commands/get_me.py @@ -0,0 +1,17 @@ +from src.application.abstractions import IUnitOfWork +from src.application.contracts import ILogger, ICache +from src.application.domain.entities import UserEntity +from src.infrastructure.database.decorators import transactional + + +class GetMeCommand: + def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger, cache: ICache): + self._unit_of_work = unit_of_work + self._logger = logger + self._cache = cache + + @transactional + async def __call__(self, user_id: str) -> UserEntity: + user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id) + self._logger.info(f'User ID: {user.id}') + return user \ No newline at end of file diff --git a/src/application/commands/legal_entity_guard.py b/src/application/commands/legal_entity_guard.py new file mode 100644 index 0000000..495334a --- /dev/null +++ b/src/application/commands/legal_entity_guard.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from src.application.abstractions import IUnitOfWork +from src.application.domain.entities.legal_entity import LegalEntityEntity +from src.application.domain.enums.account_type import AccountType +from src.application.domain.exceptions import ForbiddenException, NotFoundException + + +async def require_legal_entity(user_id: str, unit_of_work: IUnitOfWork) -> LegalEntityEntity: + user = await unit_of_work.user_repository.get_user_by_id(user_id) + if user.account_type != AccountType.LEGAL_ENTITY: + raise ForbiddenException(message='B2B access is available for legal entity accounts only') + + legal_entity = await unit_of_work.legal_entity_repository.get_by_user_id(user_id) + if legal_entity is None: + raise NotFoundException(message='Legal entity profile not found') + + return legal_entity diff --git a/src/application/commands/purchase_request_commands.py b/src/application/commands/purchase_request_commands.py new file mode 100644 index 0000000..1e86b4c --- /dev/null +++ b/src/application/commands/purchase_request_commands.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from decimal import Decimal + +from src.application.abstractions import IUnitOfWork +from src.application.commands.legal_entity_guard import require_legal_entity +from src.application.contracts import ILogger +from src.application.domain.entities.purchase_request import PurchaseRequestEntity +from src.application.domain.exceptions import NotFoundException +from src.infrastructure.database.decorators import transactional + + +class CreatePurchaseRequestCommand: + def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + @transactional + async def __call__( + self, + user_id: str, + *, + usdt_amount: Decimal, + comment: str | None = None, + target_wallet_chain: str | None = None, + target_wallet_address: str | None = None, + ) -> PurchaseRequestEntity: + legal_entity = await require_legal_entity(user_id, self._unit_of_work) + item = await self._unit_of_work.purchase_request_repository.create( + organization_id=legal_entity.id, + usdt_amount=usdt_amount, + comment=comment, + target_wallet_chain=target_wallet_chain, + target_wallet_address=target_wallet_address, + ) + self._logger.info(f'Purchase request created id={item.id} organization_id={legal_entity.id}') + return item + + +class ListPurchaseRequestsCommand: + def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + @transactional + async def __call__( + self, + user_id: str, + *, + status: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[PurchaseRequestEntity], int]: + legal_entity = await require_legal_entity(user_id, self._unit_of_work) + items = await self._unit_of_work.purchase_request_repository.list_by_organization( + organization_id=legal_entity.id, + status=status, + limit=limit, + offset=offset, + ) + total = await self._unit_of_work.purchase_request_repository.count_by_organization( + organization_id=legal_entity.id, + status=status, + ) + return items, total + + +class GetPurchaseRequestCommand: + def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + @transactional + async def __call__(self, user_id: str, request_id: str) -> PurchaseRequestEntity: + legal_entity = await require_legal_entity(user_id, self._unit_of_work) + item = await self._unit_of_work.purchase_request_repository.get_by_id(request_id) + if item.organization_id != legal_entity.id: + raise NotFoundException(message='Purchase request not found') + return item diff --git a/src/application/commands/set_avatar.py b/src/application/commands/set_avatar.py new file mode 100644 index 0000000..281aa23 --- /dev/null +++ b/src/application/commands/set_avatar.py @@ -0,0 +1,100 @@ +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 + + @transactional + async def _load_user(self, user_id: str) -> UserEntity: + user = await self._unit_of_work.user_repository.get_user_by_id(user_id) + self._logger.debug(f'Avatar _load_user user_id={user_id} has_avatar_link={bool(user.avatar_link)}') + return user + + async def __call__(self, user_id: str, image_bytes: bytes) -> tuple[UserEntity, int]: + prior = await self._load_user(user_id) + old_link = prior.avatar_link + self._logger.info( + f'SetAvatar start user_id={user_id} input_bytes={len(image_bytes)} had_previous_link={bool(old_link)}' + ) + 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 + + self._logger.debug(f'SetAvatar webp_ready bytes={len(webp_bytes)}') + + 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 + + self._logger.info(f'SetAvatar S3 upload start user_id={user_id} key={object_key} webp_bytes={len(webp_bytes)}') + + 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 + + self._logger.info(f'SetAvatar S3 upload done user_id={user_id} key={object_key} public_url_len={len(url)}') + + user = await self._save_avatar_link(user_id, url) + self._logger.info( + f'SetAvatar DB updated user_id={user_id} key={object_key} ' + f'entity_avatar_link_len={len(user.avatar_link or "")}' + ) + await self._cache.set_user(user_id, user) + self._logger.debug(f'SetAvatar cache updated user_id={user_id}') + + if old_link: + old_key = self._s3.object_key_from_public_url(old_link) + if not old_key: + self._logger.warning( + f'SetAvatar could not parse old avatar URL for S3 delete user_id={user_id} ' + f'old_link_len={len(old_link)}' + ) + elif old_key == object_key: + self._logger.debug(f'SetAvatar skip delete same object key user_id={user_id} key={object_key}') + else: + self._logger.info(f'SetAvatar S3 delete old object user_id={user_id} old_key={old_key}') + try: + await self._s3.delete_object(key=old_key) + self._logger.info(f'SetAvatar S3 old object removed user_id={user_id} old_key={old_key}') + except ClientError as exc: + code = exc.response.get('Error', {}).get('Code', '') + if code not in ('NoSuchKey', '404'): + self._logger.warning(f'S3 delete old avatar failed user_id={user_id} code={code}: {exc}') + else: + self._logger.debug(f'SetAvatar old object already gone user_id={user_id} code={code}') + + 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: + self._logger.debug(f'SetAvatar DB transaction set_avatar_link user_id={user_id} link_len={len(avatar_link)}') + return await self._unit_of_work.user_repository.set_avatar_link(user_id, avatar_link) diff --git a/src/application/commands/set_encrypted_mnemonic_complete.py b/src/application/commands/set_encrypted_mnemonic_complete.py new file mode 100644 index 0000000..0112421 --- /dev/null +++ b/src/application/commands/set_encrypted_mnemonic_complete.py @@ -0,0 +1,68 @@ +from src.application.abstractions import IUnitOfWork +from src.application.contracts import IHashService, ILogger, ICache +from src.application.domain.entities import UserEntity +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.database.decorators import transactional + + +class SetEncryptedMnemonicCompleteCommand: + def __init__( + self, + unit_of_work: IUnitOfWork, + hash_service: IHashService, + cache: ICache, + logger: ILogger, + ): + self._unit_of_work = unit_of_work + self._hash_service = hash_service + self._cache = cache + self._logger = logger + + @transactional + async def __call__(self, *, user_id: str, code: str, encrypted_mnemonic: str) -> UserEntity: + code = (code or '').strip() + + USER_PREFIX = 'encrypted_mnemonic:user:' + CODE_PREFIX = 'encrypted_mnemonic:code:' + + user_key = f'{USER_PREFIX}{user_id}' + code_key = f'{CODE_PREFIX}{code}' + + user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id) + if user.encrypted_mnemonic is not None: + self._logger.info(f'Encrypted mnemonic already set for user_id={user_id}') + raise ApplicationException(409, 'Encrypted mnemonic already set and cannot be changed') + + cached_user_id = await self._cache.get(code_key) + if not cached_user_id: + self._logger.info(f'Encrypted mnemonic set failed: code not found (user_id={user_id})') + raise ApplicationException(400, 'Invalid or expired code') + + if cached_user_id != user_id: + self._logger.info(f'Encrypted mnemonic set failed: code-user mismatch (user_id={user_id})') + raise ApplicationException(400, 'Invalid or expired code') + + code_hash = await self._cache.get(user_key) + if not code_hash: + self._logger.info(f'Encrypted mnemonic set failed: user key missing (user_id={user_id})') + raise ApplicationException(400, 'Invalid or expired code') + + ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code) + if not ok: + self._logger.info(f'Encrypted mnemonic set failed: code hash mismatch (user_id={user_id})') + raise ApplicationException(400, 'Invalid or expired code') + + user = await self._unit_of_work.user_repository.set_encrypted_mnemonic( + user_id=user_id, + encrypted_mnemonic=encrypted_mnemonic, + ) + await self._cache.set_user(user_id, user) + + try: + await self._cache.delete(code_key) + await self._cache.delete(user_key) + except Exception as e: + self._logger.warning(f'Encrypted mnemonic set cleanup failed (user_id={user_id}): {e}') + + self._logger.info(f'Encrypted mnemonic set for user_id={user_id}') + return user diff --git a/src/application/commands/set_encrypted_mnemonic_start.py b/src/application/commands/set_encrypted_mnemonic_start.py new file mode 100644 index 0000000..2b115fe --- /dev/null +++ b/src/application/commands/set_encrypted_mnemonic_start.py @@ -0,0 +1,130 @@ +import secrets +from datetime import datetime, timezone +from ulid import ULID +from src.application.abstractions import IUnitOfWork +from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.config import settings +from src.infrastructure.context_vars import trace_id_var +from src.infrastructure.database.decorators import transactional + + +class SetEncryptedMnemonicStartCommand: + def __init__( + self, + hash_service: IHashService, + cache: ICache, + unit_of_work: IUnitOfWork, + logger: ILogger, + messanger: IQueueMessanger, + ): + self._hash_service = hash_service + self._unit_of_work = unit_of_work + self._cache = cache + self._logger = logger + self._messanger = messanger + + @transactional + async def __call__(self, user_id: str) -> bool: + TTL = 300 + LOCK_TTL = 30 + MAX_ATTEMPTS = 20 + + USER_PREFIX = 'encrypted_mnemonic:user:' + CODE_PREFIX = 'encrypted_mnemonic:code:' + LOCK_PREFIX = 'encrypted_mnemonic:lock:' + + user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id) + + if user.encrypted_mnemonic is not None: + self._logger.info(f'Encrypted mnemonic already set for user_id={user_id}') + raise ApplicationException(409, 'Encrypted mnemonic already set and cannot be changed') + + if not user.email: + self._logger.warning(f'User {user_id} does not have an email address') + raise ApplicationException(404, f'User {user_id} does not have an email address') + + trace_id = trace_id_var.get() + if not trace_id or trace_id == 'N/A': + trace_id = None + + lock_key = f'{LOCK_PREFIX}{user_id}' + locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL) + if not locked: + self._logger.info(f'Encrypted mnemonic set throttled by lock (user_id={user_id})') + raise ApplicationException(429, 'Too many requests. Please wait.') + + try: + user_key = f'{USER_PREFIX}{user_id}' + + existing = await self._cache.get(user_key) + if existing: + self._logger.info(f'Encrypted mnemonic set denied: code already exists for user_id={user_id}') + raise ApplicationException(429, 'Code already sent. Please wait before retrying.') + + for _ in range(MAX_ATTEMPTS): + code = f'{secrets.randbelow(1_000_000):06d}' + code_key = f'{CODE_PREFIX}{code}' + + code_hash = await self._hash_service.hash(code) + + reserved = await self._cache.set_nx(code_key, user_id, ttl=TTL) + if not reserved: + continue + + saved = await self._cache.set(user_key, code_hash, ttl=TTL) + if not saved: + await self._cache.delete(code_key) + self._logger.error(f'Encrypted mnemonic set failed: cannot save code hash for user_id={user_id}') + raise ApplicationException(503, 'Temporary error. Please try again.') + + message_id = str(ULID()) + now = datetime.now(timezone.utc).isoformat() + + metadata = { + 'trace_id': trace_id, + 'source': 'user-service', + 'timestamp': now, + 'message_id': message_id, + } + + payload = { + 'email': user.email, + 'code': code, + 'ttl_seconds': TTL, + } + + message = { + 'event': 'encrypted_mnemonic_set', + 'payload': payload, + 'metadata': metadata, + } + + self._logger.info(f'Encrypted mnemonic set code created for user_id={user_id}') + + try: + await self._messanger.publish_to_queue( + queue=settings.RABBIT_EMAIL_CODE_QUEUE, + message=message, + persist=True, + correlation_id=trace_id, + message_id=message_id, + headers={'trace_id': trace_id} if trace_id else None, + ) + except Exception as exception: + try: + await self._cache.delete(user_key) + await self._cache.delete(code_key) + except Exception as rollback_err: + self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}') + + self._logger.error(f'Failed to publish encrypted mnemonic set email for user_id={user_id}: {str(exception)}') + raise ApplicationException(503, 'Temporary error. Please try again.') + + return True + + self._logger.error(f'Encrypted mnemonic set failed: code space exhausted for user_id={user_id}') + raise ApplicationException(503, 'Temporary error. Please try again.') + + finally: + await self._cache.delete(lock_key) diff --git a/src/application/commands/set_phone.py b/src/application/commands/set_phone.py new file mode 100644 index 0000000..6537533 --- /dev/null +++ b/src/application/commands/set_phone.py @@ -0,0 +1,18 @@ +from src.application.abstractions import IUnitOfWork +from src.application.contracts import ILogger, ICache +from src.application.domain.entities import UserEntity +from src.infrastructure.database.decorators import transactional + + +class SetPhoneCommand: + def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger, cache: ICache): + self._unit_of_work = unit_of_work + self._logger = logger + self._cache = cache + + @transactional + async def __call__(self, user_id: str, phone: str) -> UserEntity: + user = await self._unit_of_work.user_repository.set_phone(user_id=user_id, phone=phone) + await self._cache.set_user(user_id, user) + self._logger.info(f'Set phone for user {user_id}') + return user \ No newline at end of file diff --git a/src/application/commands/update_bank_details_complete.py b/src/application/commands/update_bank_details_complete.py new file mode 100644 index 0000000..1c5ba8a --- /dev/null +++ b/src/application/commands/update_bank_details_complete.py @@ -0,0 +1,76 @@ +from src.application.abstractions import IUnitOfWork +from src.application.contracts import IHashService, ILogger, ICache +from src.application.domain.entities import UserEntity +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.database.decorators import transactional + + +class UpdateBankDetailsCompleteCommand: + def __init__( + self, + unit_of_work: IUnitOfWork, + hash_service: IHashService, + cache: ICache, + logger: ILogger, + ): + self._unit_of_work = unit_of_work + self._hash_service = hash_service + self._cache = cache + self._logger = logger + + @transactional + async def __call__( + self, + *, + user_id: str, + code: str, + passport_data: str | None = None, + inn: str | None = None, + erc20: str | None = None, + ) -> UserEntity: + code = (code or '').strip() + + USER_PREFIX = 'bank_details:user:' + CODE_PREFIX = 'bank_details:code:' + + user_key = f'{USER_PREFIX}{user_id}' + code_key = f'{CODE_PREFIX}{code}' + + cached_user_id = await self._cache.get(code_key) + if not cached_user_id: + self._logger.info(f'Bank details update failed: code not found (user_id={user_id})') + raise ApplicationException(400, 'Invalid or expired code') + + if cached_user_id != user_id: + self._logger.info(f'Bank details update failed: code-user mismatch (user_id={user_id})') + raise ApplicationException(400, 'Invalid or expired code') + + code_hash = await self._cache.get(user_key) + if not code_hash: + self._logger.info(f'Bank details update failed: user key missing (user_id={user_id})') + raise ApplicationException(400, 'Invalid or expired code') + + ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code) + if not ok: + self._logger.info(f'Bank details update failed: code hash mismatch (user_id={user_id})') + raise ApplicationException(400, 'Invalid or expired code') + + fields = {} + if passport_data is not None: + fields['passport_data'] = passport_data + if inn is not None: + fields['inn'] = inn + if erc20 is not None: + fields['erc20'] = erc20 + + user = await self._unit_of_work.user_repository.set_bank_details(user_id, **fields) + await self._cache.set_user(user_id, user) + + try: + await self._cache.delete(code_key) + await self._cache.delete(user_key) + except Exception as e: + self._logger.warning(f'Bank details update cleanup failed (user_id={user_id}): {e}') + + self._logger.info(f'Bank details updated for user_id={user_id}, fields={list(fields.keys())}') + return user diff --git a/src/application/commands/update_bank_details_start.py b/src/application/commands/update_bank_details_start.py new file mode 100644 index 0000000..f3d8148 --- /dev/null +++ b/src/application/commands/update_bank_details_start.py @@ -0,0 +1,126 @@ +import secrets +from datetime import datetime, timezone +from ulid import ULID +from src.application.abstractions import IUnitOfWork +from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.config import settings +from src.infrastructure.context_vars import trace_id_var +from src.infrastructure.database.decorators import transactional + + +class UpdateBankDetailsStartCommand: + def __init__( + self, + hash_service: IHashService, + cache: ICache, + unit_of_work: IUnitOfWork, + logger: ILogger, + messanger: IQueueMessanger, + ): + self._hash_service = hash_service + self._unit_of_work = unit_of_work + self._cache = cache + self._logger = logger + self._messanger = messanger + + @transactional + async def __call__(self, user_id: str) -> bool: + TTL = 300 + LOCK_TTL = 30 + MAX_ATTEMPTS = 20 + + USER_PREFIX = 'bank_details:user:' + CODE_PREFIX = 'bank_details:code:' + LOCK_PREFIX = 'bank_details:lock:' + + user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id) + + if not user.email: + self._logger.warning(f'User {user_id} does not have an email address') + raise ApplicationException(status_code=404, message=f'User {user_id} does not have an email address') + + trace_id = trace_id_var.get() + if not trace_id or trace_id == 'N/A': + trace_id = None + + lock_key = f'{LOCK_PREFIX}{user_id}' + locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL) + if not locked: + self._logger.info(f'Bank details update throttled by lock (user_id={user_id})') + raise ApplicationException(429, 'Too many requests. Please wait.') + + try: + user_key = f'{USER_PREFIX}{user_id}' + + existing = await self._cache.get(user_key) + if existing: + self._logger.info(f'Bank details update denied: code already exists for user_id={user_id}') + raise ApplicationException(429, 'Code already sent. Please wait before retrying.') + + for _ in range(MAX_ATTEMPTS): + code = f'{secrets.randbelow(1_000_000):06d}' + code_key = f'{CODE_PREFIX}{code}' + + code_hash = await self._hash_service.hash(code) + + reserved = await self._cache.set_nx(code_key, user_id, ttl=TTL) + if not reserved: + continue + + saved = await self._cache.set(user_key, code_hash, ttl=TTL) + if not saved: + await self._cache.delete(code_key) + self._logger.error(f'Bank details update failed: cannot save code hash for user_id={user_id}') + raise ApplicationException(503, 'Temporary error. Please try again.') + + message_id = str(ULID()) + now = datetime.now(timezone.utc).isoformat() + + metadata = { + 'trace_id': trace_id, + 'source': 'user-service', + 'timestamp': now, + 'message_id': message_id, + } + + payload = { + 'email': user.email, + 'code': code, + 'ttl_seconds': TTL, + } + + message = { + 'event': 'bank_details_update', + 'payload': payload, + 'metadata': metadata, + } + + self._logger.info(f'Bank details update code created for user_id={user_id}') + + try: + await self._messanger.publish_to_queue( + queue=settings.RABBIT_EMAIL_CODE_QUEUE, + message=message, + persist=True, + correlation_id=trace_id, + message_id=message_id, + headers={'trace_id': trace_id} if trace_id else None, + ) + except Exception as exception: + try: + await self._cache.delete(user_key) + await self._cache.delete(code_key) + except Exception as rollback_err: + self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}') + + self._logger.error(f'Failed to publish bank details update email for user_id={user_id}: {str(exception)}') + raise ApplicationException(503, 'Temporary error. Please try again.') + + return True + + self._logger.error(f'Bank details update failed: code space exhausted for user_id={user_id}') + raise ApplicationException(503, 'Temporary error. Please try again.') + + finally: + await self._cache.delete(lock_key) diff --git a/src/application/contracts/__init__.py b/src/application/contracts/__init__.py new file mode 100644 index 0000000..6dd2d4b --- /dev/null +++ b/src/application/contracts/__init__.py @@ -0,0 +1,7 @@ +from src.application.contracts.i_logger import ILogger +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_s3 import IS3 diff --git a/src/application/contracts/i_cache.py b/src/application/contracts/i_cache.py new file mode 100644 index 0000000..9627ad8 --- /dev/null +++ b/src/application/contracts/i_cache.py @@ -0,0 +1,30 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from src.application.domain.entities.user import UserEntity + + +class ICache(ABC): + + @abstractmethod + async def set(self, key: str, value: str, ttl: int) -> bool: + raise NotImplementedError + + @abstractmethod + async def set_nx(self, key: str, value: str, ttl: int) -> bool: + raise NotImplementedError + + @abstractmethod + async def get(self, key: str) -> str | None: + raise NotImplementedError + + @abstractmethod + async def delete(self, key: str) -> bool: + raise NotImplementedError + + @abstractmethod + async def get_user(self, user_id: str) -> dict | None: + raise NotImplementedError + + @abstractmethod + async def set_user(self, user_id: str, user: UserEntity, ttl: int = 300) -> None: + raise NotImplementedError \ No newline at end of file diff --git a/src/application/contracts/i_csrf_service.py b/src/application/contracts/i_csrf_service.py new file mode 100644 index 0000000..a493d60 --- /dev/null +++ b/src/application/contracts/i_csrf_service.py @@ -0,0 +1,26 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from typing import Any, Optional, Mapping + + +class ICsrfService(ABC): + @abstractmethod + def issue(self, subject: Optional[str] = None) -> str: + raise NotImplementedError + + @abstractmethod + def verify(self, token: str, expected_subject: Optional[str] = None) -> dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def extract(self, cookies: Mapping[str, str], headers: Mapping[str, str]) -> tuple[Optional[str], Optional[str]]: + raise NotImplementedError + + @abstractmethod + def verify_pair( + self, + cookie_token: Optional[str], + header_token: Optional[str], + expected_subject: Optional[str] = None, + ) -> None: + raise NotImplementedError diff --git a/src/application/contracts/i_hash_service.py b/src/application/contracts/i_hash_service.py new file mode 100644 index 0000000..438090f --- /dev/null +++ b/src/application/contracts/i_hash_service.py @@ -0,0 +1,12 @@ +from abc import ABC, abstractmethod + + +class IHashService(ABC): + + @abstractmethod + async def hash(self, value: str) -> str: + raise NotImplementedError + + @abstractmethod + async def verify(self, hashed_value: str, plain_value: str) -> bool: + raise NotImplementedError \ No newline at end of file diff --git a/src/application/contracts/i_jwt_service.py b/src/application/contracts/i_jwt_service.py new file mode 100644 index 0000000..eafd65d --- /dev/null +++ b/src/application/contracts/i_jwt_service.py @@ -0,0 +1,10 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from src.application.domain.dto import AccessTokenPayload + + +class IJwtService(ABC): + + @abstractmethod + async def decode_access_token(self, token: str) -> AccessTokenPayload: + raise NotImplementedError diff --git a/src/application/contracts/i_logger.py b/src/application/contracts/i_logger.py new file mode 100644 index 0000000..1ac5797 --- /dev/null +++ b/src/application/contracts/i_logger.py @@ -0,0 +1,68 @@ +from typing import Protocol, Optional, Callable +from src.application.domain.enums.log_format import LogFormat +from src.application.domain.enums.log_level import LogLevel + + +class ILogger(Protocol): + """Interface for synchronous logger with ContextVar support for trace_id.""" + + log_format: LogFormat + min_level: LogLevel + id_generator: Optional[Callable[[], str]] + instance_id: str + + def set_format(self, log_format: LogFormat) -> None: + """Set log format using LogFormat enum""" + ... + + def set_min_level(self, level: LogLevel) -> None: + """Set minimum log level""" + ... + + def new_trace_id(self) -> str: + """Create and set new trace_id in context""" + ... + + def set_trace_id(self, trace_id: str) -> None: + """Set existing trace_id in context""" + ... + + def get_trace_id(self) -> str: + """Get current trace_id from context""" + ... + + def clear_trace_id(self) -> None: + """Clear the trace_id in the context""" + ... + + def set_instance_id(self, instance_id: str) -> None: + """Set service instance id (ULID recommended)""" + ... + + def get_instance_id(self) -> str: + """Get current service instance id""" + ... + + def debug(self, message: str) -> None: + """Log debug message""" + ... + + def info(self, message: str) -> None: + """Log info message""" + ... + + def warning(self, message: str) -> None: + """Log warning message""" + ... + + def error(self, message: str) -> None: + """Log error message""" + ... + + def critical(self, message: str) -> None: + """Log critical message""" + ... + + def exception(self, message: str) -> None: + """Log exception with traceback""" + ... \ No newline at end of file diff --git a/src/application/contracts/i_queue_messanger.py b/src/application/contracts/i_queue_messanger.py new file mode 100644 index 0000000..ab7be50 --- /dev/null +++ b/src/application/contracts/i_queue_messanger.py @@ -0,0 +1,40 @@ +from abc import ABC, abstractmethod +from typing import Mapping, Any + + +class IQueueMessanger(ABC): + + @abstractmethod + async def connect(self) -> None: + raise NotImplementedError + + @abstractmethod + async def close(self) -> None: + raise NotImplementedError + + @abstractmethod + async def publish_to_queue( + self, + queue: str, + message: Any, + *, + persist: bool = True, + headers: Mapping[str, Any] | None = None, + correlation_id: str | None = None, + message_id: str | None = None, + ) -> None: + raise NotImplementedError + + @abstractmethod + async def publish( + self, + message: Any, + *, + exchange: str, + routing_key: str, + persist: bool = True, + headers: Mapping[str, Any] | None = None, + correlation_id: str | None = None, + message_id: str | None = None, + ) -> None: + raise NotImplementedError \ No newline at end of file diff --git a/src/application/contracts/i_s3.py b/src/application/contracts/i_s3.py new file mode 100644 index 0000000..8164896 --- /dev/null +++ b/src/application/contracts/i_s3.py @@ -0,0 +1,17 @@ +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: + ... + + + async def delete_object(self, *, key: str) -> None: + ... + + + def object_key_from_public_url(self, url: str) -> str | None: + ... diff --git a/src/application/domain/dto/__init__.py b/src/application/domain/dto/__init__.py new file mode 100644 index 0000000..fbf3570 --- /dev/null +++ b/src/application/domain/dto/__init__.py @@ -0,0 +1,2 @@ +from src.application.domain.dto.token import AccessTokenPayload, AuthContext +from src.application.domain.dto.keys import JwtPublicKey, JwtPublicKeySet \ No newline at end of file diff --git a/src/application/domain/dto/keys.py b/src/application/domain/dto/keys.py new file mode 100644 index 0000000..99e953d --- /dev/null +++ b/src/application/domain/dto/keys.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass +from typing import Optional, Dict + + +@dataclass(frozen=True) +class JwtPublicKey: + kid: str + public_key_pem: str + + +@dataclass(frozen=True) +class JwtPublicKeySet: + active: JwtPublicKey + previous: Optional[JwtPublicKey] = None + + def public_keys_by_kid(self) -> Dict[str, str]: + out = {self.active.kid: self.active.public_key_pem} + if self.previous: + out[self.previous.kid] = self.previous.public_key_pem + return out \ No newline at end of file diff --git a/src/application/domain/dto/token.py b/src/application/domain/dto/token.py new file mode 100644 index 0000000..f46391c --- /dev/null +++ b/src/application/domain/dto/token.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel + + +class AccessTokenPayload(BaseModel): + sub: str + type: str + sid: str + iat: int + nbf: int + exp: int + iss: str | None = None + aud: str | None = None + + +class AuthContext(BaseModel): + user_id: str + sid: str + token: AccessTokenPayload \ No newline at end of file diff --git a/src/application/domain/entities/__init__.py b/src/application/domain/entities/__init__.py new file mode 100644 index 0000000..7b2df0e --- /dev/null +++ b/src/application/domain/entities/__init__.py @@ -0,0 +1,5 @@ +from src.application.domain.entities.user import UserEntity +from src.application.domain.entities.session import SessionEntity + + +__all__ = ['UserEntity', 'SessionEntity'] \ No newline at end of file diff --git a/src/application/domain/entities/legal_entity.py b/src/application/domain/entities/legal_entity.py new file mode 100644 index 0000000..d3bbbba --- /dev/null +++ b/src/application/domain/entities/legal_entity.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any + + +@dataclass(slots=True) +class LegalEntityEntity: + id: str + user_id: str + name: str + inn: str + status: str + short_name: str | None = None + ogrn: str | None = None + kpp: str | None = None + legal_address: str | None = None + actual_address: str | None = None + bank_details: dict[str, Any] | None = None + contact_person: str | None = None + contact_phone: str | None = None + kyc_verified: bool = True + kyc_verified_at: datetime | None = None diff --git a/src/application/domain/entities/purchase_request.py b/src/application/domain/entities/purchase_request.py new file mode 100644 index 0000000..b2dc3f5 --- /dev/null +++ b/src/application/domain/entities/purchase_request.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal + + +@dataclass(slots=True) +class PurchaseRequestEntity: + id: str + organization_id: str + status: str + usdt_amount: Decimal + rub_amount: Decimal | None + exchange_rate: Decimal | None + service_fee_percent: Decimal | None + comment: str | None + admin_comment: str | None + target_wallet_chain: str | None + target_wallet_address: str | None + tx_hash: str | None + created_at: datetime | None = None + updated_at: datetime | None = None + completed_at: datetime | None = None diff --git a/src/application/domain/entities/session.py b/src/application/domain/entities/session.py new file mode 100644 index 0000000..f774cbc --- /dev/null +++ b/src/application/domain/entities/session.py @@ -0,0 +1,20 @@ +from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(slots=True) +class SessionEntity: + sid: str + user_id: str + device_id: str + + revoked_at: datetime | None + last_seen_at: datetime + + refresh_jti_hash: str | None + refresh_expires_at: datetime | None + + user_agent: str | None = None + first_ip: str | None = None + last_ip: str | None = None diff --git a/src/application/domain/entities/user.py b/src/application/domain/entities/user.py new file mode 100644 index 0000000..3540596 --- /dev/null +++ b/src/application/domain/entities/user.py @@ -0,0 +1,31 @@ +from __future__ import annotations +from dataclasses import dataclass +from datetime import date, datetime + + +@dataclass(slots=True) +class UserEntity: + id: str | None = None + email: str | None = None + password_hash: str | None = None + + first_name: str | None = None + middle_name: str | None = None + last_name: str | None = None + birth_date: date | None = None + + encrypted_mnemonic: str | None = None + phone: str | None = None + + 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 + account_type: str | None = None + + created_at: datetime | None = None + updated_at: datetime | None = None + kyc_verified_at: datetime | None = None diff --git a/src/application/domain/enums/__init__.py b/src/application/domain/enums/__init__.py new file mode 100644 index 0000000..f2785a9 --- /dev/null +++ b/src/application/domain/enums/__init__.py @@ -0,0 +1,2 @@ +from src.application.domain.enums.log_level import LogLevel +from src.application.domain.enums.log_format import LogFormat \ No newline at end of file diff --git a/src/application/domain/enums/account_type.py b/src/application/domain/enums/account_type.py new file mode 100644 index 0000000..abc5671 --- /dev/null +++ b/src/application/domain/enums/account_type.py @@ -0,0 +1,6 @@ +from enum import StrEnum + + +class AccountType(StrEnum): + INDIVIDUAL = 'individual' + LEGAL_ENTITY = 'legal_entity' diff --git a/src/application/domain/enums/log_format.py b/src/application/domain/enums/log_format.py new file mode 100644 index 0000000..b67feab --- /dev/null +++ b/src/application/domain/enums/log_format.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class LogFormat(Enum): + """Enum for supported log formats""" + TEXT = 'text' + JSON = 'json' diff --git a/src/application/domain/enums/log_level.py b/src/application/domain/enums/log_level.py new file mode 100644 index 0000000..be1bc17 --- /dev/null +++ b/src/application/domain/enums/log_level.py @@ -0,0 +1,54 @@ +from enum import Enum + + +class LogLevel(Enum): + DEBUG = 10 + INFO = 20 + WARNING = 30 + ERROR = 40 + CRITICAL = 50 + EXCEPTION = 60 + + def __str__(self) -> str: + return self.name + + def __repr__(self) -> str: + return f"[{self.value}, '{self.name}']" + + def __eq__(self, other: object) -> bool: + if isinstance(other, LogLevel): + return self.value == other.value + if isinstance(other, int): + return self.value == other + return NotImplemented + + def __ne__(self, other: object) -> bool: + return not self.__eq__(other) + + def __lt__(self, other: object) -> bool: + if isinstance(other, LogLevel): + return self.value < other.value + if isinstance(other, int): + return self.value < other + return NotImplemented + + def __le__(self, other: object) -> bool: + if isinstance(other, LogLevel): + return self.value <= other.value + if isinstance(other, int): + return self.value <= other + return NotImplemented + + def __gt__(self, other: object) -> bool: + if isinstance(other, LogLevel): + return self.value > other.value + if isinstance(other, int): + return self.value > other + return NotImplemented + + def __ge__(self, other: object) -> bool: + if isinstance(other, LogLevel): + return self.value >= other.value + if isinstance(other, int): + return self.value >= other + return NotImplemented diff --git a/src/application/domain/exceptions/__init__.py b/src/application/domain/exceptions/__init__.py new file mode 100644 index 0000000..03368ee --- /dev/null +++ b/src/application/domain/exceptions/__init__.py @@ -0,0 +1,11 @@ +from src.application.domain.exceptions.application_exceptions import ( + ApplicationException, + BadRequestException, + ConflictException, + ForbiddenException, + InternalException, + NotFoundException, + ServiceUnavailableException, + TooManyRequestsException, + UnauthorizedException, +) diff --git a/src/application/domain/exceptions/application_exceptions.py b/src/application/domain/exceptions/application_exceptions.py new file mode 100644 index 0000000..03cecd3 --- /dev/null +++ b/src/application/domain/exceptions/application_exceptions.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import Mapping + + +class ApplicationException(Exception): + def __init__( + self, + status_code: int, + message: str, + headers: Mapping[str, str] | None = None, + ): + super().__init__(message) + self.status_code = status_code + self.message = message + self.headers = headers + + def __str__(self) -> str: + return f'{self.status_code}: {self.message}' + + +class BadRequestException(ApplicationException): + def __init__(self, message: str, headers: Mapping[str, str] | None = None): + super().__init__(400, message, headers) + + +class UnauthorizedException(ApplicationException): + def __init__(self, message: str = 'Unauthorized', headers: Mapping[str, str] | None = None): + super().__init__(401, message, headers) + + +class ForbiddenException(ApplicationException): + def __init__(self, message: str = 'Forbidden', headers: Mapping[str, str] | None = None): + super().__init__(403, message, headers) + + +class NotFoundException(ApplicationException): + def __init__(self, message: str = 'Not found', headers: Mapping[str, str] | None = None): + super().__init__(404, message, headers) + + +class ConflictException(ApplicationException): + def __init__(self, message: str, headers: Mapping[str, str] | None = None): + super().__init__(409, message, headers) + + +class TooManyRequestsException(ApplicationException): + def __init__(self, message: str, headers: Mapping[str, str] | None = None): + super().__init__(429, message, headers) + + +class ServiceUnavailableException(ApplicationException): + def __init__(self, message: str, headers: Mapping[str, str] | None = None): + super().__init__(503, message, headers) + + +class InternalException(ApplicationException): + def __init__(self, message: str = 'Internal Server Error', headers: Mapping[str, str] | None = None): + super().__init__(500, message, headers) diff --git a/src/application/domain/password_policy.py b/src/application/domain/password_policy.py new file mode 100644 index 0000000..169072f --- /dev/null +++ b/src/application/domain/password_policy.py @@ -0,0 +1,21 @@ +import re + +SPECIAL_CHARS = '!@#$%^&*()_+-=.,:;?/[]{}<>' + + +def validate_password_strength(password: str) -> str: + if re.search(r'\s', password): + raise ValueError('Password must not contain whitespace') + if len(password) < 12: + raise ValueError('Password must be at least 12 characters') + if not re.search(r'[a-z]', password): + raise ValueError('Password must contain at least one lowercase letter') + if not re.search(r'[A-Z]', password): + raise ValueError('Password must contain at least one uppercase letter') + if not re.search(r'\d', password): + raise ValueError('Password must contain at least one digit') + if not any(c in SPECIAL_CHARS for c in password): + raise ValueError( + 'Password must contain at least one special character from: !@#$%^&*()_+-=.,:;?/[]{}<>' + ) + return password diff --git a/src/infrastructure/cache/__init__.py b/src/infrastructure/cache/__init__.py new file mode 100644 index 0000000..552c4f5 --- /dev/null +++ b/src/infrastructure/cache/__init__.py @@ -0,0 +1,2 @@ +from src.infrastructure.cache.client import create_redis_client +from src.infrastructure.cache.keydb_client import KeydbCache \ No newline at end of file diff --git a/src/infrastructure/cache/client.py b/src/infrastructure/cache/client.py new file mode 100644 index 0000000..4c8b59a --- /dev/null +++ b/src/infrastructure/cache/client.py @@ -0,0 +1,16 @@ +import redis.asyncio as redis +from redis.asyncio.client import Redis +from src.infrastructure.config import settings + + +def create_redis_client() -> Redis: + return redis.from_url( + settings.REDIS_URL, + max_connections=50, + decode_responses=True, + socket_timeout=5, + socket_connect_timeout=5, + health_check_interval=30, + retry_on_timeout=True, + socket_keepalive=True, + ) \ No newline at end of file diff --git a/src/infrastructure/cache/keydb_client.py b/src/infrastructure/cache/keydb_client.py new file mode 100644 index 0000000..c6986ad --- /dev/null +++ b/src/infrastructure/cache/keydb_client.py @@ -0,0 +1,52 @@ +from __future__ import annotations +import orjson +from redis.asyncio.client import Redis +from src.application.contracts import ICache +from src.application.domain.entities.user import UserEntity + + +class KeydbCache(ICache): + USER_PREFIX = 'user:me' + + def __init__(self, redis_client: Redis): + self._r = redis_client + + async def set(self, key: str, value: str, ttl: int) -> bool: + return bool(await self._r.set(key, value, ex=ttl)) + + async def set_nx(self, key: str, value: str, ttl: int) -> bool: + return bool(await self._r.set(key, value, ex=ttl, nx=True)) + + async def get(self, key: str) -> str | None: + return await self._r.get(key) + + async def delete(self, key: str) -> bool: + return (await self._r.delete(key)) > 0 + + async def get_user(self, user_id: str) -> dict | None: + raw = await self._r.get(f'{self.USER_PREFIX}:{user_id}') + if raw is None: + return None + return orjson.loads(raw) + + async def set_user(self, user_id: str, user: UserEntity, ttl: int = 300) -> None: + data = orjson.dumps({ + '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, + '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, + '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, + }) + await self._r.set(f'{self.USER_PREFIX}:{user_id}', data, ex=ttl) diff --git a/src/infrastructure/config/__init__.py b/src/infrastructure/config/__init__.py new file mode 100644 index 0000000..4fb5df4 --- /dev/null +++ b/src/infrastructure/config/__init__.py @@ -0,0 +1 @@ +from src.infrastructure.config.settings import settings diff --git a/src/infrastructure/config/settings.py b/src/infrastructure/config/settings.py new file mode 100644 index 0000000..a1ceaa7 --- /dev/null +++ b/src/infrastructure/config/settings.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +from functools import lru_cache +from typing import Any, List, Literal, Mapping +from urllib.parse import quote + +from dotenv import find_dotenv, load_dotenv +from pydantic import Field, PrivateAttr +from pydantic_settings import BaseSettings, SettingsConfigDict + +from src.infrastructure.vault.client import VaultClient + +env_file = find_dotenv('.env') +if env_file: + load_dotenv(env_file) + + +def _as_int(value: object, default: int) -> int: + if value is None: + return default + if isinstance(value, int): + return value + return int(str(value).strip()) + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8', case_sensitive=True, extra='ignore') + + _vault_database_secrets: dict[str, Any] = PrivateAttr(default_factory=dict) + + VAULT_ADDR: str = 'https://corp.vault.elcsa.ru' + VAULT_ROLE_ID: str = '' + VAULT_SECRET_ID: str = '' + VAULT_NAMESPACE: str | None = None + VAULT_MOUNT_POINT: str = 'dev-secrets' + VAULT_DATABASE_SECRET_PATH: str = 'database' + VAULT_RABBIT_SECRET_PATH: str = 'rabbitmq' + VAULT_CSRF_SECRET_PATH: str = 'csrf' + 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 = '' + DATABASE_PORT: int = Field(default=5432, ge=1, le=65535) + DATABASE_NAME: str = '' + DATABASE_USER: str = '' + DATABASE_PASSWORD: str = '' + + DATABASE_POOL_SIZE: int = 10 + DATABASE_MAX_OVERFLOW: int = 20 + DATABASE_POOL_TIMEOUT: int = 30 + DATABASE_POOL_RECYCLE: int = 3600 + DATABASE_ECHO: bool = False + + CSRF_SECRET_KEY: str = Field( + default='change-me-change-me-change-me-change-me', + min_length=32, + ) + + CSRF_COOKIE_SECURE: bool = False + CSRF_COOKIE_HTTPONLY: bool = True + CSRF_COOKIE_SAMESITE: Literal['Lax', 'Strict', 'None'] = 'Lax' + CSRF_COOKIE_PATH: str = '/' + CSRF_COOKIE_DOMAIN: str | None = None + + DOCS_USERNAME: str = 'admin' + DOCS_PASSWORD: str = 'admin' + + JWT_ACCESS_TTL_SECONDS: int = 15 * 60 + JWT_REFRESH_TTL_SECONDS: int = 30 * 24 * 60 * 60 + JWT_ISSUER: str | None = None + JWT_AUDIENCE: str | None = None + JWT_ALGORITHM: str = 'RS256' + JWT_KEYS_REFRESH_SECONDS: int = 3600 + + REDIS_HOST: str = 'keydb' + REDIS_PORT: int = 6379 + REDIS_PASSWORD: str | None = None + REDIS_DB: int = 0 + + RABBIT_HOST: str = 'localhost' + RABBIT_PORT: int = 5672 + RABBIT_USER: str = 'guest' + RABBIT_PASSWORD: str = 'guest' + RABBIT_VHOST: str = '/' + + RABBIT_PUBLISH_PERSIST: bool = True + 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 = False + S3_AVATAR_KEY_PREFIX: str = 'avatars' + + LOG_LEVEL: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO' + LOG_FORMAT: Literal['JSON', 'TEXT'] = 'JSON' + + def _get_vault_secret(self, secrets: dict[str, Any], *keys: str) -> str: + for key in keys: + value = secrets.get(key) + if value is not None and str(value).strip() != '': + 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', False) + 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( + 'Set VAULT_ROLE_ID and VAULT_SECRET_ID for Vault, or set DATABASE_URL ' + '(or DATABASE_HOST, DATABASE_USER, DATABASE_PASSWORD, DATABASE_NAME) in the environment', + ) + return + + client = VaultClient( + addr=self.VAULT_ADDR, + role_id=self.VAULT_ROLE_ID, + secret_id=self.VAULT_SECRET_ID, + namespace=self.VAULT_NAMESPACE, + mount_point=self.VAULT_MOUNT_POINT, + ) + + db = client.read_secret(self.VAULT_DATABASE_SECRET_PATH) + object.__setattr__(self, '_vault_database_secrets', db) + + def kv(d: dict[str, Any], *keys: str) -> Any: + for k in keys: + if k in d and d[k] is not None: + return d[k] + return None + + if kv(db, 'HOST', 'host') is not None: + object.__setattr__(self, 'DATABASE_HOST', str(kv(db, 'HOST', 'host'))) + if kv(db, 'PORT', 'port') is not None: + object.__setattr__(self, 'DATABASE_PORT', _as_int(kv(db, 'PORT', 'port'), self.DATABASE_PORT)) + if kv(db, 'NAME', 'name') is not None: + object.__setattr__(self, 'DATABASE_NAME', str(kv(db, 'NAME', 'name'))) + if kv(db, 'USER', 'user') is not None: + object.__setattr__(self, 'DATABASE_USER', str(kv(db, 'USER', 'user'))) + if kv(db, 'PASSWORD', 'password') is not None: + object.__setattr__(self, 'DATABASE_PASSWORD', str(kv(db, 'PASSWORD', 'password'))) + + rabbit = client.read_secret_optional(self.VAULT_RABBIT_SECRET_PATH) + if rabbit: + if kv(rabbit, 'HOST', 'host') is not None: + object.__setattr__(self, 'RABBIT_HOST', str(kv(rabbit, 'HOST', 'host'))) + if kv(rabbit, 'PORT', 'port') is not None: + object.__setattr__(self, 'RABBIT_PORT', _as_int(kv(rabbit, 'PORT', 'port'), self.RABBIT_PORT)) + if kv(rabbit, 'USER', 'user') is not None: + object.__setattr__(self, 'RABBIT_USER', str(kv(rabbit, 'USER', 'user'))) + if kv(rabbit, 'PASSWORD', 'password') is not None: + object.__setattr__(self, 'RABBIT_PASSWORD', str(kv(rabbit, 'PASSWORD', 'password'))) + if kv(rabbit, 'VHOST', 'vhost') is not None: + object.__setattr__(self, 'RABBIT_VHOST', str(kv(rabbit, 'VHOST', 'vhost'))) + + csrf = client.read_secret_optional(self.VAULT_CSRF_SECRET_PATH) + if csrf and kv(csrf, 'KEY', 'key') is not None: + key = str(kv(csrf, 'KEY', 'key')) + if len(key) >= 32: + object.__setattr__(self, 'CSRF_SECRET_KEY', key) + + docs = client.read_secret_optional(self.VAULT_DOCS_SECRET_PATH) + if docs: + u = docs.get('DOCS_USERNAME') or docs.get('USERNAME') + p = docs.get('DOCS_PASSWORD') or docs.get('PASSWORD') + if u is not None: + object.__setattr__(self, 'DOCS_USERNAME', str(u)) + 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: + s3_secret_data = client.read_secret_optional(s3_rel_path) + if s3_secret_data: + 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') + + @property + def DATABASE_URL(self) -> str: + direct = (self.DATABASE_URL_DIRECT or '').strip() + if direct: + return direct + + ready_url = self._get_vault_secret( + self._vault_database_secrets, + 'DATABASE_URL', + 'database_url', + ) + if ready_url: + return ready_url + + host = self._get_vault_secret(self._vault_database_secrets, 'host', 'HOST') + port = self._get_vault_secret(self._vault_database_secrets, 'port', 'PORT') or str(self.DATABASE_PORT) + user = self._get_vault_secret(self._vault_database_secrets, 'user', 'USER') + password = self._get_vault_secret(self._vault_database_secrets, 'password', 'PASSWORD') + name = self._get_vault_secret(self._vault_database_secrets, 'name', 'NAME', 'database', 'DATABASE') + if not host or not user or not password or not name: + h = (self.DATABASE_HOST or '').strip() + u = (self.DATABASE_USER or '').strip() + p = (self.DATABASE_PASSWORD or '').strip() + n = (self.DATABASE_NAME or '').strip() + if h and u and p and n: + quoted_user = quote(u, safe='') + quoted_password = quote(p, safe='') + po = str(self.DATABASE_PORT) + return f'postgresql+asyncpg://{quoted_user}:{quoted_password}@{h}:{po}/{n}' + return '' + quoted_user = quote(user, safe='') + quoted_password = quote(password, safe='') + return f'postgresql+asyncpg://{quoted_user}:{quoted_password}@{host}:{port}/{name}' + + @property + def REDIS_URL(self) -> str: + auth = f':{self.REDIS_PASSWORD}@' if self.REDIS_PASSWORD else '' + return f'redis://{auth}{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}' + + @property + def RABBIT_URL(self) -> str: + vhost = '%2F' if self.RABBIT_VHOST == '/' else self.RABBIT_VHOST.lstrip('/') + return f'amqp://{self.RABBIT_USER}:{self.RABBIT_PASSWORD}@{self.RABBIT_HOST}:{self.RABBIT_PORT}/{vhost}' + + @property + def EXCLUDED_PATHS(self) -> List[str]: + return ['/docs', '/redoc', '/openapi.json', '/ping', '/health'] + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/src/infrastructure/context_vars/__init__.py b/src/infrastructure/context_vars/__init__.py new file mode 100644 index 0000000..b68ba6f --- /dev/null +++ b/src/infrastructure/context_vars/__init__.py @@ -0,0 +1 @@ +from src.infrastructure.context_vars.trace_id import trace_id_var \ No newline at end of file diff --git a/src/infrastructure/context_vars/trace_id.py b/src/infrastructure/context_vars/trace_id.py new file mode 100644 index 0000000..ec63d65 --- /dev/null +++ b/src/infrastructure/context_vars/trace_id.py @@ -0,0 +1,4 @@ +from contextvars import ContextVar + + +trace_id_var: ContextVar[str] = ContextVar('trace_id', default='N/A') diff --git a/src/infrastructure/database/__init__.py b/src/infrastructure/database/__init__.py new file mode 100644 index 0000000..71393f5 --- /dev/null +++ b/src/infrastructure/database/__init__.py @@ -0,0 +1 @@ +from src.infrastructure.database.unit_of_work import UnitOfWork \ No newline at end of file diff --git a/src/infrastructure/database/context.py b/src/infrastructure/database/context.py new file mode 100644 index 0000000..c3ac8e9 --- /dev/null +++ b/src/infrastructure/database/context.py @@ -0,0 +1,22 @@ +from sqlalchemy.ext.asyncio import async_sessionmaker +from sqlalchemy.ext.asyncio.engine import create_async_engine +from sqlalchemy.ext.asyncio.session import AsyncSession +from typing import AsyncGenerator +from src.infrastructure.config import settings + + +engine = create_async_engine( + settings.DATABASE_URL, + pool_size=settings.DATABASE_POOL_SIZE, + max_overflow=settings.DATABASE_MAX_OVERFLOW, + pool_timeout=settings.DATABASE_POOL_TIMEOUT, + pool_recycle=settings.DATABASE_POOL_RECYCLE, + echo=settings.DATABASE_ECHO +) + +async_session_maker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + + +async def get_session() -> AsyncGenerator[AsyncSession, None]: + async with async_session_maker() as session: + yield session \ No newline at end of file diff --git a/src/infrastructure/database/decorators/__init__.py b/src/infrastructure/database/decorators/__init__.py new file mode 100644 index 0000000..02df9d0 --- /dev/null +++ b/src/infrastructure/database/decorators/__init__.py @@ -0,0 +1 @@ +from src.infrastructure.database.decorators.transactional import transactional \ No newline at end of file diff --git a/src/infrastructure/database/decorators/transactional.py b/src/infrastructure/database/decorators/transactional.py new file mode 100644 index 0000000..b472d47 --- /dev/null +++ b/src/infrastructure/database/decorators/transactional.py @@ -0,0 +1,15 @@ +from __future__ import annotations +from functools import wraps +from typing import Callable, Awaitable, TypeVar, ParamSpec + + +P = ParamSpec("P") +R = TypeVar("R") + + +def transactional(method: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: + @wraps(method) + async def wrapper(self, *args: P.args, **kwargs: P.kwargs) -> R: + async with self._unit_of_work: + return await method(self, *args, **kwargs) + return wrapper diff --git a/src/infrastructure/database/models/__init__.py b/src/infrastructure/database/models/__init__.py new file mode 100644 index 0000000..cd8ddcd --- /dev/null +++ b/src/infrastructure/database/models/__init__.py @@ -0,0 +1,6 @@ +from src.infrastructure.database.models.base import Base +from src.infrastructure.database.models.user import UserModel +from src.infrastructure.database.models.legal_entity import LegalEntityModel +from src.infrastructure.database.models.purchase_request import PurchaseRequestModel + +__all__ = ['Base', 'UserModel', 'LegalEntityModel', 'PurchaseRequestModel'] diff --git a/src/infrastructure/database/models/base.py b/src/infrastructure/database/models/base.py new file mode 100644 index 0000000..aaed4e3 --- /dev/null +++ b/src/infrastructure/database/models/base.py @@ -0,0 +1,19 @@ +from sqlalchemy.ext.asyncio import AsyncAttrs +from sqlalchemy.orm import DeclarativeBase + + +class Base(AsyncAttrs, DeclarativeBase): + __abstract__ = True + + def __repr__(self) -> str: + class_name = self.__class__.__name__ + attributes = ', '.join(f"{col.name}={getattr(self, col.name, None)!r}" + for col in self.__table__.columns) + return f"<{class_name}({attributes})>" + + def __str__(self) -> str: + class_name = self.__class__.__name__ + attributes = ', '.join(f"{col.name}={getattr(self, col.name)}" + for col in self.__table__.columns + if getattr(self, col.name) is not None) + return f"{class_name}({attributes})" \ No newline at end of file diff --git a/src/infrastructure/database/models/legal_entity.py b/src/infrastructure/database/models/legal_entity.py new file mode 100644 index 0000000..a5ecbe1 --- /dev/null +++ b/src/infrastructure/database/models/legal_entity.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from src.infrastructure.database.models.base import Base +from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin + + +class LegalEntityModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin): + __tablename__ = 'legal_entities' + + user_id: Mapped[str] = mapped_column(String(26), ForeignKey('users.id', ondelete='RESTRICT'), nullable=False, unique=True, index=True) + name: Mapped[str] = mapped_column(String(512), nullable=False) + short_name: Mapped[str | None] = mapped_column(String(256), nullable=True) + inn: Mapped[str] = mapped_column(String(12), nullable=False, index=True) + ogrn: Mapped[str | None] = mapped_column(String(15), nullable=True) + kpp: Mapped[str | None] = mapped_column(String(9), nullable=True) + legal_address: Mapped[str | None] = mapped_column(Text, nullable=True) + actual_address: Mapped[str | None] = mapped_column(Text, nullable=True) + bank_details: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True) + contact_person: Mapped[str | None] = mapped_column(String(256), nullable=True) + contact_phone: Mapped[str | None] = mapped_column(String(16), nullable=True) + status: Mapped[str] = mapped_column(String(32), nullable=False, server_default='active', default='active') + kyc_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='true', default=True) + kyc_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + encrypted_mnemonic: Mapped[str | None] = mapped_column(Text, nullable=True) + created_by: Mapped[str | None] = mapped_column(String(26), nullable=True) diff --git a/src/infrastructure/database/models/mixins/__init__.py b/src/infrastructure/database/models/mixins/__init__.py new file mode 100644 index 0000000..b69a0df --- /dev/null +++ b/src/infrastructure/database/models/mixins/__init__.py @@ -0,0 +1,3 @@ +from src.infrastructure.database.models.mixins.audit import AuditTimestampsMixin +from src.infrastructure.database.models.mixins.ulid import UlidPrimaryKeyMixin +from src.infrastructure.database.models.mixins.soft_delete import SoftDeleteMixin \ No newline at end of file diff --git a/src/infrastructure/database/models/mixins/audit.py b/src/infrastructure/database/models/mixins/audit.py new file mode 100644 index 0000000..c3f143d --- /dev/null +++ b/src/infrastructure/database/models/mixins/audit.py @@ -0,0 +1,16 @@ +from sqlalchemy import DateTime, func +from sqlalchemy.orm import Mapped, mapped_column + + +class AuditTimestampsMixin: + created_at: Mapped[DateTime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + updated_at: Mapped[DateTime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + onupdate=func.now(), + ) \ No newline at end of file diff --git a/src/infrastructure/database/models/mixins/soft_delete.py b/src/infrastructure/database/models/mixins/soft_delete.py new file mode 100644 index 0000000..ca6a14a --- /dev/null +++ b/src/infrastructure/database/models/mixins/soft_delete.py @@ -0,0 +1,6 @@ +from sqlalchemy import Boolean +from sqlalchemy.orm import Mapped, mapped_column + + +class SoftDeleteMixin: + is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='false', default=False) \ No newline at end of file diff --git a/src/infrastructure/database/models/mixins/ulid.py b/src/infrastructure/database/models/mixins/ulid.py new file mode 100644 index 0000000..1d272ef --- /dev/null +++ b/src/infrastructure/database/models/mixins/ulid.py @@ -0,0 +1,8 @@ +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column +from ulid import ULID + + +class UlidPrimaryKeyMixin: + + id: Mapped[str] = mapped_column(String(26), primary_key=True, default=lambda: str(ULID())) diff --git a/src/infrastructure/database/models/purchase_request.py b/src/infrastructure/database/models/purchase_request.py new file mode 100644 index 0000000..526a203 --- /dev/null +++ b/src/infrastructure/database/models/purchase_request.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import DateTime, ForeignKey, Numeric, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from src.infrastructure.database.models.base import Base +from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin + + +class PurchaseRequestModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin): + __tablename__ = 'purchase_requests' + + organization_id: Mapped[str] = mapped_column( + String(26), + ForeignKey('legal_entities.id', ondelete='RESTRICT'), + nullable=False, + index=True, + ) + status: Mapped[str] = mapped_column(String(32), nullable=False, server_default='submitted', default='submitted') + usdt_amount: Mapped[Decimal] = mapped_column(Numeric(18, 8), nullable=False) + rub_amount: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True) + exchange_rate: Mapped[Decimal | None] = mapped_column(Numeric(18, 8), nullable=True) + service_fee_percent: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True) + comment: Mapped[str | None] = mapped_column(Text, nullable=True) + admin_comment: Mapped[str | None] = mapped_column(Text, nullable=True) + target_wallet_chain: Mapped[str | None] = mapped_column(String(16), nullable=True, server_default='ETH') + target_wallet_address: Mapped[str | None] = mapped_column(String(128), nullable=True) + tx_hash: Mapped[str | None] = mapped_column(String(128), nullable=True) + assigned_to: Mapped[str | None] = mapped_column(String(26), nullable=True) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/src/infrastructure/database/models/sessions.py b/src/infrastructure/database/models/sessions.py new file mode 100644 index 0000000..b482d74 --- /dev/null +++ b/src/infrastructure/database/models/sessions.py @@ -0,0 +1,50 @@ +from datetime import datetime, timezone +from sqlalchemy import String, DateTime, ForeignKey, Index +from sqlalchemy.orm import Mapped, mapped_column +from ulid import ULID +from src.infrastructure.database.models import Base +from src.infrastructure.database.models.mixins import UlidPrimaryKeyMixin, AuditTimestampsMixin + + +class Session(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin): + __tablename__ = "sessions" + + sid: Mapped[str] = mapped_column( + String(26), + unique=True, + index=True, + nullable=False, + default=lambda: str(ULID()), + ) + + user_id: Mapped[str] = mapped_column( + String(26), + ForeignKey("users.id", ondelete="CASCADE"), + index=True, + nullable=False, + ) + + device_id: Mapped[str] = mapped_column( + String(26), + nullable=False, + index=True, + ) + + user_agent: Mapped[str | None] = mapped_column(String(500)) + first_ip: Mapped[str | None] = mapped_column(String(64)) + last_ip: Mapped[str | None] = mapped_column(String(64)) + + last_seen_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + refresh_jti_hash: Mapped[str | None] = mapped_column(String(255)) + refresh_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +Index("ux_sessions_user_device", Session.user_id, Session.device_id, unique=True) +Index("ix_sessions_user_active", Session.user_id, Session.revoked_at) diff --git a/src/infrastructure/database/models/user.py b/src/infrastructure/database/models/user.py new file mode 100644 index 0000000..f6a31f4 --- /dev/null +++ b/src/infrastructure/database/models/user.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from sqlalchemy import Boolean, Date, DateTime, String, Text +from sqlalchemy.orm import Mapped, mapped_column +from src.infrastructure.database.models.base import Base +from src.infrastructure.database.models.mixins import UlidPrimaryKeyMixin, AuditTimestampsMixin, SoftDeleteMixin + + +class UserModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin, SoftDeleteMixin): + __tablename__ = 'users' + + email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + + last_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + first_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + middle_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + birth_date: Mapped[Date | None] = mapped_column(Date, nullable=True) + + encrypted_mnemonic: Mapped[str | None] = mapped_column(Text, nullable=True) + phone: Mapped[str | None] = mapped_column(String(16), nullable=True) + + 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) + + account_type: Mapped[str] = mapped_column(String(20), nullable=False, server_default='individual', default='individual') diff --git a/src/infrastructure/database/repositories/__init__.py b/src/infrastructure/database/repositories/__init__.py new file mode 100644 index 0000000..7496069 --- /dev/null +++ b/src/infrastructure/database/repositories/__init__.py @@ -0,0 +1,3 @@ +from src.infrastructure.database.repositories.user_repository import UserRepository +from src.infrastructure.database.repositories.legal_entity_repository import LegalEntityRepository +from src.infrastructure.database.repositories.purchase_request_repository import PurchaseRequestRepository diff --git a/src/infrastructure/database/repositories/legal_entity_repository.py b/src/infrastructure/database/repositories/legal_entity_repository.py new file mode 100644 index 0000000..f663062 --- /dev/null +++ b/src/infrastructure/database/repositories/legal_entity_repository.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import SQLAlchemyError + +from src.application.abstractions.repositories.i_legal_entity_repository import ILegalEntityRepository +from src.application.contracts import ILogger +from src.application.domain.entities.legal_entity import LegalEntityEntity +from src.application.domain.exceptions import InternalException +from src.infrastructure.database.models.legal_entity import LegalEntityModel + + +class LegalEntityRepository(ILegalEntityRepository): + def __init__(self, session: AsyncSession, logger: ILogger): + self._session = session + self._logger = logger + + @staticmethod + def _to_entity(model: LegalEntityModel) -> LegalEntityEntity: + return LegalEntityEntity( + id=model.id, + user_id=model.user_id, + name=model.name, + inn=model.inn, + status=model.status, + short_name=model.short_name, + ogrn=model.ogrn, + kpp=model.kpp, + legal_address=model.legal_address, + actual_address=model.actual_address, + bank_details=model.bank_details, + contact_person=model.contact_person, + contact_phone=model.contact_phone, + kyc_verified=model.kyc_verified, + kyc_verified_at=model.kyc_verified_at, + ) + + async def get_by_user_id(self, user_id: str) -> LegalEntityEntity | None: + try: + stmt = select(LegalEntityModel).where(LegalEntityModel.user_id == user_id) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + if model is None: + return None + return self._to_entity(model) + except SQLAlchemyError as exc: + self._logger.exception(str(exc)) + raise InternalException(message=f'Database error: {exc}') from exc diff --git a/src/infrastructure/database/repositories/purchase_request_repository.py b/src/infrastructure/database/repositories/purchase_request_repository.py new file mode 100644 index 0000000..47b96a4 --- /dev/null +++ b/src/infrastructure/database/repositories/purchase_request_repository.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from decimal import Decimal + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import SQLAlchemyError +from ulid import ULID + +from src.application.abstractions.repositories import IPurchaseRequestRepository +from src.application.contracts import ILogger +from src.application.domain.entities.purchase_request import PurchaseRequestEntity +from src.application.domain.exceptions import ApplicationException, InternalException, NotFoundException +from src.infrastructure.database.models import PurchaseRequestModel + + +class PurchaseRequestRepository(IPurchaseRequestRepository): + def __init__(self, session: AsyncSession, logger: ILogger): + self._session = session + self._logger = logger + + @staticmethod + def _to_entity(model: PurchaseRequestModel) -> PurchaseRequestEntity: + return PurchaseRequestEntity( + id=model.id, + organization_id=model.organization_id, + status=model.status, + usdt_amount=model.usdt_amount, + rub_amount=model.rub_amount, + exchange_rate=model.exchange_rate, + service_fee_percent=model.service_fee_percent, + comment=model.comment, + admin_comment=model.admin_comment, + target_wallet_chain=model.target_wallet_chain, + target_wallet_address=model.target_wallet_address, + tx_hash=model.tx_hash, + created_at=model.created_at, + updated_at=model.updated_at, + completed_at=model.completed_at, + ) + + def _apply_filters(self, stmt, *, organization_id: str, status: str | None): + stmt = stmt.where(PurchaseRequestModel.organization_id == organization_id) + if status: + stmt = stmt.where(PurchaseRequestModel.status == status) + return stmt + + async def create( + self, + *, + organization_id: str, + usdt_amount: Decimal, + comment: str | None, + target_wallet_chain: str | None, + target_wallet_address: str | None, + ) -> PurchaseRequestEntity: + try: + model = PurchaseRequestModel( + id=str(ULID()), + organization_id=organization_id, + status='submitted', + usdt_amount=usdt_amount, + comment=comment, + target_wallet_chain=target_wallet_chain or 'ETH', + target_wallet_address=target_wallet_address, + ) + self._session.add(model) + await self._session.flush() + await self._session.refresh(model) + return self._to_entity(model) + except SQLAlchemyError as exc: + self._logger.exception(str(exc)) + raise InternalException(message=f'Database error: {exc}') from exc + + async def get_by_id(self, request_id: str) -> PurchaseRequestEntity: + try: + res = await self._session.execute( + select(PurchaseRequestModel).where(PurchaseRequestModel.id == request_id) + ) + model = res.scalar_one_or_none() + if model is None: + raise NotFoundException(message='Purchase request not found') + return self._to_entity(model) + except ApplicationException: + raise + except SQLAlchemyError as exc: + self._logger.exception(str(exc)) + raise InternalException(message=f'Database error: {exc}') from exc + + async def list_by_organization( + self, + *, + organization_id: str, + status: str | None, + limit: int, + offset: int, + ) -> list[PurchaseRequestEntity]: + try: + stmt = select(PurchaseRequestModel).order_by(PurchaseRequestModel.created_at.desc()) + stmt = self._apply_filters(stmt, organization_id=organization_id, status=status) + res = await self._session.execute(stmt.limit(limit).offset(offset)) + return [self._to_entity(model) for model in res.scalars().all()] + except SQLAlchemyError as exc: + self._logger.exception(str(exc)) + raise InternalException(message=f'Database error: {exc}') from exc + + async def count_by_organization(self, *, organization_id: str, status: str | None) -> int: + try: + stmt = select(func.count()).select_from(PurchaseRequestModel) + stmt = self._apply_filters(stmt, organization_id=organization_id, status=status) + res = await self._session.execute(stmt) + return int(res.scalar_one()) + except SQLAlchemyError as exc: + self._logger.exception(str(exc)) + raise InternalException(message=f'Database error: {exc}') from exc diff --git a/src/infrastructure/database/repositories/session_repository.py b/src/infrastructure/database/repositories/session_repository.py new file mode 100644 index 0000000..55e8bc4 --- /dev/null +++ b/src/infrastructure/database/repositories/session_repository.py @@ -0,0 +1,198 @@ +from __future__ import annotations +from datetime import datetime +from typing import Optional +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession +from src.application.contracts import ILogger +from src.application.domain.entities import SessionEntity +from src.application.abstractions.repositories import ISessionRepository +from src.infrastructure.database.models import Session + + +class SessionRepository(ISessionRepository): + def __init__(self, session: AsyncSession, logger: ILogger): + self._session = session + self._logger = logger + + async def get_by_sid(self, sid: str) -> Optional[SessionEntity]: + res = await self._session.execute(select(Session).where(Session.sid == sid)) + m = res.scalar_one_or_none() + if m is None: + return None + + return SessionEntity( + sid=m.sid, + user_id=m.user_id, + device_id=m.device_id, + revoked_at=m.revoked_at, + last_seen_at=m.last_seen_at, + refresh_jti_hash=m.refresh_jti_hash, + refresh_expires_at=m.refresh_expires_at, + user_agent=m.user_agent, + first_ip=m.first_ip, + last_ip=m.last_ip, + ) + + async def get_by_user_device(self, user_id: str, device_id: str) -> Optional[SessionEntity]: + res = await self._session.execute( + select(Session).where(Session.user_id == user_id, Session.device_id == device_id) + ) + m = res.scalar_one_or_none() + if m is None: + return None + + return SessionEntity( + sid=m.sid, + user_id=m.user_id, + device_id=m.device_id, + revoked_at=m.revoked_at, + last_seen_at=m.last_seen_at, + refresh_jti_hash=m.refresh_jti_hash, + refresh_expires_at=m.refresh_expires_at, + user_agent=m.user_agent, + first_ip=m.first_ip, + last_ip=m.last_ip, + ) + + async def upsert_by_device( + self, + *, + user_id: str, + device_id: str, + sid: str, + refresh_jti_hash: str, + refresh_expires_at: datetime, + user_agent: str | None, + ip: str | None, + now: datetime, + ) -> SessionEntity: + res = await self._session.execute( + select(Session).where(Session.user_id == user_id, Session.device_id == device_id) + ) + m = res.scalar_one_or_none() + + if m is None: + m = Session( + sid=sid, + user_id=user_id, + device_id=device_id, + revoked_at=None, + last_seen_at=now, + refresh_jti_hash=refresh_jti_hash, + refresh_expires_at=refresh_expires_at, + user_agent=user_agent, + first_ip=ip, + last_ip=ip, + ) + self._session.add(m) + await self._session.flush() + + self._logger.info(f'Session created (user_id={user_id}, device_id={device_id}, sid={sid})') + else: + m.sid = sid + m.revoked_at = None + m.last_seen_at = now + m.refresh_jti_hash = refresh_jti_hash + m.refresh_expires_at = refresh_expires_at + m.user_agent = user_agent + m.last_ip = ip + + await self._session.flush() + + self._logger.info(f'Session updated (user_id={user_id}, device_id={device_id}, sid={sid})') + + return SessionEntity( + sid=m.sid, + user_id=m.user_id, + device_id=m.device_id, + revoked_at=m.revoked_at, + last_seen_at=m.last_seen_at, + refresh_jti_hash=m.refresh_jti_hash, + refresh_expires_at=m.refresh_expires_at, + user_agent=m.user_agent, + first_ip=m.first_ip, + last_ip=m.last_ip, + ) + + async def revoke_by_sid(self, sid: str, now: datetime) -> None: + # Интерфейс требует None -> просто делаем update и flush + await self._session.execute( + update(Session) + .where(Session.sid == sid, Session.revoked_at.is_(None)) + .values(revoked_at=now) + .execution_options(synchronize_session='fetch') + ) + await self._session.flush() + + async def rotate_refresh( + self, + sid: str, + new_jti_hash: str, + new_refresh_expires_at: datetime, + now: datetime, + ip: str | None, + user_agent: str | None, + ) -> None: + + values = { + 'refresh_jti_hash': new_jti_hash, + 'refresh_expires_at': new_refresh_expires_at, + 'last_seen_at': now, + 'user_agent': user_agent, + } + if ip is not None: + values['last_ip'] = ip + + await self._session.execute( + update(Session) + .where(Session.sid == sid, Session.revoked_at.is_(None)) + .values(**values) + .execution_options(synchronize_session='fetch') + ) + await self._session.flush() + + async def touch_last_seen(self, sid: str, *, ip: str | None, now: datetime) -> None: + values = {'last_seen_at': now} + if ip is not None: + values['last_ip'] = ip + + await self._session.execute( + update(Session) + .where(Session.sid == sid, Session.revoked_at.is_(None)) + .values(**values) + .execution_options(synchronize_session='fetch') + ) + await self._session.flush() + + async def rotate_refresh_if_match( + self, + *, + sid: str, + old_jti_hash: str, + new_jti_hash: str, + new_refresh_expires_at: datetime, + now: datetime, + ip: str | None, + user_agent: str | None, + ) -> bool: + values = { + 'refresh_jti_hash': new_jti_hash, + 'refresh_expires_at': new_refresh_expires_at, + 'last_seen_at': now, + 'user_agent': user_agent, + } + if ip is not None: + values['last_ip'] = ip + + res = await self._session.execute( + update(Session) + .where( + Session.sid == sid, + Session.revoked_at.is_(None), + Session.refresh_jti_hash == old_jti_hash, # ✅ защита от гонок + ) + .values(**values) + .execution_options(synchronize_session='fetch') + ) + await self._session.flush() + return (res.rowcount or 0) > 0 diff --git a/src/infrastructure/database/repositories/user_repository.py b/src/infrastructure/database/repositories/user_repository.py new file mode 100644 index 0000000..bd4a6f7 --- /dev/null +++ b/src/infrastructure/database/repositories/user_repository.py @@ -0,0 +1,58 @@ +from __future__ import annotations +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import SQLAlchemyError +from src.application.contracts import ILogger +from src.application.domain.exceptions import ApplicationException, InternalException, NotFoundException +from src.application.abstractions.repositories import IUserRepository +from src.application.domain.entities import UserEntity +from src.infrastructure.database.models import UserModel + + +class UserRepository(IUserRepository): + def __init__(self, session: AsyncSession, logger: ILogger): + self._session = session + self._logger = logger + + async def _get_active_user(self, user_id: str) -> UserModel: + stmt = ( + select(UserModel) + .where( + UserModel.id == user_id, + UserModel.is_deleted.is_(False), + ) + ) + result = await self._session.execute(stmt) + user: UserModel | None = result.scalar_one_or_none() + if user is None: + self._logger.warning(f'User not found with user_id {user_id}') + raise NotFoundException(message='User not found') + return user + + @staticmethod + def _to_entity(user: UserModel) -> UserEntity: + return UserEntity( + id=user.id, + email=user.email, + account_type=user.account_type, + first_name=user.first_name, + middle_name=user.middle_name, + last_name=user.last_name, + birth_date=user.birth_date, + phone=user.phone, + kyc_verified_at=user.kyc_verified_at, + kyc_verified=user.kyc_verified, + is_deleted=user.is_deleted, + created_at=user.created_at, + updated_at=user.updated_at, + ) + + async def get_user_by_id(self, user_id: str) -> UserEntity: + try: + user = await self._get_active_user(user_id) + return self._to_entity(user) + except ApplicationException: + raise + except SQLAlchemyError as exception: + self._logger.exception(str(exception)) + raise InternalException(message=f'Database error: {str(exception)}') diff --git a/src/infrastructure/database/unit_of_work.py b/src/infrastructure/database/unit_of_work.py new file mode 100644 index 0000000..1aec81e --- /dev/null +++ b/src/infrastructure/database/unit_of_work.py @@ -0,0 +1,61 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from src.application.abstractions import IUnitOfWork +from src.application.abstractions.repositories import ( + IUserRepository, + ILegalEntityRepository, + IPurchaseRequestRepository, +) +from src.application.contracts import ILogger +from src.infrastructure.database.repositories import ( + UserRepository, + LegalEntityRepository, + PurchaseRequestRepository, +) + + +class UnitOfWork(IUnitOfWork): + def __init__(self, session_factory: async_sessionmaker[AsyncSession], logger: ILogger): + self.session_factory = session_factory + self._session: AsyncSession = None + self._user_repository: IUserRepository = None + self._legal_entity_repository: ILegalEntityRepository = None + self._purchase_request_repository: IPurchaseRequestRepository = None + self._logger: ILogger = logger + + async def __aenter__(self): + self._logger.debug('UnitOfWork enter') + self._user_repository = None + self._legal_entity_repository = None + self._purchase_request_repository = None + self._session = self.session_factory() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if exc_type: + self._logger.error(f'UnitOfWork rollback_on_error exc_type={exc_type.__name__} exc_val={exc_val!r}') + await self._session.rollback() + self._logger.debug(f'UnitOfWork session rollback done exc_type={exc_type.__name__}') + else: + await self._session.flush() + await self._session.commit() + self._logger.debug('UnitOfWork commit') + await self._session.close() + self._logger.debug('UnitOfWork exit session closed') + + @property + def user_repository(self) -> IUserRepository: + if self._user_repository is None: + self._user_repository = UserRepository(session=self._session, logger=self._logger) + return self._user_repository + + @property + def legal_entity_repository(self) -> ILegalEntityRepository: + if self._legal_entity_repository is None: + self._legal_entity_repository = LegalEntityRepository(session=self._session, logger=self._logger) + return self._legal_entity_repository + + @property + def purchase_request_repository(self) -> IPurchaseRequestRepository: + if self._purchase_request_repository is None: + self._purchase_request_repository = PurchaseRequestRepository(session=self._session, logger=self._logger) + return self._purchase_request_repository diff --git a/src/infrastructure/logger/__init__.py b/src/infrastructure/logger/__init__.py new file mode 100644 index 0000000..a6fee69 --- /dev/null +++ b/src/infrastructure/logger/__init__.py @@ -0,0 +1,28 @@ +from src.application.contracts import ILogger +from src.application.domain.enums import LogFormat +from src.application.domain.enums import LogLevel +from src.infrastructure.config.settings import settings +from src.infrastructure.logger.logger import Logger + +log_levels = { + 'DEBUG': LogLevel.DEBUG, + 'INFO': LogLevel.INFO, + 'WARNING': LogLevel.WARNING, + 'ERROR': LogLevel.ERROR, + 'CRITICAL': LogLevel.CRITICAL, + 'EXCEPTION': LogLevel.EXCEPTION, +} + +log_formats = { + 'JSON': LogFormat.JSON, + 'TEXT': LogFormat.TEXT, +} + +logger = Logger( + min_level=log_levels.get(settings.LOG_LEVEL, LogLevel.INFO), + log_format=log_formats.get(settings.LOG_FORMAT, LogFormat.JSON), +) + + +def get_logger() -> ILogger: + return logger diff --git a/src/infrastructure/logger/logger.py b/src/infrastructure/logger/logger.py new file mode 100644 index 0000000..0fc2c8d --- /dev/null +++ b/src/infrastructure/logger/logger.py @@ -0,0 +1,129 @@ +import traceback +import inspect +import sys +import json +from datetime import datetime +from typing import Callable, Optional, Any +from ulid import ULID +from src.application.contracts import ILogger +from src.application.domain.enums import LogFormat, LogLevel +from src.infrastructure.context_vars import trace_id_var + + +class Logger(ILogger): + _instance = None + __default_format = LogFormat.JSON + + def __new__(cls, *args: Any, **kwargs: Any) -> "Logger": + if cls._instance is None: + cls._instance = super(Logger, cls).__new__(cls) + return cls._instance + + def __init__( + self, + log_format: LogFormat = __default_format, + min_level: LogLevel = LogLevel.INFO, + id_generator: Optional[Callable[[], str]] = lambda: str(ULID()), + instance_id: str = "N/A", + ): + self.log_format = log_format + self.min_level = min_level + self.id_generator = id_generator + self.instance_id = instance_id + + def set_instance_id(self, instance_id: str) -> None: + self.instance_id = instance_id + + def get_instance_id(self) -> str: + return self.instance_id + + def set_format(self, log_format: LogFormat) -> None: + if not isinstance(log_format, LogFormat): + raise ValueError("Log format must be an instance of LogFormat enum") + self.log_format = log_format + + def set_min_level(self, level: LogLevel) -> None: + self.min_level = level + + def new_trace_id(self) -> str: + trace_id = str(ULID()) if self.id_generator is None else self.id_generator() + trace_id_var.set(trace_id) + return trace_id + + def set_trace_id(self, trace_id: str) -> None: + trace_id_var.set(trace_id) + + def get_trace_id(self) -> str: + return trace_id_var.get() + + def clear_trace_id(self) -> None: + trace_id_var.set("N/A") + + def _prepare_log_data(self, level: LogLevel, message: str) -> dict[str, Any]: + current_frame = inspect.currentframe() + if ( + current_frame + and current_frame.f_back + and current_frame.f_back.f_back + and current_frame.f_back.f_back.f_back + ): + frame = current_frame.f_back.f_back.f_back + filename = frame.f_code.co_filename + line_number = frame.f_lineno + else: + filename = "unknown" + line_number = 0 + + log_data = { + "timestamp": datetime.now().isoformat(), + "level": level.name, + "instance_id": self.instance_id, + "file": filename, + "line": line_number, + "trace_id": trace_id_var.get(), + "message": message, + } + + if level == LogLevel.EXCEPTION: + log_data["exception"] = traceback.format_exc() + + return log_data + + def _log(self, level: LogLevel, message: str) -> None: + if level >= self.min_level: + log_data = self._prepare_log_data(level, message) + + if self.log_format == LogFormat.JSON: + log_message = json.dumps(log_data, ensure_ascii=False) + else: + log_message = ( + f"{log_data['timestamp']} - {log_data['level']} - " + f"{log_data['instance_id']} - {log_data['trace_id']} - " + f"{log_data['file']}:{log_data['line']} - " + f"{log_data['message']}" + ) + if "exception" in log_data: + log_message += f"\nTraceback:\n{log_data['exception']}" + + self._write(log_message) + + def _write(self, message: str) -> None: + sys.stdout.write(message + "\n") + + def debug(self, message: str) -> None: + self._log(LogLevel.DEBUG, message) + + def info(self, message: str) -> None: + self._log(LogLevel.INFO, message) + + def warning(self, message: str) -> None: + self._log(LogLevel.WARNING, message) + + def error(self, message: str) -> None: + self._log(LogLevel.ERROR, message) + + def critical(self, message: str) -> None: + self._log(LogLevel.CRITICAL, message) + + def exception(self, message: str) -> None: + self._log(LogLevel.EXCEPTION, message) \ No newline at end of file diff --git a/src/infrastructure/media/webp.py b/src/infrastructure/media/webp.py new file mode 100644 index 0000000..64002f7 --- /dev/null +++ b/src/infrastructure/media/webp.py @@ -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() diff --git a/src/infrastructure/messanger/__init__.py b/src/infrastructure/messanger/__init__.py new file mode 100644 index 0000000..0369f8a --- /dev/null +++ b/src/infrastructure/messanger/__init__.py @@ -0,0 +1 @@ +from src.infrastructure.messanger.rabbit_client import RabbitClient \ No newline at end of file diff --git a/src/infrastructure/messanger/rabbit_client.py b/src/infrastructure/messanger/rabbit_client.py new file mode 100644 index 0000000..d18db3f --- /dev/null +++ b/src/infrastructure/messanger/rabbit_client.py @@ -0,0 +1,72 @@ +from typing import Any, Mapping +from faststream.rabbit import RabbitBroker +from src.application.contracts import IQueueMessanger +from src.infrastructure.config import settings + + +class RabbitClient(IQueueMessanger): + def __init__(self) -> None: + self._broker = RabbitBroker( + settings.RABBIT_URL, + ) + self._connected = False + + async def connect(self) -> None: + if self._connected: + return + await self._broker.connect() + self._connected = True + + async def close(self) -> None: + if not self._connected: + return + await self._broker.close() + self._connected = False + + async def _ensure_connected(self) -> None: + if not self._connected: + await self.connect() + + async def publish_to_queue( + self, + queue: str, + message: Any, + *, + persist: bool | None = None, + headers: Mapping[str, Any] | None = None, + correlation_id: str | None = None, + message_id: str | None = None, + ) -> None: + await self._ensure_connected() + + await self._broker.publish( + message, + queue=queue, + persist=settings.RABBIT_PUBLISH_PERSIST if persist is None else persist, + headers=headers, + correlation_id=correlation_id, + message_id=message_id, + ) + + async def publish( + self, + message: Any, + *, + exchange: str, + routing_key: str, + persist: bool | None = None, + headers: Mapping[str, Any] | None = None, + correlation_id: str | None = None, + message_id: str | None = None, + ) -> None: + await self._ensure_connected() + + await self._broker.publish( + message, + exchange=exchange, + routing_key=routing_key, + persist=settings.RABBIT_PUBLISH_PERSIST if persist is None else persist, + headers=headers, + correlation_id=correlation_id, + message_id=message_id, + ) diff --git a/src/infrastructure/security/__init__.py b/src/infrastructure/security/__init__.py new file mode 100644 index 0000000..6dc434f --- /dev/null +++ b/src/infrastructure/security/__init__.py @@ -0,0 +1,3 @@ +from src.infrastructure.security.jwt import JwtService +from src.infrastructure.security.csrf import CsrfService +from src.infrastructure.security.hash import HashService \ No newline at end of file diff --git a/src/infrastructure/security/csrf.py b/src/infrastructure/security/csrf.py new file mode 100644 index 0000000..1b6d3fd --- /dev/null +++ b/src/infrastructure/security/csrf.py @@ -0,0 +1,81 @@ +from __future__ import annotations +import secrets +from typing import Any, Optional, Mapping +from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature +from src.application.contracts import ICsrfService +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.config.settings import settings + + +class CsrfService(ICsrfService): + COOKIE_NAME = 'csrf_token' + HEADER_NAME = 'X-CSRF-Token' + SALT = 'csrf' + TTL_SECONDS = 3600 + + def __init__(self) -> None: + self._serializer = URLSafeTimedSerializer( + secret_key=settings.CSRF_SECRET_KEY, + salt=self.SALT, + ) + + @property + def cookie_name(self) -> str: + return self.COOKIE_NAME + + @property + def header_name(self) -> str: + return self.HEADER_NAME + + @property + def ttl_seconds(self) -> int: + return self.TTL_SECONDS + + def issue(self, subject: Optional[str] = None) -> str: + payload = { + 'sub': subject, + 'nonce': secrets.token_urlsafe(32), + } + return self._serializer.dumps(payload) + + def verify(self, token: str, expected_subject: Optional[str] = None) -> dict[str, Any]: + try: + data = self._serializer.loads(token, max_age=self.TTL_SECONDS) + except SignatureExpired: + raise ApplicationException( + status_code=403, + message='CSRF token expired', + ) + except BadSignature: + raise ApplicationException( + status_code=403, + message='CSRF token invalid', + ) + + if expected_subject is not None and data.get('sub') != expected_subject: + raise ApplicationException( + status_code=403, + message='CSRF token subject mismatch', + ) + + return data + + def extract(self, cookies: Mapping[str, str], headers: Mapping[str, str]) -> tuple[Optional[str], Optional[str]]: + cookie_token = cookies.get(self.COOKIE_NAME) + header_token = headers.get(self.HEADER_NAME) + return cookie_token, header_token + + def verify_pair(self, cookie_token: Optional[str], header_token: Optional[str], expected_subject: Optional[str] = None) -> None: + if not cookie_token or not header_token: + raise ApplicationException( + status_code=403, + message='CSRF token missing', + ) + + if not secrets.compare_digest(cookie_token, header_token): + raise ApplicationException( + status_code=403, + message='CSRF token mismatch', + ) + + self.verify(cookie_token, expected_subject=expected_subject) diff --git a/src/infrastructure/security/hash.py b/src/infrastructure/security/hash.py new file mode 100644 index 0000000..94d92b8 --- /dev/null +++ b/src/infrastructure/security/hash.py @@ -0,0 +1,17 @@ +import bcrypt +from src.application.contracts import IHashService, ILogger + + +class HashService(IHashService): + + def __init__(self, logger: ILogger): + self._logger = logger + + async def hash(self, value: str) -> str: + hashed_value = bcrypt.hashpw(value.encode(), bcrypt.gensalt()) + self._logger.info(f'Hash value {hashed_value.decode()}') + return hashed_value.decode() + + async def verify(self, hashed_value: str, plain_value: str) -> bool: + self._logger.info(f'Hash value {hashed_value[:10]}') + return bcrypt.checkpw(plain_value.encode(), hashed_value.encode()) \ No newline at end of file diff --git a/src/infrastructure/security/jwt.py b/src/infrastructure/security/jwt.py new file mode 100644 index 0000000..4274902 --- /dev/null +++ b/src/infrastructure/security/jwt.py @@ -0,0 +1,109 @@ +from __future__ import annotations +from jose import jwt, ExpiredSignatureError, JWTError +from src.application.contracts import ILogger, IJwtService +from src.application.domain.dto import AccessTokenPayload +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.config.settings import settings +from src.infrastructure.vault import JwtKeyStore + + +class JwtService(IJwtService): + def __init__(self, logger: ILogger, key_store: JwtKeyStore) -> None: + self._logger = logger + self._key_store = key_store + + async def decode_access_token(self, token: str) -> AccessTokenPayload: + payload = await self._decode_and_verify(token) + + if payload.get('type') != 'access': + self._logger.warning(f'Access token invalid type received_type={payload.get('type')}') + raise ApplicationException(status_code=401, message='Invalid token type') + + try: + return AccessTokenPayload( + sub=str(payload['sub']), + type='access', + sid=str(payload['sid']), + iat=int(payload['iat']), + nbf=int(payload['nbf']), + exp=int(payload['exp']), + iss=payload.get('iss'), + aud=payload.get('aud'), + ) + except KeyError as exception: + self._logger.warning(f'Access token missing claim error={str(exception)}') + raise ApplicationException(status_code=401, message=f'Missing token claim: {exception}') + + async def _decode_and_verify(self, token: str) -> dict: + kid: str | None = None + try: + header = jwt.get_unverified_header(token) + + kid = header.get('kid') + if not kid: + self._logger.warning(f'JWT header missing kid header={header}') + raise ApplicationException(status_code=401, message='Missing token header: kid') + + received_alg = header.get('alg') + if received_alg != settings.JWT_ALGORITHM: + self._logger.warning(f'JWT invalid algorithm kid={kid} received_alg={received_alg} expected_alg={settings.JWT_ALGORITHM}') + raise ApplicationException(status_code=401, message='Invalid token algorithm') + + public_pem = await self._key_store.get_public_key_for_kid(str(kid)) + + if not public_pem: + self._logger.info(f'JWT kid miss kid={kid} forcing keystore refresh') + await self._key_store.refresh() + public_pem = await self._key_store.get_public_key_for_kid(str(kid)) + + if not public_pem: + self._logger.warning(f'JWT unknown kid kid={kid}') + raise ApplicationException(status_code=401, message='Unknown token kid') + + options = { + 'verify_signature': True, + 'verify_exp': True, + 'verify_nbf': True, + 'verify_iat': True, + 'verify_aud': bool(settings.JWT_AUDIENCE), + 'verify_iss': bool(settings.JWT_ISSUER), + 'require_exp': True, + 'require_iat': True, + 'require_nbf': True, + 'require_sub': True, + 'leeway': 10, + } + + payload = jwt.decode( + token, + public_pem, + algorithms=[settings.JWT_ALGORITHM], + audience=settings.JWT_AUDIENCE or None, + issuer=settings.JWT_ISSUER or None, + options=options, + ) + + if 'sid' not in payload: + self._logger.warning(f'JWT missing sid claim kid={kid}') + raise ApplicationException(status_code=401, message='Missing token claim: sid') + + if 'type' not in payload: + self._logger.warning(f'JWT missing type claim kid={kid}') + raise ApplicationException(status_code=401, message='Missing token claim: type') + + return payload + + except ExpiredSignatureError as exception: + self._logger.info(f'JWT expired kid={kid} error={str(exception)}') + raise ApplicationException(status_code=401, message='Token expired') + + except ApplicationException: + raise + + except JWTError as exception: + self._logger.warning(f'JWT decode failed kid={kid} error={str(exception)}') + raise ApplicationException(status_code=401, message='Invalid token') + + except Exception as exception: + self._logger.error(f'Unexpected JWT decode error kid={kid} error={str(exception)}') + raise ApplicationException(status_code=500, message='JWT decode failed') \ No newline at end of file diff --git a/src/infrastructure/storage/s3_service.py b/src/infrastructure/storage/s3_service.py new file mode 100644 index 0000000..62780a1 --- /dev/null +++ b/src/infrastructure/storage/s3_service.py @@ -0,0 +1,125 @@ +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 + + @staticmethod + def _url_prefix_variants(prefix: str) -> list[str]: + p = prefix.rstrip('/') + '/' + out = [p] + if p.startswith('https://'): + out.append('http://' + p[8:]) + elif p.startswith('http://'): + out.append('https://' + p[7:]) + return out + + def _public_url_prefixes(self) -> list[str]: + acc: list[str] = [] + pb = self._public_base_url + if pb: + acc.extend(self._url_prefix_variants(pb)) + ep = self._endpoint_url + if ep: + base = f'{ep.rstrip("/")}/{self._bucket}' + acc.extend(self._url_prefix_variants(base)) + if ep and self._use_reg_ru_website_public_host and 's3.regru.cloud' in ep.lower(): + wh = f'https://{self._bucket}.website.regru.cloud' + acc.extend(self._url_prefix_variants(wh)) + if not ep: + if self._region == 'us-east-1': + h = f'https://{self._bucket}.s3.amazonaws.com' + else: + h = f'https://{self._bucket}.s3.{self._region}.amazonaws.com' + acc.extend(self._url_prefix_variants(h)) + seen: set[str] = set() + uniq: list[str] = [] + for x in sorted(acc, key=len, reverse=True): + if x not in seen: + seen.add(x) + uniq.append(x) + return uniq + + def object_key_from_public_url(self, url: str) -> str | None: + u = (url or '').strip() + if not u: + return None + for p in self._public_url_prefixes(): + if u.startswith(p): + k = u[len(p):].split('?', 1)[0].split('#', 1)[0] + return k if k else None + return None + + 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) + + async def delete_object(self, *, key: str) -> None: + 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.delete_object(Bucket=self._bucket, Key=key) diff --git a/src/infrastructure/utils/__init__.py b/src/infrastructure/utils/__init__.py new file mode 100644 index 0000000..2f4158c --- /dev/null +++ b/src/infrastructure/utils/__init__.py @@ -0,0 +1 @@ +from src.infrastructure.utils.instance_id import generate_instance_id \ No newline at end of file diff --git a/src/infrastructure/utils/instance_id.py b/src/infrastructure/utils/instance_id.py new file mode 100644 index 0000000..49a571c --- /dev/null +++ b/src/infrastructure/utils/instance_id.py @@ -0,0 +1,14 @@ +from ulid import ULID + + +def generate_instance_id() -> str: + """ + Generate a process-wide instance id in ULID format. + + ULID is 26 chars (Crockford Base32) and lexicographically sortable by time. + """ + + + return str(ULID()) + + diff --git a/src/infrastructure/vault/__init__.py b/src/infrastructure/vault/__init__.py new file mode 100644 index 0000000..dc30bb1 --- /dev/null +++ b/src/infrastructure/vault/__init__.py @@ -0,0 +1,4 @@ +from src.infrastructure.vault.client import VaultClient +from src.infrastructure.vault.utils import create_hvac_client, read_kv2_secret +from src.infrastructure.vault.keys import JwtKeyStore +from src.infrastructure.vault.scheduler import start_jwt_keys_scheduler \ No newline at end of file diff --git a/src/infrastructure/vault/client.py b/src/infrastructure/vault/client.py new file mode 100644 index 0000000..ddbacc5 --- /dev/null +++ b/src/infrastructure/vault/client.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from typing import Any + +import hvac + + +def _vault_token_renew_failed(exception: Exception) -> bool: + if isinstance(exception, (hvac.exceptions.Forbidden, hvac.exceptions.Unauthorized)): + return True + message = getattr(exception, 'message', None) or str(exception) + if isinstance(message, str): + lower = message.lower() + return 'permission denied' in lower or 'invalid token' in lower or '403' in lower + return False + + +class VaultClient: + + def __init__( + self, + *, + addr: str, + role_id: str, + secret_id: str, + namespace: str | None, + mount_point: str, + ) -> None: + self._mount_point = mount_point + self._addr = addr + self._role_id = role_id + self._secret_id = secret_id + self._namespace = namespace + self._client = hvac.Client(url=addr, namespace=namespace) + self._approle_login() + + def _approle_login(self) -> None: + self._client.auth.approle.login(role_id=self._role_id, secret_id=self._secret_id) + + def _renew_or_login(self) -> None: + try: + self._client.auth.token.renew_self() + except Exception: + self._approle_login() + + def read_secret(self, path: str) -> dict[str, Any]: + for attempt in range(2): + try: + secret = self._client.secrets.kv.v2.read_secret_version( + path=path, + mount_point=self._mount_point, + ) + return dict(secret.get('data', {}).get('data', {})) + except Exception as exc: + if attempt == 0 and _vault_token_renew_failed(exc): + self._renew_or_login() + continue + raise + + def read_secret_optional(self, path: str) -> dict[str, Any]: + if not path: + return {} + try: + return self.read_secret(path) + except (hvac.exceptions.InvalidPath, hvac.exceptions.Forbidden, hvac.exceptions.Unauthorized): + return {} diff --git a/src/infrastructure/vault/keys.py b/src/infrastructure/vault/keys.py new file mode 100644 index 0000000..473f580 --- /dev/null +++ b/src/infrastructure/vault/keys.py @@ -0,0 +1,111 @@ +from __future__ import annotations +import asyncio +from datetime import datetime, timezone +from src.application.domain.dto import JwtPublicKeySet, JwtPublicKey +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.vault.client import VaultClient + + +class JwtKeyStore: + + _instance: 'JwtKeyStore | None' = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__( + self, + *, + vault_addr: str, + vault_role_id: str, + vault_secret_id: str, + vault_namespace: str | None, + mount_point: str, + kid_path: str = 'jwt/kid', + kids_prefix: str = 'jwt/kids', + refresh_ttl_seconds: int = 60, + ): + if getattr(self, '_initialized', False): + return + + self._vault_client = VaultClient( + addr=vault_addr, + role_id=vault_role_id, + secret_id=vault_secret_id, + namespace=vault_namespace, + mount_point=mount_point, + ) + + self._kid_path = kid_path + self._kids_prefix = kids_prefix + + self._refresh_ttl_seconds = refresh_ttl_seconds + + self._lock = asyncio.Lock() + self._keyset: JwtPublicKeySet | None = None + self._last_refresh_at: datetime | None = None + + self._initialized = True + + @classmethod + def get_instance(cls) -> 'JwtKeyStore': + if cls._instance is None: + raise ApplicationException(status_code=500, message='JwtKeyStore not initialized') + return cls._instance + + def _read_keyset_sync(self) -> JwtPublicKeySet: + kids = self._vault_client.read_secret(self._kid_path) + active_kid = kids.get('active') + previous_kid = kids.get('previous') + + if not active_kid: + raise RuntimeError('Vault jwt/kid secret missing "active"') + + active = self._read_public_key_sync(str(active_kid)) + + previous = None + if previous_kid and previous_kid != active_kid: + previous = self._read_public_key_sync(str(previous_kid)) + + return JwtPublicKeySet(active=active, previous=previous) + + def _read_public_key_sync(self, kid: str) -> JwtPublicKey: + data = self._vault_client.read_secret(f'{self._kids_prefix}/{kid}') + pub = data.get('public_key') + if not pub: + raise RuntimeError(f'Vault jwt/kids/{kid} missing public_key') + return JwtPublicKey(kid=kid, public_key_pem=pub) + + async def refresh(self) -> JwtPublicKeySet: + keyset = await asyncio.to_thread(self._read_keyset_sync) + async with self._lock: + self._keyset = keyset + self._last_refresh_at = datetime.now(timezone.utc) + return keyset + + async def get_public_key_for_kid(self, kid: str) -> str | None: + ks = await self._get_or_refresh() + return ks.public_keys_by_kid().get(kid) + + async def last_refresh_at(self) -> datetime | None: + async with self._lock: + return self._last_refresh_at + + async def _get_or_refresh(self) -> JwtPublicKeySet: + async with self._lock: + ks = self._keyset + last = self._last_refresh_at + + if ks is None: + return await self.refresh() + + if last is None: + return await self.refresh() + + age = (datetime.now(timezone.utc) - last).total_seconds() + if age >= self._refresh_ttl_seconds: + return await self.refresh() + + return ks diff --git a/src/infrastructure/vault/scheduler.py b/src/infrastructure/vault/scheduler.py new file mode 100644 index 0000000..007818d --- /dev/null +++ b/src/infrastructure/vault/scheduler.py @@ -0,0 +1,23 @@ +from __future__ import annotations +import logging +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.interval import IntervalTrigger +from src.infrastructure.vault import JwtKeyStore + +logger = logging.getLogger(__name__) + + +def start_jwt_keys_scheduler(store: JwtKeyStore, *, refresh_seconds: int = 3600) -> AsyncIOScheduler: + scheduler = AsyncIOScheduler() + scheduler.add_job( + store.refresh, + trigger=IntervalTrigger(seconds=refresh_seconds), + id="jwt_keys_refresh", + replace_existing=True, + max_instances=1, + coalesce=True, + misfire_grace_time=60, + ) + scheduler.start() + logger.info("JWT keys scheduler started (interval=%s seconds)", refresh_seconds) + return scheduler \ No newline at end of file diff --git a/src/infrastructure/vault/utils.py b/src/infrastructure/vault/utils.py new file mode 100644 index 0000000..27b3ba9 --- /dev/null +++ b/src/infrastructure/vault/utils.py @@ -0,0 +1,17 @@ +from __future__ import annotations +import hvac + + +def create_hvac_client(*, url: str, token: str, timeout: int = 5) -> hvac.Client: + client = hvac.Client(url=url, token=token, timeout=timeout) + if not client.is_authenticated(): + raise RuntimeError("Vault authentication failed. Check VAULT_ADDR / VAULT_TOKEN") + return client + + +def read_kv2_secret(*, client: hvac.Client, mount_point: str, path: str) -> dict: + secret = client.secrets.kv.v2.read_secret_version( + mount_point=mount_point, + path=path, + ) + return secret["data"]["data"] \ No newline at end of file diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..dcee91b --- /dev/null +++ b/src/main.py @@ -0,0 +1,140 @@ +from __future__ import annotations +from contextlib import asynccontextmanager +import secrets +from typing import AsyncGenerator +from fastapi import Depends, FastAPI +from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html +from fastapi.responses import HTMLResponse +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from starlette.middleware.cors import CORSMiddleware +from starlette.exceptions import HTTPException +from fastapi.exceptions import RequestValidationError +from src.application.domain.exceptions import ApplicationException, UnauthorizedException +from src.infrastructure.cache import create_redis_client +from src.infrastructure.vault import JwtKeyStore, start_jwt_keys_scheduler +from src.infrastructure.utils import generate_instance_id +from src.infrastructure.logger import logger +from src.infrastructure.config import settings +from src.presentation.handlers import ( + application_exception_handler, + http_exception_handler, + unhandled_exception_handler, + validation_exception_handler, +) +from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware +from src.presentation.routing import purchase_requests_router + +security = HTTPBasic() + + +async def verify_credentials(credentials: HTTPBasicCredentials = Depends(security)) -> HTTPBasicCredentials: + user_ok = secrets.compare_digest(credentials.username, settings.DOCS_USERNAME) + pass_ok = secrets.compare_digest(credentials.password, settings.DOCS_PASSWORD) + if not (user_ok and pass_ok): + raise UnauthorizedException( + message='Unauthorized', + headers={'WWW-Authenticate': 'Basic'}, + ) + return credentials + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + + instance_id = generate_instance_id() + logger.set_instance_id(instance_id) + logger.info(f'B2B service instance started with id {instance_id}') + + app.state.redis = create_redis_client() + + if not settings.VAULT_ROLE_ID.strip() or not settings.VAULT_SECRET_ID.strip(): + raise RuntimeError('VAULT_ROLE_ID and VAULT_SECRET_ID must be set') + + jwt_store = JwtKeyStore( + vault_addr=settings.VAULT_ADDR, + vault_role_id=settings.VAULT_ROLE_ID, + vault_secret_id=settings.VAULT_SECRET_ID, + vault_namespace=settings.VAULT_NAMESPACE, + mount_point=settings.VAULT_MOUNT_POINT, + kid_path=settings.VAULT_JWT_KID_PATH, + kids_prefix=settings.VAULT_JWT_KIDS_PREFIX, + ) + + await jwt_store.refresh() + + jwt_scheduler = start_jwt_keys_scheduler(jwt_store, refresh_seconds=settings.JWT_KEYS_REFRESH_SECONDS) + + app.state.jwt_key_store = jwt_store + app.state.jwt_keys_scheduler = jwt_scheduler + yield + await app.state.redis.aclose() + logger.info(f'B2B service instance ended with id {instance_id}') + + +app: FastAPI = FastAPI( + redoc_url=None, + docs_url=None, + lifespan=lifespan, + title='B2B Service', + version='1.0.0', + description='Purchase requests API for legal entity client users.', + license_info={ + 'name': 'MIT', + 'url': 'https://opensource.org/licenses/MIT', + }, +) + +app.add_exception_handler(RequestValidationError, validation_exception_handler) +app.add_exception_handler(HTTPException, http_exception_handler) +app.add_exception_handler(ApplicationException, application_exception_handler) +app.add_exception_handler(Exception, unhandled_exception_handler) + +app.include_router(purchase_requests_router) + +app.add_middleware(TraceIDMiddleware, logger=logger) +app.add_middleware( + SecurityHeadersMiddleware, + hsts=True, + hsts_preload=False, + frame_options='DENY', + referrer_policy='strict-origin-when-cross-origin', + content_security_policy="default-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'", +) +app.add_middleware( + CORSMiddleware, + allow_origins=[], + allow_origin_regex='.*', + allow_credentials=True, + allow_methods=['*'], + allow_headers=['*'], +) + + +@app.get('/docs', include_in_schema=False) +async def custom_swagger_ui_html(_credentials: HTTPBasicCredentials = Depends(verify_credentials)) -> HTMLResponse: + '''Custom Swagger documentation, optionally protected with basic authentication.''' + return get_swagger_ui_html( + openapi_url=getattr(app, 'openapi_url', '/openapi.json'), + title=getattr(app, 'title', 'FastAPI') + ' - Swagger UI', + oauth2_redirect_url=getattr(app, 'swagger_ui_oauth2_redirect_url', None), + swagger_js_url='https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js', + swagger_css_url='https://unpkg.com/swagger-ui-dist@5/swagger-ui.css', + ) + + +@app.get('/redoc', include_in_schema=False) +async def custom_redoc_html(_credentials: HTTPBasicCredentials = Depends(verify_credentials)) -> HTMLResponse: + '''Custom ReDoc documentation, optionally protected with basic authentication.''' + return get_redoc_html( + openapi_url=getattr(app, 'openapi_url', '/openapi.json'), + title=getattr(app, 'title', 'FastAPI') + ' - ReDoc', + redoc_js_url='https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js', + ) + + +@app.post('/ping') +async def ping() -> dict[str, str]: + return { + 'message': 'pong', + 'status': 'ok', + } diff --git a/src/presentation/decorators/__init__.py b/src/presentation/decorators/__init__.py new file mode 100644 index 0000000..9fa8d95 --- /dev/null +++ b/src/presentation/decorators/__init__.py @@ -0,0 +1,4 @@ +from src.presentation.decorators.csrf import csrf_protect +from src.presentation.decorators.rate_limit import rate_limit, _email_rl_key as email_rl_key +from src.presentation.decorators.auth import require_access_token +from src.presentation.decorators.cache import cached \ No newline at end of file diff --git a/src/presentation/decorators/auth.py b/src/presentation/decorators/auth.py new file mode 100644 index 0000000..b1ea930 --- /dev/null +++ b/src/presentation/decorators/auth.py @@ -0,0 +1,36 @@ +from fastapi import Depends, Request +from fastapi.security.utils import get_authorization_scheme_param +from src.application.contracts import IJwtService +from src.application.domain.exceptions import UnauthorizedException +from src.application.domain.dto import AccessTokenPayload, AuthContext +from src.presentation.dependencies import get_jwt_service + + +def _extract_access_token(request: Request) -> str | None: + token = request.cookies.get('access_token') + + if token: + return token + + auth = request.headers.get('Authorization') + if auth: + scheme, param = get_authorization_scheme_param(auth) + if scheme.lower() == 'bearer' and param: + return param + + return None + + +async def require_access_token( + request: Request, + jwt_service: IJwtService = Depends(get_jwt_service), +) -> AuthContext: + token = _extract_access_token(request) + if not token: + raise UnauthorizedException(message='Not authenticated') + + payload: AccessTokenPayload = await jwt_service.decode_access_token(token) + if payload.type != 'access': + raise UnauthorizedException(message='Invalid token type') + + return AuthContext(user_id=payload.sub, sid=payload.sid, token=payload) diff --git a/src/presentation/decorators/cache.py b/src/presentation/decorators/cache.py new file mode 100644 index 0000000..a7cbdaf --- /dev/null +++ b/src/presentation/decorators/cache.py @@ -0,0 +1,46 @@ +from __future__ import annotations +import functools +from typing import Any, Awaitable, Callable +from fastapi import Request +from fastapi.responses import ORJSONResponse +from src.infrastructure.cache import KeydbCache +from src.infrastructure.logger import get_logger +from src.presentation.dependencies.cache import get_redis + + +def cached(*, prefix: str) -> Callable: + + def decorator(func: Callable[..., Awaitable[Any]]): + @functools.wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + logger = get_logger() + + request = kwargs.get('request') + if not isinstance(request, Request): + for a in args: + if isinstance(a, Request): + request = a + break + + auth = kwargs.get('auth') + user_id = getattr(auth, 'user_id', None) if auth else None + + if request is None or user_id is None: + return await func(*args, **kwargs) + + cache_key = f'{prefix}:{user_id}' + + try: + redis = get_redis(request) + cache = KeydbCache(redis) + hit = await cache.get_user(user_id) + if hit is not None: + logger.debug(f'Cache hit key={cache_key}') + return ORJSONResponse(status_code=200, content=hit) + except Exception as e: + logger.warning(f'Cache read failed key={cache_key} error={e}') + + return await func(*args, **kwargs) + + return wrapper + return decorator diff --git a/src/presentation/decorators/csrf.py b/src/presentation/decorators/csrf.py new file mode 100644 index 0000000..768e69e --- /dev/null +++ b/src/presentation/decorators/csrf.py @@ -0,0 +1,61 @@ +from __future__ import annotations +import inspect +from functools import wraps +from typing import Callable, Awaitable, Any, Optional, Annotated +from fastapi import Request, Header +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.security import CsrfService + + +def csrf_protect( + expected_subject_getter: Optional[Callable[[Request], Optional[str]]] = None, +): + def decorator(func: Callable[..., Awaitable[Any]]): + sig = inspect.signature(func) + params = list(sig.parameters.values()) + + has_request = any(p.annotation is Request or p.name == 'request' for p in params) + if not has_request: + raise RuntimeError('csrf_protect requires endpoint to accept `request: Request`') + + has_header = any(p.name == 'x_csrf_token' for p in params) + if not has_header: + params.append( + inspect.Parameter( + name='x_csrf_token', + kind=inspect.Parameter.KEYWORD_ONLY, + default=None, + annotation=Annotated[str | None, Header(alias='X-CSRF-Token')], + ) + ) + + @wraps(func) + async def wrapper(*args, **kwargs): + request: Request | None = kwargs.get('request') + if request is None: + for arg in args: + if isinstance(arg, Request): + request = arg + break + + if request is None: + raise ApplicationException( + status_code=500, + message='Request is required for CSRF protection', + ) + + csrf = CsrfService() + + cookie_token, _ = csrf.extract(request.cookies, request.headers) + header_token = kwargs.get('x_csrf_token') + + expected_subject = expected_subject_getter(request) if expected_subject_getter else None + csrf.verify_pair(cookie_token, header_token, expected_subject) + + kwargs.pop('x_csrf_token', None) + return await func(*args, **kwargs) + + wrapper.__signature__ = sig.replace(parameters=params) + return wrapper + + return decorator diff --git a/src/presentation/decorators/rate_limit.py b/src/presentation/decorators/rate_limit.py new file mode 100644 index 0000000..6ff0094 --- /dev/null +++ b/src/presentation/decorators/rate_limit.py @@ -0,0 +1,171 @@ +from __future__ import annotations +import functools +import inspect +import hashlib +from typing import Any, Awaitable, Callable, Literal, Optional, Protocol, runtime_checkable +from fastapi import Request +from redis.asyncio.client import Redis +from src.application.contracts import ILogger +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.logger import get_logger +from src.presentation.dependencies import get_redis + + +def _find_request(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Request: + req = kwargs.get('request') + if isinstance(req, Request): + return req + for a in args: + if isinstance(a, Request): + return a + raise RuntimeError('rate_limit decorator requires fastapi.Request argument') + + +def _client_ip(request: Request) -> str: + xff = request.headers.get('x-forwarded-for') + if xff: + return xff.split(',')[0].strip() + if request.client: + return request.client.host + return 'unknown' + + +_LUA_INCR_EXPIRE_TTL = ''' +local key = KEYS[1] +local window = tonumber(ARGV[1]) + +local current = redis.call('INCR', key) +if current == 1 then + redis.call('EXPIRE', key, window) +end + +local ttl = redis.call('TTL', key) +return { current, ttl } +''' + + +Scope = Literal['ip', 'device', 'user', 'key'] + + +@runtime_checkable +class KeyBuilder1(Protocol): + def __call__(self, request: Request) -> str: ... + + +@runtime_checkable +class KeyBuilder3(Protocol): + def __call__(self, request: Request, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str: ... + + +KeyBuilder = KeyBuilder1 | KeyBuilder3 + + +def _call_key_builder(builder: KeyBuilder, request: Request, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str: + try: + sig = inspect.signature(builder) + if len(sig.parameters) >= 3: + return builder(request, args, kwargs) + return builder(request) + except Exception as e: + try: + return builder(request, args, kwargs) + except Exception: + raise e + +def _email_rl_key(request: Request, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str: + + body = kwargs.get('body') + if body is None and args: + for a in args: + if hasattr(a, 'email'): + body = a + break + + email = (getattr(body, 'email', '') or '').strip().lower() + if not email: + email = _client_ip(request) + + digest = hashlib.sha256(email.encode('utf-8')).hexdigest()[:24] + return f'email:{digest}' + +def rate_limit( + *, + limit: int, + window_seconds: int, + scope: Scope = 'ip', + key_prefix: str = 'rl', + key_builder: Optional[KeyBuilder] = None, + fail_open: bool = True, +) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]: + + if limit <= 0: + raise ValueError('rate_limit: limit must be > 0') + if window_seconds <= 0: + raise ValueError('rate_limit: window_seconds must be > 0') + if scope == 'key' and not key_builder: + raise ValueError('rate_limit: scope="key" requires key_builder') + + def decorator(func: Callable[..., Awaitable[Any]]): + @functools.wraps(func) + async def wrapper(*args: Any, **kwargs: Any): + request = _find_request(args, kwargs) + logger: ILogger = get_logger() + + if scope == 'ip': + ident = _client_ip(request) + elif scope == 'device': + ident = request.cookies.get('device_id') or _client_ip(request) + elif scope == 'user': + user = getattr(request.state, 'user', None) + user_id = getattr(user, 'id', None) if user else None + ident = str(user_id) if user_id else _client_ip(request) + else: + try: + ident = _call_key_builder(key_builder, request, args, kwargs) # type: ignore[arg-type] + except Exception as e: + logger.error(f'RateLimit key_builder failed error={str(e)}') + raise ApplicationException(500, 'Rate limiter key_builder failed') + + route = request.url.path + method = request.method + redis_key = f'{key_prefix}:{scope}:{method}:{route}:{ident}' + + logger.debug(f'RateLimit check key={redis_key} limit={limit} window={window_seconds}') + + try: + redis: Redis = get_redis(request) + + result = await redis.eval( + _LUA_INCR_EXPIRE_TTL, + 1, + redis_key, + str(window_seconds), + ) + + count = int(result[0]) + ttl_raw = int(result[1]) if result and len(result) > 1 else window_seconds + ttl = window_seconds if ttl_raw < 0 else ttl_raw + + except Exception as e: + logger.error(f'RateLimit redis failure key={redis_key} error={str(e)}') + + if fail_open: + logger.warning(f'RateLimit fail-open activated key={redis_key}') + return await func(*args, **kwargs) + + raise ApplicationException(503, 'Rate limiter unavailable') + + if count > limit: + retry_after = max(ttl, 0) + logger.warning(f'RateLimit exceeded key={redis_key} count={count} limit={limit} retry_after={retry_after}') + raise ApplicationException( + status_code=429, + message='Too Many Requests', + headers={'Retry-After': str(retry_after)}, + ) + + logger.debug(f'RateLimit passed key={redis_key} count={count}') + return await func(*args, **kwargs) + + return wrapper + return decorator \ No newline at end of file diff --git a/src/presentation/dependencies/__init__.py b/src/presentation/dependencies/__init__.py new file mode 100644 index 0000000..425344a --- /dev/null +++ b/src/presentation/dependencies/__init__.py @@ -0,0 +1,7 @@ +from src.presentation.dependencies.commands import ( + get_create_purchase_request_command, + get_get_purchase_request_command, + get_list_purchase_requests_command, +) +from src.presentation.dependencies.security import get_jwt_service +from src.presentation.dependencies.cache import get_redis, get_cache diff --git a/src/presentation/dependencies/cache.py b/src/presentation/dependencies/cache.py new file mode 100644 index 0000000..fb4fc7a --- /dev/null +++ b/src/presentation/dependencies/cache.py @@ -0,0 +1,12 @@ +from fastapi import Depends, Request +from redis.asyncio.client import Redis +from src.application.contracts import ICache +from src.infrastructure.cache import KeydbCache + + +def get_redis(request: Request) -> Redis: + return request.app.state.redis + + +def get_cache(redis_client: Redis = Depends(get_redis)) -> ICache: + return KeydbCache(redis_client) diff --git a/src/presentation/dependencies/commands.py b/src/presentation/dependencies/commands.py new file mode 100644 index 0000000..116a8db --- /dev/null +++ b/src/presentation/dependencies/commands.py @@ -0,0 +1,31 @@ +from fastapi import Depends +from src.application.abstractions import IUnitOfWork +from src.application.commands import ( + CreatePurchaseRequestCommand, + GetPurchaseRequestCommand, + ListPurchaseRequestsCommand, +) +from src.application.contracts import ILogger +from src.presentation.dependencies.logger import get_logger +from src.presentation.dependencies.unit_of_work import get_unit_of_work + + +def get_create_purchase_request_command( + logger: ILogger = Depends(get_logger), + unit_of_work: IUnitOfWork = Depends(get_unit_of_work), +) -> CreatePurchaseRequestCommand: + return CreatePurchaseRequestCommand(unit_of_work=unit_of_work, logger=logger) + + +def get_list_purchase_requests_command( + logger: ILogger = Depends(get_logger), + unit_of_work: IUnitOfWork = Depends(get_unit_of_work), +) -> ListPurchaseRequestsCommand: + return ListPurchaseRequestsCommand(unit_of_work=unit_of_work, logger=logger) + + +def get_get_purchase_request_command( + logger: ILogger = Depends(get_logger), + unit_of_work: IUnitOfWork = Depends(get_unit_of_work), +) -> GetPurchaseRequestCommand: + return GetPurchaseRequestCommand(unit_of_work=unit_of_work, logger=logger) diff --git a/src/presentation/dependencies/logger.py b/src/presentation/dependencies/logger.py new file mode 100644 index 0000000..3c95f84 --- /dev/null +++ b/src/presentation/dependencies/logger.py @@ -0,0 +1,7 @@ +from functools import lru_cache +from src.application.contracts import ILogger +from src.infrastructure.logger import logger + +@lru_cache +def get_logger() -> ILogger: + return logger \ No newline at end of file diff --git a/src/presentation/dependencies/queue_messanger.py b/src/presentation/dependencies/queue_messanger.py new file mode 100644 index 0000000..87a2d3a --- /dev/null +++ b/src/presentation/dependencies/queue_messanger.py @@ -0,0 +1,8 @@ +from functools import lru_cache +from src.application.contracts import IQueueMessanger +from src.infrastructure.messanger import RabbitClient + + +@lru_cache(maxsize=1) +def get_rabbit() -> IQueueMessanger: + return RabbitClient() diff --git a/src/presentation/dependencies/s3_storage.py b/src/presentation/dependencies/s3_storage.py new file mode 100644 index 0000000..5e62c1c --- /dev/null +++ b/src/presentation/dependencies/s3_storage.py @@ -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 diff --git a/src/presentation/dependencies/security.py b/src/presentation/dependencies/security.py new file mode 100644 index 0000000..597faf0 --- /dev/null +++ b/src/presentation/dependencies/security.py @@ -0,0 +1,25 @@ +from functools import lru_cache +from fastapi import Depends +from src.application.contracts import IJwtService, ILogger, IHashService +from src.infrastructure.security import JwtService, HashService +from src.infrastructure.vault import JwtKeyStore +from src.presentation.dependencies.logger import get_logger + + +@lru_cache(maxsize=1) +def _hash_service(logger: ILogger) -> IHashService: + return HashService(logger=logger) + + +def get_hash_service(logger: ILogger = Depends(get_logger)) -> IHashService: + return _hash_service(logger) + + +@lru_cache(maxsize=1) +def _jwt_service(logger: ILogger) -> IJwtService: + key_store = JwtKeyStore.get_instance() + return JwtService(logger=logger, key_store=key_store) + + +def get_jwt_service(logger: ILogger = Depends(get_logger)) -> IJwtService: + return _jwt_service(logger) \ No newline at end of file diff --git a/src/presentation/dependencies/unit_of_work.py b/src/presentation/dependencies/unit_of_work.py new file mode 100644 index 0000000..5629bd4 --- /dev/null +++ b/src/presentation/dependencies/unit_of_work.py @@ -0,0 +1,10 @@ +from fastapi import Depends +from src.application.abstractions import IUnitOfWork +from src.application.contracts import ILogger +from src.infrastructure.database import UnitOfWork +from src.infrastructure.database.context import async_session_maker +from src.infrastructure.logger import get_logger + + +def get_unit_of_work(logger: ILogger = Depends(get_logger)) -> IUnitOfWork: + return UnitOfWork(session_factory=async_session_maker, logger=logger) \ No newline at end of file diff --git a/src/presentation/handlers/__init__.py b/src/presentation/handlers/__init__.py new file mode 100644 index 0000000..e9ca9c5 --- /dev/null +++ b/src/presentation/handlers/__init__.py @@ -0,0 +1,4 @@ +from src.presentation.handlers.unhandled_handler import unhandled_exception_handler +from src.presentation.handlers.application_handler import application_exception_handler +from src.presentation.handlers.http_exception_handler import http_exception_handler +from src.presentation.handlers.validation_handler import validation_exception_handler diff --git a/src/presentation/handlers/application_handler.py b/src/presentation/handlers/application_handler.py new file mode 100644 index 0000000..aa68716 --- /dev/null +++ b/src/presentation/handlers/application_handler.py @@ -0,0 +1,17 @@ +from fastapi.responses import ORJSONResponse +from fastapi import Request +from src.application.domain.exceptions import ApplicationException + + +async def application_exception_handler(_request: Request, exc: ApplicationException) -> ORJSONResponse: + detail = exc.message + if 500 <= exc.status_code: + detail = "Internal Server Error" + + return ORJSONResponse( + status_code=exc.status_code, + content={"detail": detail}, + headers=dict(exc.headers) if exc.headers else None, + ) + + diff --git a/src/presentation/handlers/http_exception_handler.py b/src/presentation/handlers/http_exception_handler.py new file mode 100644 index 0000000..84510d2 --- /dev/null +++ b/src/presentation/handlers/http_exception_handler.py @@ -0,0 +1,11 @@ +from fastapi import Request +from fastapi.responses import ORJSONResponse +from starlette.exceptions import HTTPException + + +async def http_exception_handler(_request: Request, exc: HTTPException) -> ORJSONResponse: + return ORJSONResponse( + status_code=exc.status_code, + content={'detail': exc.detail}, + headers=dict(exc.headers) if exc.headers else None, + ) diff --git a/src/presentation/handlers/unhandled_handler.py b/src/presentation/handlers/unhandled_handler.py new file mode 100644 index 0000000..c6f1d52 --- /dev/null +++ b/src/presentation/handlers/unhandled_handler.py @@ -0,0 +1,12 @@ +from fastapi.responses import ORJSONResponse +from fastapi import Request +from starlette import status +from src.infrastructure.logger import logger + + +async def unhandled_exception_handler(_request: Request, exc: Exception) -> ORJSONResponse: + logger.exception(f'Unhandled exception: {type(exc).__name__}') + return ORJSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={'detail': 'Internal Server Error'}, + ) \ No newline at end of file diff --git a/src/presentation/handlers/validation_handler.py b/src/presentation/handlers/validation_handler.py new file mode 100644 index 0000000..155ac5d --- /dev/null +++ b/src/presentation/handlers/validation_handler.py @@ -0,0 +1,10 @@ +from fastapi import Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import ORJSONResponse + + +async def validation_exception_handler(_request: Request, exc: RequestValidationError) -> ORJSONResponse: + return ORJSONResponse( + status_code=422, + content={'detail': exc.errors()}, + ) diff --git a/src/presentation/middleware/__init__.py b/src/presentation/middleware/__init__.py new file mode 100644 index 0000000..50faccd --- /dev/null +++ b/src/presentation/middleware/__init__.py @@ -0,0 +1,2 @@ +from src.presentation.middleware.trace_id import TraceIDMiddleware +from src.presentation.middleware.security_headers import SecurityHeadersMiddleware \ No newline at end of file diff --git a/src/presentation/middleware/security_headers.py b/src/presentation/middleware/security_headers.py new file mode 100644 index 0000000..7cd0387 --- /dev/null +++ b/src/presentation/middleware/security_headers.py @@ -0,0 +1,51 @@ +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + def __init__( + self, + app, + *, + hsts: bool = True, + hsts_max_age: int = 31536000, # 1 год + hsts_include_subdomains: bool = True, + hsts_preload: bool = False, + frame_options: str = 'DENY', # или 'SAMEORIGIN' + referrer_policy: str = 'strict-origin-when-cross-origin', + content_security_policy: str | None = None, + ): + super().__init__(app) + self.hsts = hsts + self.hsts_max_age = hsts_max_age + self.hsts_include_subdomains = hsts_include_subdomains + self.hsts_preload = hsts_preload + self.frame_options = frame_options + self.referrer_policy = referrer_policy + self.csp = content_security_policy + + async def dispatch(self, request: Request, call_next) -> Response: + response: Response = await call_next(request) + + if request.url.path in ('/docs', '/redoc', '/openapi.json'): + return response + + if self.hsts and request.url.scheme == 'https': + hsts = f'max-age={self.hsts_max_age}' + if self.hsts_include_subdomains: + hsts += '; includeSubDomains' + if self.hsts_preload: + hsts += '; preload' + response.headers['Strict-Transport-Security'] = hsts + + response.headers['X-Content-Type-Options'] = 'nosniff' + + response.headers['X-Frame-Options'] = self.frame_options + + response.headers['Referrer-Policy'] = self.referrer_policy + + if self.csp: + response.headers['Content-Security-Policy'] = self.csp + + return response diff --git a/src/presentation/middleware/trace_id.py b/src/presentation/middleware/trace_id.py new file mode 100644 index 0000000..8abd8e3 --- /dev/null +++ b/src/presentation/middleware/trace_id.py @@ -0,0 +1,135 @@ +from __future__ import annotations +from typing import Optional +from contextvars import Token +from starlette.requests import Request +from starlette.types import ASGIApp, Message, Receive, Scope, Send +from ulid import ULID +from src.application.contracts import ILogger +from src.infrastructure.config import settings +from src.infrastructure.context_vars import trace_id_var + + +class TraceIDMiddleware: + def __init__( + self, + app: ASGIApp, + logger: ILogger, + response_header_name: str = "X-Trace-ID", + attach_response_header: bool = True, + ) -> None: + self.app = app + self.logger = logger + self.response_header_name = response_header_name + self.attach_response_header = attach_response_header + + def _is_excluded(self, path: str) -> bool: + return any(path == p or path.startswith(p.rstrip("/") + "/") for p in settings.EXCLUDED_PATHS) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = Request(scope) + + if self._is_excluded(request.url.path): + await self.app(scope, receive, send) + return + + trace_id = request.headers.get("X-Trace-ID") or request.headers.get("X-Request-ID") + if not trace_id: + trace_id = str(ULID()) + + request.state.trace_id = trace_id + + token: Token = trace_id_var.set(trace_id) + + self.logger.debug(f"Request started: {request.method} {request.url} - TraceID: {trace_id}") + + status_code_holder: dict[str, Optional[int]] = {"status": None} + + async def send_wrapper(message: Message) -> None: + if message["type"] == "http.response.start": + status_code_holder["status"] = int(message["status"]) + + if self.attach_response_header: + headers = list(message.get("headers", [])) + headers.append((self.response_header_name.lower().encode(), trace_id.encode())) + message["headers"] = headers + await send(message) + + try: + await self.app(scope, receive, send_wrapper) + finally: + status = status_code_holder["status"] + status_part = f"{status}" if status is not None else "unknown" + self.logger.debug( + f"Request finished: {request.method} {request.url} - TraceID: {trace_id} - Status: {status_part}" + ) + trace_id_var.reset(token) + + +# from __future__ import annotations +# from typing import Optional +# from starlette.requests import Request +# from starlette.types import ASGIApp, Message, Receive, Scope, Send +# from ulid import ULID +# from src.application.contracts import ILogger +# from src.infrastructure.config.settings import settings +# +# +# class TraceIDMiddleware: +# def __init__( +# self, +# app: ASGIApp, +# logger: ILogger, +# response_header_name: str = 'X-Trace-ID', +# attach_response_header: bool = True, +# ) -> None: +# self.app = app +# self.logger = logger +# self.response_header_name = response_header_name +# self.attach_response_header = attach_response_header +# +# def _is_excluded(self, path: str) -> bool: +# return any(path == p or path.startswith(p.rstrip('/') + '/') for p in settings.EXCLUDED_PATHS) +# +# async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: +# if scope['type'] != 'http': +# await self.app(scope, receive, send) +# return +# +# request = Request(scope) +# +# if self._is_excluded(request.url.path): +# await self.app(scope, receive, send) +# return +# +# trace_id = request.headers.get('X-Trace-ID') or request.headers.get('X-Request-ID') +# if not trace_id: +# trace_id = str(ULID()) +# +# request.state.trace_id = trace_id +# self.logger.set_trace_id(trace_id) +# +# self.logger.debug(f'Request started: {request.method} {request.url} - TraceID: {trace_id}') +# +# status_code_holder: dict[str, Optional[int]] = {'status': None} +# +# async def send_wrapper(message: Message) -> None: +# if message['type'] == 'http.response.start': +# status_code_holder['status'] = int(message['status']) +# +# if self.attach_response_header: +# headers = list(message.get('headers', [])) +# headers.append((self.response_header_name.lower().encode(), trace_id.encode())) +# message['headers'] = headers +# await send(message) +# +# try: +# await self.app(scope, receive, send_wrapper) +# finally: +# status = status_code_holder['status'] +# status_part = f'{status}' if status is not None else 'unknown' +# self.logger.debug(f'Request finished: {request.method} {request.url} - TraceID: {trace_id} - Status: {status_part}') +# self.logger.clear_trace_id() diff --git a/src/presentation/routing/__init__.py b/src/presentation/routing/__init__.py new file mode 100644 index 0000000..f6b1b15 --- /dev/null +++ b/src/presentation/routing/__init__.py @@ -0,0 +1,3 @@ +from src.presentation.routing.purchase_requests import purchase_requests_router + +__all__ = ['purchase_requests_router'] diff --git a/src/presentation/routing/account.py b/src/presentation/routing/account.py new file mode 100644 index 0000000..65fd7d2 --- /dev/null +++ b/src/presentation/routing/account.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter, Depends, Request +from fastapi.responses import ORJSONResponse +from starlette import status +from src.application.commands.get_me import GetMeCommand +from src.application.contracts import ILogger +from src.application.domain.dto import AuthContext +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, + response_model=MeUserPublicResponse, + summary='Текущий пользователь', + description=( + 'Возвращает профиль, в том числе поле avatar_link — публичный HTTPS URL ' + 'текущего аватара (WebP в объектном хранилище), либо null, если аватар не задан. ' + 'Изображение по этому URL отдаёт не Users API, а хранилище; отдельного эндпоинта загрузки байтов аватара нет. ' + 'Загрузить или сменить аватар можно через PATCH /me/settings/avatar. ' + 'Защита 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 me_user_public(user) diff --git a/src/presentation/routing/account_settings.py b/src/presentation/routing/account_settings.py new file mode 100644 index 0000000..30abd28 --- /dev/null +++ b/src/presentation/routing/account_settings.py @@ -0,0 +1,384 @@ +from fastapi import APIRouter, Depends, Request +from fastapi.responses import ORJSONResponse +from starlette import status +from src.application.commands import ( + SetPhoneCommand, + SetAvatarCommand, + DeleteAvatarCommand, + ChangePasswordStartCommand, + ChangePasswordCompleteCommand, + ForgotPasswordStartCommand, + ForgotPasswordCompleteCommand, +) +from src.application.domain.dto import AuthContext +from src.presentation.decorators import require_access_token, csrf_protect, rate_limit, email_rl_key +from src.presentation.dependencies import ( + get_delete_avatar_command, + get_set_avatar_command, + get_set_phone_command, + get_change_password_start_command, + get_change_password_complete_command, + get_forgot_password_start_command, + get_forgot_password_complete_command, +) +from src.presentation.schemas import ( + SetAvatarRequest, + SetPhoneRequest, + ChangePasswordConfirmRequest, + ForgotPasswordStartRequest, + ForgotPasswordCompleteRequest, +) +from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload +from src.presentation.schemas.me_public import MeUserPublicResponse, 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, + }, +} + + +_PASSWORD_ERROR_RESPONSES: dict[int, dict[str, object]] = { + status.HTTP_400_BAD_REQUEST: { + 'description': 'Неверный или просроченный код, пароли не совпадают или совпадают с текущим.', + 'model': ApiErrorPayload, + }, + status.HTTP_401_UNAUTHORIZED: { + 'description': 'Не передан или неверен access token.', + 'model': ApiErrorPayload, + }, + status.HTTP_404_NOT_FOUND: { + 'description': 'Учётная запись не найдена или у пользователя нет email.', + 'model': ApiErrorPayload, + }, + status.HTTP_422_UNPROCESSABLE_ENTITY: { + 'description': 'Тело запроса не соответствует схеме (код, длина пароля).', + 'model': ApiValidationErrorsPayload, + }, + status.HTTP_429_TOO_MANY_REQUESTS: { + 'description': 'Код уже отправлен или слишком частые запросы.', + 'model': ApiErrorPayload, + }, + status.HTTP_500_INTERNAL_SERVER_ERROR: { + 'description': 'Внутренняя ошибка сервера.', + 'model': ApiErrorPayload, + }, + status.HTTP_503_SERVICE_UNAVAILABLE: { + 'description': 'Временная ошибка отправки кода или сохранения в кеш.', + 'model': ApiErrorPayload, + }, +} + + +_FORGOT_PASSWORD_ERROR_RESPONSES: dict[int, dict[str, object]] = { + status.HTTP_400_BAD_REQUEST: { + 'description': 'Неверный или просроченный код, пароли не совпадают.', + 'model': ApiErrorPayload, + }, + status.HTTP_422_UNPROCESSABLE_ENTITY: { + 'description': 'Тело запроса не соответствует схеме.', + 'model': ApiValidationErrorsPayload, + }, + status.HTTP_429_TOO_MANY_REQUESTS: { + 'description': 'Код уже отправлен или слишком частые запросы.', + 'model': ApiErrorPayload, + }, + status.HTTP_500_INTERNAL_SERVER_ERROR: { + 'description': 'Внутренняя ошибка сервера.', + 'model': ApiErrorPayload, + }, + status.HTTP_503_SERVICE_UNAVAILABLE: { + 'description': 'Временная ошибка отправки кода или сохранения в кеш.', + 'model': ApiErrorPayload, + }, +} + + +_DELETE_AVATAR_ERROR_RESPONSES: dict[int, dict[str, object]] = { + status.HTTP_401_UNAUTHORIZED: { + 'description': 'Не передан или неверен access token.', + 'model': ApiErrorPayload, + }, + status.HTTP_404_NOT_FOUND: { + 'description': 'Учётная запись не найдена.', + 'model': ApiErrorPayload, + }, + 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) +@csrf_protect() +async def set_phone( + request: Request, + body: SetPhoneRequest, + auth: AuthContext = Depends(require_access_token), + command: SetPhoneCommand = Depends(get_set_phone_command), +): + user = await command(user_id=auth.user_id, phone=body.phone) + return ORJSONResponse(status_code=status.HTTP_200_OK, content={'phone': user.phone}) + + +@account_settings_router.patch( + path='/avatar', + response_class=ORJSONResponse, + status_code=status.HTTP_200_OK, + response_model=SetAvatarPublicResponse, + summary='Обновить аватар', + description=( + 'Принимает фото в Base64, сохраняет как WebP в объектном хранилище и записывает публичный URL в профиль. ' + 'После успешной записи удаляется предыдущий объект в S3 (если ссылку удаётся сопоставить с ключом).' + ), + response_description=( + 'Профиль пользователя в том же формате, что и GET /me, плюс размер сохранённого WebP.' + ), + responses=_SET_AVATAR_ERROR_RESPONSES, +) +@csrf_protect() +async def set_avatar( + request: Request, + body: SetAvatarRequest, + auth: AuthContext = Depends(require_access_token), + 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.delete( + path='/avatar', + response_class=ORJSONResponse, + status_code=status.HTTP_200_OK, + response_model=MeUserPublicResponse, + summary='Удалить аватар', + description=( + 'Удаляет файл в объектном хранилище при известном URL и обнуляет avatar_link в профиле.' + ), + responses=_DELETE_AVATAR_ERROR_RESPONSES, +) +@csrf_protect() +async def delete_avatar( + request: Request, + auth: AuthContext = Depends(require_access_token), + command: DeleteAvatarCommand = Depends(get_delete_avatar_command), +) -> MeUserPublicResponse: + user = await command(user_id=auth.user_id) + return me_user_public(user) + + +@account_settings_router.post( + path='/password/start', + response_class=ORJSONResponse, + status_code=status.HTTP_200_OK, + summary='Запросить код для смены пароля', + description='Отправляет шестизначный код на email текущего пользователя. Повторный запрос возможен после истечения TTL.', + responses=_PASSWORD_ERROR_RESPONSES, +) +@csrf_protect() +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 ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result}) + + +@account_settings_router.post( + path='/password/complete', + response_class=ORJSONResponse, + status_code=status.HTTP_200_OK, + summary='Подтвердить смену пароля', + description=( + 'Принимает код из письма, новый пароль и его подтверждение. ' + 'Новый пароль должен отличаться от текущего и соответствовать политике сложности (минимум 12 символов).' + ), + responses=_PASSWORD_ERROR_RESPONSES, +) +@csrf_protect() +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 ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result}) + + +@account_settings_router.post( + path='/password/forgot/start', + response_class=ORJSONResponse, + status_code=status.HTTP_200_OK, + summary='Запросить код для восстановления пароля', + description=( + 'Принимает email. Если учётная запись существует, отправляет шестизначный код. ' + 'Ответ всегда успешный при валидном email (без раскрытия наличия аккаунта).' + ), + responses=_FORGOT_PASSWORD_ERROR_RESPONSES, +) +@rate_limit(limit=5, window_seconds=300, scope='key', key_prefix='rl', key_builder=email_rl_key) +async def forgot_password_start( + request: Request, + body: ForgotPasswordStartRequest, + command: ForgotPasswordStartCommand = Depends(get_forgot_password_start_command), +): + result = await command(email=body.email) + return ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result}) + + +@account_settings_router.post( + path='/password/forgot/complete', + response_class=ORJSONResponse, + status_code=status.HTTP_200_OK, + summary='Установить новый пароль по коду из письма', + description=( + 'Принимает email, код из письма, новый пароль и подтверждение. ' + 'Пароль: минимум 12 символов, строчная и заглавная буква, цифра, спецсимвол, без пробелов.' + ), + responses=_FORGOT_PASSWORD_ERROR_RESPONSES, +) +@rate_limit(limit=10, window_seconds=300, scope='key', key_prefix='rl', key_builder=email_rl_key) +async def forgot_password_complete( + request: Request, + body: ForgotPasswordCompleteRequest, + command: ForgotPasswordCompleteCommand = Depends(get_forgot_password_complete_command), +): + result = await command( + email=body.email, + code=body.code, + new_password=body.new_password, + confirm_password=body.confirm_password, + ) + return ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result}) + + +# +# @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='/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, +# }, +# ) diff --git a/src/presentation/routing/deals.py b/src/presentation/routing/deals.py new file mode 100644 index 0000000..1a77b6e --- /dev/null +++ b/src/presentation/routing/deals.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +deals_router = APIRouter(prefix='/deals') + +@deals_router.get(path='') +async def deals(): + pass diff --git a/src/presentation/routing/devices.py b/src/presentation/routing/devices.py new file mode 100644 index 0000000..b2aa203 --- /dev/null +++ b/src/presentation/routing/devices.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter, Request +from fastapi.responses import ORJSONResponse +from starlette import status + +devices_router = APIRouter(prefix='/devices') + +@devices_router.get(path='/', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) +async def devices( + request: Request, +): + pass diff --git a/src/presentation/routing/purchase_requests.py b/src/presentation/routing/purchase_requests.py new file mode 100644 index 0000000..2c2f1a2 --- /dev/null +++ b/src/presentation/routing/purchase_requests.py @@ -0,0 +1,68 @@ +from fastapi import APIRouter, Depends, Query, status + +from src.application.commands import ( + CreatePurchaseRequestCommand, + GetPurchaseRequestCommand, + ListPurchaseRequestsCommand, +) +from src.application.domain.dto import AuthContext +from src.presentation.decorators.auth import require_access_token +from src.presentation.dependencies.commands import ( + get_create_purchase_request_command, + get_get_purchase_request_command, + get_list_purchase_requests_command, +) +from src.presentation.schemas.purchase_request import ( + CreatePurchaseRequestBody, + PurchaseRequestListResponse, + PurchaseRequestResponse, +) +from src.presentation.serializers.purchase_request import purchase_request_to_response + +purchase_requests_router = APIRouter(prefix='/purchase-requests', tags=['purchase-requests']) + + +@purchase_requests_router.post('', response_model=PurchaseRequestResponse, status_code=status.HTTP_201_CREATED) +async def create_purchase_request( + body: CreatePurchaseRequestBody, + auth: AuthContext = Depends(require_access_token), + command: CreatePurchaseRequestCommand = Depends(get_create_purchase_request_command), +): + item = await command( + auth.user_id, + usdt_amount=body.usdt_amount, + comment=body.comment, + target_wallet_chain=body.target_wallet_chain, + target_wallet_address=body.target_wallet_address, + ) + return purchase_request_to_response(item) + + +@purchase_requests_router.get('', response_model=PurchaseRequestListResponse) +async def list_purchase_requests( + status_filter: str | None = Query(default=None, alias='status'), + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + auth: AuthContext = Depends(require_access_token), + command: ListPurchaseRequestsCommand = Depends(get_list_purchase_requests_command), +): + items, total = await command( + auth.user_id, + status=status_filter, + limit=limit, + offset=offset, + ) + return PurchaseRequestListResponse( + items=[purchase_request_to_response(x) for x in items], + total=total, + ) + + +@purchase_requests_router.get('/{request_id}', response_model=PurchaseRequestResponse) +async def get_purchase_request( + request_id: str, + auth: AuthContext = Depends(require_access_token), + command: GetPurchaseRequestCommand = Depends(get_get_purchase_request_command), +): + item = await command(auth.user_id, request_id) + return purchase_request_to_response(item) diff --git a/src/presentation/schemas/__init__.py b/src/presentation/schemas/__init__.py new file mode 100644 index 0000000..3428adf --- /dev/null +++ b/src/presentation/schemas/__init__.py @@ -0,0 +1,10 @@ +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 +from src.presentation.schemas.password import ( + ChangePasswordConfirmRequest, + ForgotPasswordStartRequest, + ForgotPasswordCompleteRequest, +) +from src.presentation.schemas.email import ChangeEmailConfirmOldRequest, ChangeEmailCompleteRequest \ No newline at end of file diff --git a/src/presentation/schemas/api_errors.py b/src/presentation/schemas/api_errors.py new file mode 100644 index 0000000..c8f6c16 --- /dev/null +++ b/src/presentation/schemas/api_errors.py @@ -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='Список ошибок валидации тела или параметров запроса' + ) diff --git a/src/presentation/schemas/avatar.py b/src/presentation/schemas/avatar.py new file mode 100644 index 0000000..d1a95ad --- /dev/null +++ b/src/presentation/schemas/avatar.py @@ -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} diff --git a/src/presentation/schemas/bank.py b/src/presentation/schemas/bank.py new file mode 100644 index 0000000..9f2a0f3 --- /dev/null +++ b/src/presentation/schemas/bank.py @@ -0,0 +1,116 @@ +import re +from typing import Self +from pydantic import BaseModel, field_validator, model_validator + + +class BankUpdateRequest(BaseModel): + passport_data: str | None = None + inn: str | None = None + erc20: str | None = None + + @model_validator(mode='after') + def at_least_one(self) -> Self: + if not any([self.passport_data, self.inn, self.erc20]): + raise ValueError('At least one field is required') + return self + + @field_validator('passport_data', 'inn', 'erc20') + @classmethod + def strip_optional(cls, v: str | None) -> str | None: + if v is None: + return None + s = v.strip() + return s or None + + @field_validator('inn') + @classmethod + def validate_inn(cls, v: str | None) -> str | None: + if v is None: + return None + v = v.strip() + if not re.match(r'^\d{10}(\d{2})?$', v): + raise ValueError('INN must be 10 or 12 digits') + if len(v) > 12: + raise ValueError('INN is too long') + return v + + @field_validator('erc20') + @classmethod + def validate_erc20(cls, v: str | None) -> str | None: + if v is None: + return None + v = v.strip() + if not re.match(r'^0x[a-fA-F0-9]{40}$', v): + raise ValueError('ERC20 address must be 0x followed by 40 hex characters') + return v + + @field_validator('passport_data') + @classmethod + def validate_passport_data(cls, v: str | None) -> str | None: + if v is None: + return None + v = v.strip() + if len(v) > 255: + raise ValueError('Passport data is too long') + return v + + +class BankConfirmRequest(BaseModel): + code: str + passport_data: str | None = None + inn: str | None = None + erc20: str | None = None + + @model_validator(mode='after') + def at_least_one_field(self) -> Self: + if not any([self.passport_data, self.inn, self.erc20]): + raise ValueError('At least one field is required') + return self + + @field_validator('code') + @classmethod + def validate_code(cls, v: str) -> str: + v = v.strip() + if not re.match(r'^\d{6}$', v): + raise ValueError('Code must be exactly 6 digits') + return v + + @field_validator('passport_data', 'inn', 'erc20') + @classmethod + def strip_optional(cls, v: str | None) -> str | None: + if v is None: + return None + s = v.strip() + return s or None + + @field_validator('inn') + @classmethod + def validate_inn(cls, v: str | None) -> str | None: + if v is None: + return None + v = v.strip() + if not re.match(r'^\d{10}(\d{2})?$', v): + raise ValueError('INN must be 10 or 12 digits') + if len(v) > 12: + raise ValueError('INN is too long') + return v + + @field_validator('erc20') + @classmethod + def validate_erc20(cls, v: str | None) -> str | None: + if v is None: + return None + v = v.strip() + if not re.match(r'^0x[a-fA-F0-9]{40}$', v): + raise ValueError('ERC20 address must be 0x followed by 40 hex characters') + return v + + @field_validator('passport_data') + @classmethod + def validate_passport_data(cls, v: str | None) -> str | None: + if v is None: + return None + v = v.strip() + if len(v) > 255: + raise ValueError('Passport data is too long') + return v diff --git a/src/presentation/schemas/email.py b/src/presentation/schemas/email.py new file mode 100644 index 0000000..30e0874 --- /dev/null +++ b/src/presentation/schemas/email.py @@ -0,0 +1,35 @@ +import re +from pydantic import BaseModel, field_validator + + +class ChangeEmailConfirmOldRequest(BaseModel): + code: str + new_email: str + + @field_validator('code') + @classmethod + def validate_code(cls, v: str) -> str: + v = v.strip() + if not re.match(r'^\d{6}$', v): + raise ValueError('Code must be exactly 6 digits') + return v + + @field_validator('new_email') + @classmethod + def validate_new_email(cls, v: str) -> str: + v = v.strip().lower() + if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', v): + raise ValueError('Invalid email address') + return v + + +class ChangeEmailCompleteRequest(BaseModel): + code: str + + @field_validator('code') + @classmethod + def validate_code(cls, v: str) -> str: + v = v.strip() + if not re.match(r'^\d{6}$', v): + raise ValueError('Code must be exactly 6 digits') + return v diff --git a/src/presentation/schemas/encrypted_mnemonic.py b/src/presentation/schemas/encrypted_mnemonic.py new file mode 100644 index 0000000..54a6049 --- /dev/null +++ b/src/presentation/schemas/encrypted_mnemonic.py @@ -0,0 +1,23 @@ +import re +from pydantic import BaseModel, field_validator + + +class EncryptedMnemonicConfirmRequest(BaseModel): + code: str + encrypted_mnemonic: str + + @field_validator('code') + @classmethod + def validate_code(cls, v: str) -> str: + v = v.strip() + if not re.match(r'^\d{6}$', v): + raise ValueError('Code must be exactly 6 digits') + return v + + @field_validator('encrypted_mnemonic') + @classmethod + def validate_encrypted_mnemonic(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError('encrypted_mnemonic must not be empty') + return v diff --git a/src/presentation/schemas/me_public.py b/src/presentation/schemas/me_public.py new file mode 100644 index 0000000..07a6b58 --- /dev/null +++ b/src/presentation/schemas/me_public.py @@ -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, байты', + ) diff --git a/src/presentation/schemas/password.py b/src/presentation/schemas/password.py new file mode 100644 index 0000000..6da729c --- /dev/null +++ b/src/presentation/schemas/password.py @@ -0,0 +1,75 @@ +import re +from typing import Self +from pydantic import BaseModel, field_validator, model_validator +from src.application.domain.password_policy import validate_password_strength + + +class ForgotPasswordStartRequest(BaseModel): + email: str + + @field_validator('email') + @classmethod + def validate_email(cls, v: str) -> str: + v = v.strip().lower() + if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', v): + raise ValueError('Invalid email address') + return v + + +class ForgotPasswordCompleteRequest(BaseModel): + email: str + code: str + new_password: str + confirm_password: str + + @field_validator('email') + @classmethod + def validate_email(cls, v: str) -> str: + v = v.strip().lower() + if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', v): + raise ValueError('Invalid email address') + return v + + @model_validator(mode='after') + def passwords_match(self) -> Self: + if self.new_password != self.confirm_password: + raise ValueError('Passwords do not match') + return self + + @field_validator('code') + @classmethod + def validate_code(cls, v: str) -> str: + v = v.strip() + if not re.match(r'^\d{6}$', v): + raise ValueError('Code must be exactly 6 digits') + return v + + @field_validator('new_password') + @classmethod + def validate_new_password(cls, v: str) -> str: + return validate_password_strength(v) + + +class ChangePasswordConfirmRequest(BaseModel): + code: str + new_password: str + confirm_password: str + + @model_validator(mode='after') + def passwords_match(self) -> Self: + if self.new_password != self.confirm_password: + raise ValueError('Passwords do not match') + return self + + @field_validator('code') + @classmethod + def validate_code(cls, v: str) -> str: + v = v.strip() + if not re.match(r'^\d{6}$', v): + raise ValueError('Code must be exactly 6 digits') + return v + + @field_validator('new_password') + @classmethod + def validate_new_password(cls, v: str) -> str: + return validate_password_strength(v) diff --git a/src/presentation/schemas/phone.py b/src/presentation/schemas/phone.py new file mode 100644 index 0000000..1a81088 --- /dev/null +++ b/src/presentation/schemas/phone.py @@ -0,0 +1,16 @@ +import re +from pydantic import BaseModel, field_validator + + +class SetPhoneRequest(BaseModel): + phone: str + + @field_validator('phone') + @classmethod + def validate_russian_phone(cls, v: str) -> str: + cleaned = re.sub(r'[\s\-\(\)]', '', v) + pattern = r'^(\+7|8)\d{10}$' + if not re.match(pattern, cleaned): + raise ValueError('Invalid Russian phone number') + normalized = '+7' + cleaned[-10:] + return normalized diff --git a/src/presentation/schemas/purchase_request.py b/src/presentation/schemas/purchase_request.py new file mode 100644 index 0000000..242d685 --- /dev/null +++ b/src/presentation/schemas/purchase_request.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from decimal import Decimal + +from pydantic import BaseModel, Field + + +class CreatePurchaseRequestBody(BaseModel): + usdt_amount: Decimal = Field(gt=0) + comment: str | None = None + target_wallet_chain: str | None = Field(default='ETH', max_length=16) + target_wallet_address: str | None = Field(default=None, max_length=128) + + +class PurchaseRequestResponse(BaseModel): + id: str + organization_id: str + status: str + usdt_amount: str + rub_amount: str | None + exchange_rate: str | None + service_fee_percent: str | None + comment: str | None + admin_comment: str | None + target_wallet_chain: str | None + target_wallet_address: str | None + tx_hash: str | None + created_at: str | None + updated_at: str | None + completed_at: str | None + + +class PurchaseRequestListResponse(BaseModel): + items: list[PurchaseRequestResponse] + total: int diff --git a/src/presentation/serializers/__init__.py b/src/presentation/serializers/__init__.py new file mode 100644 index 0000000..a92cc01 --- /dev/null +++ b/src/presentation/serializers/__init__.py @@ -0,0 +1 @@ +from src.presentation.serializers.me_user import me_user_payload, me_user_public diff --git a/src/presentation/serializers/me_user.py b/src/presentation/serializers/me_user.py new file mode 100644 index 0000000..169ce69 --- /dev/null +++ b/src/presentation/serializers/me_user.py @@ -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') diff --git a/src/presentation/serializers/purchase_request.py b/src/presentation/serializers/purchase_request.py new file mode 100644 index 0000000..3b2bbe8 --- /dev/null +++ b/src/presentation/serializers/purchase_request.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from src.application.domain.entities.purchase_request import PurchaseRequestEntity +from src.presentation.schemas.purchase_request import PurchaseRequestResponse + + +def purchase_request_to_response(entity: PurchaseRequestEntity) -> PurchaseRequestResponse: + return PurchaseRequestResponse( + id=entity.id, + organization_id=entity.organization_id, + status=entity.status, + usdt_amount=str(entity.usdt_amount), + rub_amount=str(entity.rub_amount) if entity.rub_amount is not None else None, + exchange_rate=str(entity.exchange_rate) if entity.exchange_rate is not None else None, + service_fee_percent=str(entity.service_fee_percent) if entity.service_fee_percent is not None else None, + comment=entity.comment, + admin_comment=entity.admin_comment, + target_wallet_chain=entity.target_wallet_chain, + target_wallet_address=entity.target_wallet_address, + tx_hash=entity.tx_hash, + created_at=entity.created_at.isoformat() if entity.created_at else None, + updated_at=entity.updated_at.isoformat() if entity.updated_at else None, + completed_at=entity.completed_at.isoformat() if entity.completed_at else None, + ) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..cce18f0 --- /dev/null +++ b/uv.lock @@ -0,0 +1,517 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "apscheduler" +version = "3.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { 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 = "b2b-service" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "apscheduler" }, + { name = "asyncpg" }, + { name = "dotenv" }, + { name = "fastapi" }, + { name = "granian" }, + { name = "hvac" }, + { name = "orjson" }, + { name = "pydantic-settings" }, + { name = "python-jose" }, + { name = "python-ulid" }, + { name = "redis" }, + { name = "sqlalchemy" }, + { name = "uvloop", marker = "sys_platform != 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "apscheduler", specifier = "==3.11.2" }, + { name = "asyncpg", specifier = "==0.31.0" }, + { name = "dotenv", specifier = "==0.9.9" }, + { name = "fastapi", specifier = "==0.128.7" }, + { name = "granian", specifier = "==2.6.1" }, + { name = "hvac", specifier = "==2.4.0" }, + { name = "orjson", specifier = "==3.11.7" }, + { name = "pydantic-settings", specifier = "==2.12.0" }, + { name = "python-jose", specifier = "==3.5.0" }, + { name = "python-ulid", specifier = "==3.1.0" }, + { name = "redis", specifier = "==7.2.0" }, + { name = "sqlalchemy", specifier = "==2.0.46" }, + { name = "uvloop", marker = "sys_platform != 'win32'", specifier = "==0.22.1" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "dotenv" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dotenv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, +] + +[[package]] +name = "ecdsa" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" }, +] + +[[package]] +name = "fastapi" +version = "0.128.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/fc/af386750b3fd8d8828167e4c82b787a8eeca2eca5c5429c9db8bb7c70e04/fastapi-0.128.7.tar.gz", hash = "sha256:783c273416995486c155ad2c0e2b45905dedfaf20b9ef8d9f6a9124670639a24", size = 375325, upload-time = "2026-02-10T12:26:40.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/1a/f983b45661c79c31be575c570d46c437a5409b67a939c1b3d8d6b3ed7a7f/fastapi-0.128.7-py3-none-any.whl", hash = "sha256:6bd9bd31cb7047465f2d3fa3ba3f33b0870b17d4eaf7cdb36d1576ab060ad662", size = 103630, upload-time = "2026-02-10T12:26:39.414Z" }, +] + +[[package]] +name = "granian" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/22/93016f4f9e9115ba981f51fc17c7c369a34772f11a93043320a2a3d5c6ea/granian-2.6.1.tar.gz", hash = "sha256:d209065b12f18b6d7e78f1c16ff9444e5367dddeb41e3225c2cf024762740590", size = 115480, upload-time = "2026-01-07T11:08:55.927Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/d1/9d191ea0b4f01a0d2437600b32a025e687189bae072878ec161f358eb465/granian-2.6.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:801bcf7efc3fdd12a08016ed94b1a386480c9a5185eb8e017fd83db1b2d210b4", size = 3070339, upload-time = "2026-01-07T11:07:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1e/be0ba55a2b21aeadeb8774721964740130fdd3dd7337d8a5ec130a0c48c0/granian-2.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:853fb869a50d742576bb4f974f321242a71a4d8eed918939397b317ab32c6a2d", size = 2819049, upload-time = "2026-01-07T11:07:23.877Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/d8adb472dc71b212281a82d3ea00858809f2844a79b45e63bbb3a09921b7/granian-2.6.1-cp312-cp312-manylinux_2_24_armv7l.whl", hash = "sha256:327a6090496c1deebd9e315f973bdbfc5c927e5574588bba918bfe2127bbd578", size = 3322325, upload-time = "2026-01-07T11:07:25.304Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/c3ce9e4f19163f35c5c57c45af2ad353abcc6091a44625caec56e065ca4a/granian-2.6.1-cp312-cp312-manylinux_2_24_i686.whl", hash = "sha256:4c91f0eefc34d809773762a9b81c1c48e20ff74c0f1be876d1132d82c0f74609", size = 3136460, upload-time = "2026-01-07T11:07:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/3d/87/91b57eb5407a12bfe779acfa3fbb2be329aec14e6d88acf293fe910c19e5/granian-2.6.1-cp312-cp312-manylinux_2_24_x86_64.whl", hash = "sha256:c5754de57b56597d5998b7bb40aa9d0dc4e1dbeb5aea3309945126ed71b41c6d", size = 3386850, upload-time = "2026-01-07T11:07:27.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/b61a6f3bfc2f35e504e42789776a269cbdc0cdafdb10597bd6534e93ba3d/granian-2.6.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:e849d6467ebe77d0a75eb4175f7cc06b1150dbfce0259932a4270c765b4de6c4", size = 3240693, upload-time = "2026-01-07T11:07:29.52Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1d/c40bd8dd99b855190d67127e0610f082cfbc7898dbd41f1ade015c2041f7/granian-2.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5a265867203e30d3c54d9d99783346040681ba2aaec70fcbe63de0e295e7882f", size = 3312703, upload-time = "2026-01-07T11:07:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ca/589c042afc3287b36dfeed6df56074cc831a94e5217bcbd7c1af20812fe2/granian-2.6.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:03f0a1505e7862183203d7d7c1e2b29349bd63a18858ced49aec4d7aadb98fc8", size = 3483737, upload-time = "2026-01-07T11:07:32.726Z" }, + { url = "https://files.pythonhosted.org/packages/6f/51/72eb037bac01db9623fa5fb128739bfb5679fb90e6da2645c5a3d8a4168d/granian-2.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:703ed57ba134ab16f15d49f7d644329db1cb0f7f8114ec3f08fb8039850e308a", size = 3514745, upload-time = "2026-01-07T11:07:34.706Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/be9d5e97d3775dfc0f98b56a85ad6c73d7b0ac4cfc452558696e061d038d/granian-2.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c771949707118116fa78b03511e690cb6c3bd94e9d84db7c2bdfe0250fecc80", size = 2349022, upload-time = "2026-01-07T11:07:36.484Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, + { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, +] + +[[package]] +name = "hvac" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/57/b46c397fb3842cfb02a44609aa834c887f38dd75f290c2fc5a34da4b2fee/hvac-2.4.0.tar.gz", hash = "sha256:e0056ad9064e7923e874e6769015b032580b639e29246f5ab1044f7959c1c7e0", size = 332543, upload-time = "2025-10-30T12:57:47.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/33/71e45a6bd6875f44a26f99da31c63b6840123e88bedf2c0b1ce429b8be12/hvac-2.4.0-py3-none-any.whl", hash = "sha256:008db5efd8c2f77bd37d2368ea5f713edceae1c65f11fd608393179478649e0f", size = 155921, upload-time = "2025-10-30T12:57:46.253Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" }, + { url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" }, + { url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" }, + { url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" }, + { url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" }, + { url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" }, + { url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" }, + { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +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-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-jose" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + +[[package]] +name = "python-ulid" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/7e/0d6c82b5ccc71e7c833aed43d9e8468e1f2ff0be1b3f657a6fcafbb8433d/python_ulid-3.1.0.tar.gz", hash = "sha256:ff0410a598bc5f6b01b602851a3296ede6f91389f913a5d5f8c496003836f636", size = 93175, upload-time = "2025-08-18T16:09:26.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/a0/4ed6632b70a52de845df056654162acdebaf97c20e3212c559ac43e7216e/python_ulid-3.1.0-py3-none-any.whl", hash = "sha256:e2cdc979c8c877029b4b7a38a6fba3bc4578e4f109a308419ff4d3ccf0a46619", size = 11577, upload-time = "2025-08-18T16:09:25.047Z" }, +] + +[[package]] +name = "redis" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/32/6fac13a11e73e1bc67a2ae821a72bfe4c2d8c4c48f0267e4a952be0f1bae/redis-7.2.0.tar.gz", hash = "sha256:4dd5bf4bd4ae80510267f14185a15cba2a38666b941aff68cccf0256b51c1f26", size = 4901247, upload-time = "2026-02-16T17:16:22.797Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/cf/f6180b67f99688d83e15c84c5beda831d1d341e95872d224f87ccafafe61/redis-7.2.0-py3-none-any.whl", hash = "sha256:01f591f8598e483f1842d429e8ae3a820804566f1c73dca1b80e23af9fba0497", size = 394898, upload-time = "2026-02-16T17:16:20.693Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/35/d16bfa235c8b7caba3730bba43e20b1e376d2224f407c178fbf59559f23e/sqlalchemy-2.0.46-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a9a72b0da8387f15d5810f1facca8f879de9b85af8c645138cba61ea147968c", size = 2153405, upload-time = "2026-01-21T19:05:54.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/6c/3192e24486749862f495ddc6584ed730c0c994a67550ec395d872a2ad650/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2347c3f0efc4de367ba00218e0ae5c4ba2306e47216ef80d6e31761ac97cb0b9", size = 3334702, upload-time = "2026-01-21T18:46:45.384Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a2/b9f33c8d68a3747d972a0bb758c6b63691f8fb8a49014bc3379ba15d4274/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9094c8b3197db12aa6f05c51c05daaad0a92b8c9af5388569847b03b1007fb1b", size = 3347664, upload-time = "2026-01-21T18:40:09.979Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d2/3e59e2a91eaec9db7e8dc6b37b91489b5caeb054f670f32c95bcba98940f/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37fee2164cf21417478b6a906adc1a91d69ae9aba8f9533e67ce882f4bb1de53", size = 3277372, upload-time = "2026-01-21T18:46:47.168Z" }, + { url = "https://files.pythonhosted.org/packages/dd/dd/67bc2e368b524e2192c3927b423798deda72c003e73a1e94c21e74b20a85/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b1e14b2f6965a685c7128bd315e27387205429c2e339eeec55cb75ca4ab0ea2e", size = 3312425, upload-time = "2026-01-21T18:40:11.548Z" }, + { url = "https://files.pythonhosted.org/packages/43/82/0ecd68e172bfe62247e96cb47867c2d68752566811a4e8c9d8f6e7c38a65/sqlalchemy-2.0.46-cp312-cp312-win32.whl", hash = "sha256:412f26bb4ba942d52016edc8d12fb15d91d3cd46b0047ba46e424213ad407bcb", size = 2113155, upload-time = "2026-01-21T18:42:49.748Z" }, + { url = "https://files.pythonhosted.org/packages/bc/2a/2821a45742073fc0331dc132552b30de68ba9563230853437cac54b2b53e/sqlalchemy-2.0.46-cp312-cp312-win_amd64.whl", hash = "sha256:ea3cd46b6713a10216323cda3333514944e510aa691c945334713fca6b5279ff", size = 2140078, upload-time = "2026-01-21T18:42:51.197Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { 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" }, +]