Compare commits

...

10 Commits

Author SHA1 Message Date
Раис Юсупалиев a3ce93b058 Добавлен CI, добавлена отправка письма о получении оплаты, добавлен order_id в success_url
Deploy / deploy (push) Failing after 10s
2026-05-27 23:34:03 +03:00
Раис Юсупалиев 50124fb2c9 Добавлена отправка накладных на почту 2026-05-24 00:55:09 +03:00
Раис Юсупалиев 5526f90cb3 Добавлен поллер накладной 2026-05-23 20:24:23 +03:00
Раис Юсупалиев c494d50566 Добавлен заказ накладной 2026-05-23 19:27:32 +03:00
Раис Юсупалиев 39cd5ddc7a Добавлена валидация цены 2026-05-13 16:35:22 +03:00
Раис Юсупалиев 6b66af5eb3 Добавлена обработка уведомлений через ручку 2026-04-18 21:27:15 +03:00
Раис Юсупалиев 78e9ad4fa9 Добавлена отправка NotificationURL и SuccessURL 2026-04-18 00:58:13 +03:00
Раис Юсупалиев bddac60965 Добавлено сохранение заказов в postgres 2026-04-18 00:33:45 +03:00
Раис Юсупалиев 2b201a08be Добавлен адаптер к tbank и формирование ссылки на оплату 2026-04-12 18:56:41 +03:00
Раис Юсупалиев ab0b66e1c2 fix order form 2026-04-11 00:36:37 +03:00
103 changed files with 9340 additions and 724 deletions
+68
View File
@@ -0,0 +1,68 @@
name: Deploy
on:
push:
branches:
- master
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build and push image
run: |
docker login gitea.p4r4dls.ru \
-u ${{ secrets.REGISTRY_USER }} \
-p ${{ secrets.REGISTRY_TOKEN }}
docker buildx create --use --name multibuilder
docker buildx build \
--platform linux/amd64 \
--push \
-t gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9 \
.
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan 194.58.121.203 >> ~/.ssh/known_hosts
- name: Render and copy configs
run: |
set -euo pipefail
render() {
local src=$1 dst=$2
vars=$(grep -oP '\$\{\K[^}]+' "$src")
for var in $vars; do
if [[ -z "${!var:-}" ]]; then
echo "Error: $var не задан в Gitea Secrets" >&2
exit 1
fi
done
envsubst < "$src" > "$dst"
}
render config.template.yaml config.rendered.yaml
render docker-compose.template.yml docker-compose.rendered.yml
scp -i ~/.ssh/deploy_key config.rendered.yaml \
deploy@194.58.121.203:/app/g2s-aggregator/config.yaml
scp -i ~/.ssh/deploy_key docker-compose.rendered.yml \
deploy@194.58.121.203:/app/g2s-aggregator/docker-compose.yml
- name: Deploy
run: |
ssh -i ~/.ssh/deploy_key deploy@194.58.121.203 "
cd /app/g2s-aggregator &&
docker login gitea.p4r4dls.ru \
-u ${{ secrets.REGISTRY_USER }} \
-p ${{ secrets.REGISTRY_TOKEN }} &&
docker compose pull &&
docker compose up -d
"
+1
View File
@@ -1,6 +1,7 @@
.idea .idea
*.iml *.iml
/config.yaml /config.yaml
/docker-compose.yml
__pycache__ __pycache__
http-client.private.env.json http-client.private.env.json
scripts scripts
+2 -1
View File
@@ -11,6 +11,7 @@ RUN poetry config virtualenvs.create false \
&& poetry install --no-interaction --no-root && poetry install --no-interaction --no-root
COPY app ./app COPY app ./app
COPY config.yaml ./config.yaml COPY alembic.ini ./alembic.ini
COPY alembic ./alembic
CMD ["poetry", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["poetry", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+41
View File
@@ -0,0 +1,41 @@
[alembic]
script_location = alembic
prepend_sys_path = .
path_separator = os
sqlalchemy.url =
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+66
View File
@@ -0,0 +1,66 @@
"""Alembic async migration environment."""
from asyncio import run
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from app.config import get_settings
from app.repositories.order.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def _database_url() -> str:
configured_url = config.get_main_option("sqlalchemy.url")
if configured_url:
return configured_url
return get_settings().postgres.dsn
def run_migrations_offline() -> None:
context.configure(
url=_database_url(),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
configuration = config.get_section(config.config_ini_section, {})
configuration["sqlalchemy.url"] = _database_url()
connectable = async_engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
run(run_async_migrations())
+20
View File
@@ -0,0 +1,20 @@
"""${message}"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: str | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,60 @@
"""Create orders table."""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260412_028"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"orders",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("order_uuid", sa.String(length=128), nullable=False),
sa.Column("payment_url", sa.String(length=2048), nullable=False),
sa.Column("price", sa.Integer(), nullable=False),
sa.Column("delivery_type", sa.Integer(), nullable=False),
sa.Column("tariff_code", sa.Integer(), nullable=False),
sa.Column("sender", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("recipient", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column(
"from_location",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
),
sa.Column(
"to_location",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
),
sa.Column("packages", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("services", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("comment", sa.String(length=1024), nullable=True),
sa.Column("payment_status", sa.String(length=64), nullable=True),
sa.Column("tbank_payment_id", sa.BigInteger(), nullable=True),
sa.Column("cdek_order_uuid", sa.String(length=128), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("order_uuid", name="uq_orders_order_uuid"),
)
def downgrade() -> None:
op.drop_table("orders")
@@ -0,0 +1,97 @@
"""Rework orders table to a single payload JSONB column."""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260513_032"
down_revision: str | None = "20260412_028"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.drop_column("orders", "sender")
op.drop_column("orders", "recipient")
op.drop_column("orders", "from_location")
op.drop_column("orders", "to_location")
op.drop_column("orders", "packages")
op.drop_column("orders", "services")
op.drop_column("orders", "comment")
op.drop_column("orders", "delivery_type")
op.add_column(
"orders",
sa.Column(
"payload",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
),
)
op.add_column(
"orders",
sa.Column("account_email", sa.String(length=320), nullable=False),
)
def downgrade() -> None:
op.drop_column("orders", "account_email")
op.drop_column("orders", "payload")
op.add_column(
"orders",
sa.Column("delivery_type", sa.Integer(), nullable=False),
)
op.add_column(
"orders",
sa.Column("comment", sa.String(length=1024), nullable=True),
)
op.add_column(
"orders",
sa.Column(
"services",
postgresql.JSONB(astext_type=sa.Text()),
nullable=True,
),
)
op.add_column(
"orders",
sa.Column(
"packages",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
),
)
op.add_column(
"orders",
sa.Column(
"to_location",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
),
)
op.add_column(
"orders",
sa.Column(
"from_location",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
),
)
op.add_column(
"orders",
sa.Column(
"recipient",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
),
)
op.add_column(
"orders",
sa.Column(
"sender",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
),
)
@@ -0,0 +1,28 @@
"""Add CDEK waybill columns to orders table."""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260523_033"
down_revision: str | None = "20260513_032"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"orders",
sa.Column("cdek_waybill_uuid", sa.String(length=128), nullable=True),
)
op.add_column(
"orders",
sa.Column("cdek_waybill_url", sa.String(length=2048), nullable=True),
)
def downgrade() -> None:
op.drop_column("orders", "cdek_waybill_url")
op.drop_column("orders", "cdek_waybill_uuid")
@@ -0,0 +1,32 @@
"""Add CDEK polling columns to orders table."""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260524_034"
down_revision: str | None = "20260523_033"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"orders",
sa.Column("cdek_order_status", sa.String(length=64), nullable=True),
)
op.add_column(
"orders",
sa.Column(
"cdek_polled_at",
sa.DateTime(timezone=True),
nullable=True,
),
)
def downgrade() -> None:
op.drop_column("orders", "cdek_polled_at")
op.drop_column("orders", "cdek_order_status")
@@ -0,0 +1,27 @@
"""Add waybill_email_sent_at column to orders table."""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260524_035"
down_revision: str | None = "20260524_034"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"orders",
sa.Column(
"waybill_email_sent_at",
sa.DateTime(timezone=True),
nullable=True,
),
)
def downgrade() -> None:
op.drop_column("orders", "waybill_email_sent_at")
@@ -0,0 +1,27 @@
"""Add payment_email_sent_at column to orders table."""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260527_036"
down_revision: str | None = "20260524_035"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"orders",
sa.Column(
"payment_email_sent_at",
sa.DateTime(timezone=True),
nullable=True,
),
)
def downgrade() -> None:
op.drop_column("orders", "payment_email_sent_at")
+4
View File
@@ -18,5 +18,9 @@ class DeliveryProvider(ABC):
raise NotImplementedError raise NotImplementedError
class ProviderClientError(RuntimeError):
"""Raised when a provider call fails for temporary or provider-side reasons."""
class ProviderRequestError(RuntimeError): class ProviderRequestError(RuntimeError):
"""Raised when a provider rejects input request data.""" """Raised when a provider rejects input request data."""
+280 -10
View File
@@ -3,27 +3,44 @@
import asyncio import asyncio
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any from typing import Any
import logging
import httpx import httpx
import structlog
from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError from app.adapters.delivery_providers.base import (
DeliveryProvider,
ProviderClientError,
ProviderRequestError,
)
from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient
from app.adapters.delivery_providers.cdek.mapper import map_cdek_response from app.adapters.delivery_providers.cdek.mapper import (
CDEKMappingError,
map_cdek_response,
map_cdek_response_for_tariff_code,
)
from app.adapters.delivery_providers.cdek.order_mapper import ( from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderInfo,
CDEKOrderMappingError, CDEKOrderMappingError,
CDEKOrderRegistrationResult,
CDEKWaybillInfo,
centimeters_string_to_int,
kilograms_string_to_grams,
map_cdek_existing_order_response,
map_cdek_order_info_response,
map_cdek_order_request, map_cdek_order_request,
map_cdek_order_response, map_cdek_order_response,
map_cdek_waybill_info_response,
resolve_cdek_city_code,
) )
from app.cities import cities_map from app.cities import cities_map
from app.config import AdapterConfig from app.config import AdapterConfig
from app.schemas.order import OrderCreateRequest, OrderCreateResponse from app.schemas.payment import InitPaymentRequest
from app.schemas.request import DeliveryCalculationRequest from app.schemas.request import DeliveryCalculationRequest
from app.schemas.response import DeliveryPrice from app.schemas.response import DeliveryPrice
log = logging.getLogger(__name__) log = structlog.get_logger(__name__)
class CDEKClientError(RuntimeError): class CDEKClientError(ProviderClientError):
"""Raised when CDEK tariff request fails.""" """Raised when CDEK tariff request fails."""
@@ -48,6 +65,7 @@ class CDEKClient:
normalized_base_url = base_url.rstrip("/") normalized_base_url = base_url.rstrip("/")
self._tariff_url = f"{normalized_base_url}/calculator/tarifflist" self._tariff_url = f"{normalized_base_url}/calculator/tarifflist"
self._orders_url = f"{normalized_base_url}/orders" self._orders_url = f"{normalized_base_url}/orders"
self._print_orders_url = f"{normalized_base_url}/print/orders"
self._timeout_seconds = timeout_seconds self._timeout_seconds = timeout_seconds
self._retry_attempts = retry_attempts self._retry_attempts = retry_attempts
self._retry_backoff_seconds = retry_backoff_seconds self._retry_backoff_seconds = retry_backoff_seconds
@@ -57,6 +75,29 @@ class CDEKClient:
self, request: DeliveryCalculationRequest self, request: DeliveryCalculationRequest
) -> dict[str, Any]: ) -> dict[str, Any]:
payload = await self._build_payload(request) payload = await self._build_payload(request)
return await self._post_tariff_payload(
payload,
request_error_message=None,
)
async def get_raw_payment_price(
self,
request: InitPaymentRequest,
) -> dict[str, Any]:
payload = self._build_payment_price_payload(request)
return await self._post_tariff_payload(
payload,
request_error_message=(
"CDEK payment price validation request was rejected with status"
),
)
async def _post_tariff_payload(
self,
payload: dict[str, Any],
*,
request_error_message: str | None,
) -> dict[str, Any]:
for attempt in range(self._retry_attempts + 1): for attempt in range(self._retry_attempts + 1):
try: try:
token = await self._auth_client.get_access_token() token = await self._auth_client.get_access_token()
@@ -78,10 +119,27 @@ class CDEKClient:
if attempt < self._retry_attempts: if attempt < self._retry_attempts:
await self._sleep(self._retry_delay(attempt)) await self._sleep(self._retry_delay(attempt))
continue continue
log.warning(
"cdek_tariff_request_server_error",
status_code=response.status_code,
response_body=_response_text_or_none(response),
request_payload=payload,
)
raise CDEKClientError( raise CDEKClientError(
f"CDEK tariff request failed with status {response.status_code}." f"CDEK tariff request failed with status {response.status_code}."
) )
if request_error_message is not None and 400 <= response.status_code < 500:
log.warning(
"cdek_tariff_request_rejected",
status_code=response.status_code,
response_body=_response_text_or_none(response),
request_payload=payload,
)
raise CDEKRequestError(
f"{request_error_message} {response.status_code}."
)
try: try:
response.raise_for_status() response.raise_for_status()
raw_payload = response.json() raw_payload = response.json()
@@ -94,8 +152,10 @@ class CDEKClient:
raise CDEKClientError("CDEK tariff request failed unexpectedly.") raise CDEKClientError("CDEK tariff request failed unexpectedly.")
async def register_order(self, request: OrderCreateRequest) -> OrderCreateResponse: async def register_order(
payload = map_cdek_order_request(request) self, request: InitPaymentRequest, order_uuid: str
) -> CDEKOrderRegistrationResult:
payload = map_cdek_order_request(request, order_uuid)
for attempt in range(self._retry_attempts + 1): for attempt in range(self._retry_attempts + 1):
try: try:
token = await self._auth_client.get_access_token() token = await self._auth_client.get_access_token()
@@ -123,6 +183,17 @@ class CDEKClient:
) )
if 400 <= response.status_code < 500: if 400 <= response.status_code < 500:
response_payload = _response_json_or_none(response)
existing_result = map_cdek_existing_order_response(response_payload)
if existing_result is not None:
return existing_result
log.warning(
"cdek_order_register_rejected",
status_code=response.status_code,
request_payload=payload,
response_payload=response_payload,
response_body=_response_text_or_none(response),
)
raise CDEKRequestError( raise CDEKRequestError(
"CDEK order registration request was rejected with status " "CDEK order registration request was rejected with status "
f"{response.status_code}." f"{response.status_code}."
@@ -141,6 +212,12 @@ class CDEKClient:
"CDEK order registration payload must be a JSON object." "CDEK order registration payload must be a JSON object."
) )
log.info(
"cdek_order_register_raw_response",
status_code=response.status_code,
response_payload=raw_payload,
)
try: try:
return map_cdek_order_response(raw_payload) return map_cdek_order_response(raw_payload)
except CDEKOrderMappingError as exc: except CDEKOrderMappingError as exc:
@@ -150,6 +227,134 @@ class CDEKClient:
raise CDEKClientError("CDEK order registration failed unexpectedly.") raise CDEKClientError("CDEK order registration failed unexpectedly.")
async def get_order(self, cdek_order_uuid: str) -> CDEKOrderInfo:
url = f"{self._orders_url}/{cdek_order_uuid}"
raw_payload = await self._get_json(
url,
failure_message="CDEK get order failed",
)
try:
return map_cdek_order_info_response(raw_payload)
except CDEKOrderMappingError as exc:
raise CDEKClientError(
"CDEK get order response payload is invalid."
) from exc
async def get_waybill(self, cdek_waybill_uuid: str) -> CDEKWaybillInfo:
url = f"{self._print_orders_url}/{cdek_waybill_uuid}"
raw_payload = await self._get_json(
url,
failure_message="CDEK get waybill failed",
)
try:
return map_cdek_waybill_info_response(raw_payload)
except CDEKOrderMappingError as exc:
raise CDEKClientError(
"CDEK get waybill response payload is invalid."
) from exc
async def download_waybill_pdf(self, url: str) -> bytes:
failure_message = "CDEK waybill PDF download failed"
for attempt in range(self._retry_attempts + 1):
try:
token = await self._auth_client.get_access_token()
response = await self._http_client.get(
url,
headers={"Authorization": f"Bearer {token}"},
timeout=self._timeout_seconds,
)
except (httpx.TimeoutException, httpx.TransportError) as exc:
if attempt < self._retry_attempts:
await self._sleep(self._retry_delay(attempt))
continue
raise CDEKClientError(
f"{failure_message} after retry attempts."
) from exc
if self._should_retry(response.status_code):
if attempt < self._retry_attempts:
await self._sleep(self._retry_delay(attempt))
continue
raise CDEKClientError(
f"{failure_message} with retriable status "
f"{response.status_code}."
)
if 400 <= response.status_code < 500:
log.warning(
"cdek_waybill_pdf_download_rejected",
url=url,
status_code=response.status_code,
response_body=_response_text_or_none(response),
)
raise CDEKRequestError(
f"{failure_message} with status {response.status_code}."
)
try:
response.raise_for_status()
except httpx.HTTPError as exc:
raise CDEKClientError(
f"{failure_message}: invalid response."
) from exc
return response.content
raise CDEKClientError(f"{failure_message} unexpectedly.")
async def _get_json(self, url: str, *, failure_message: str) -> dict[str, Any]:
for attempt in range(self._retry_attempts + 1):
try:
token = await self._auth_client.get_access_token()
response = await self._http_client.get(
url,
headers={"Authorization": f"Bearer {token}"},
timeout=self._timeout_seconds,
)
except (httpx.TimeoutException, httpx.TransportError) as exc:
if attempt < self._retry_attempts:
await self._sleep(self._retry_delay(attempt))
continue
raise CDEKClientError(
f"{failure_message} after retry attempts."
) from exc
if self._should_retry(response.status_code):
if attempt < self._retry_attempts:
await self._sleep(self._retry_delay(attempt))
continue
raise CDEKClientError(
f"{failure_message} with retriable status "
f"{response.status_code}."
)
if 400 <= response.status_code < 500:
log.warning(
"cdek_get_request_rejected",
url=url,
status_code=response.status_code,
response_body=_response_text_or_none(response),
)
raise CDEKRequestError(
f"{failure_message} with status {response.status_code}."
)
try:
response.raise_for_status()
raw_payload = response.json()
except (httpx.HTTPError, TypeError, ValueError) as exc:
raise CDEKClientError(
f"{failure_message}: invalid response payload."
) from exc
if not isinstance(raw_payload, dict):
raise CDEKClientError(
f"{failure_message}: payload must be a JSON object."
)
return raw_payload
raise CDEKClientError(f"{failure_message} unexpectedly.")
def _retry_delay(self, attempt: int) -> float: def _retry_delay(self, attempt: int) -> float:
return self._retry_backoff_seconds * (attempt + 1) return self._retry_backoff_seconds * (attempt + 1)
@@ -176,6 +381,34 @@ class CDEKClient:
], ],
} }
@staticmethod
def _build_payment_price_payload(
request: InitPaymentRequest,
) -> dict[str, Any]:
try:
from_code = resolve_cdek_city_code(request.sender_address.city_id)
to_code = resolve_cdek_city_code(request.receiver_address.city_id)
weight_grams = kilograms_string_to_grams(request.system_data.weight)
except CDEKOrderMappingError as exc:
raise CDEKRequestError(str(exc)) from exc
package: dict[str, Any] = {"weight": weight_grams}
dimensions = request.system_data.dimensions
if request.system_data.parcel_type == "parcel" and dimensions is not None:
try:
package["length"] = centimeters_string_to_int(dimensions.length, "length")
package["width"] = centimeters_string_to_int(dimensions.width, "width")
package["height"] = centimeters_string_to_int(dimensions.height, "height")
except CDEKOrderMappingError as exc:
raise CDEKRequestError(str(exc)) from exc
return {
"type": 2,
"from_location": {"code": from_code},
"to_location": {"code": to_code},
"packages": [package],
}
@staticmethod @staticmethod
def _get_cdek_city_code(city_id: int) -> int: def _get_cdek_city_code(city_id: int) -> int:
city_entry = cities_map.get(str(city_id)) city_entry = cities_map.get(str(city_id))
@@ -241,5 +474,42 @@ class CDEKProvider(DeliveryProvider):
raw_payload = await self._client.get_raw_price(request) raw_payload = await self._client.get_raw_price(request)
return map_cdek_response(raw_payload) return map_cdek_response(raw_payload)
async def register_order(self, request: OrderCreateRequest) -> OrderCreateResponse: async def get_payment_price(
return await self._client.register_order(request) self,
request: InitPaymentRequest,
) -> DeliveryPrice | None:
raw_payload = await self._client.get_raw_payment_price(request)
try:
return map_cdek_response_for_tariff_code(
raw_payload,
tariff_code=request.system_data.tariff.tariff_code,
)
except CDEKMappingError as exc:
raise CDEKClientError(
"CDEK payment price validation response payload is invalid."
) from exc
async def register_order(
self, request: InitPaymentRequest, order_uuid: str
) -> CDEKOrderRegistrationResult:
return await self._client.register_order(request, order_uuid)
async def get_order(self, cdek_order_uuid: str) -> CDEKOrderInfo:
return await self._client.get_order(cdek_order_uuid)
async def get_waybill(self, cdek_waybill_uuid: str) -> CDEKWaybillInfo:
return await self._client.get_waybill(cdek_waybill_uuid)
def _response_json_or_none(response: httpx.Response) -> object | None:
try:
return response.json()
except (TypeError, ValueError):
return None
def _response_text_or_none(response: httpx.Response) -> str | None:
try:
return response.text
except Exception:
return None
@@ -19,6 +19,24 @@ def map_cdek_response(payload: dict[str, Any]) -> list[DeliveryPrice]:
] ]
def map_cdek_response_for_tariff_code(
payload: dict[str, Any],
*,
tariff_code: int,
) -> DeliveryPrice | None:
tariff_codes = payload.get("tariff_codes")
if not isinstance(tariff_codes, list):
raise CDEKMappingError("CDEK response must include tariff_codes.")
payload_currency = payload.get("currency")
for tariff in tariff_codes:
if not isinstance(tariff, dict):
raise CDEKMappingError("CDEK tariff entry must be an object.")
if _tariff_code_matches(tariff.get("tariff_code"), tariff_code):
return _map_tariff(tariff, payload_currency=payload_currency)
return None
def _map_tariff( def _map_tariff(
tariff: dict[str, Any], tariff: dict[str, Any],
*, *,
@@ -47,6 +65,8 @@ def _map_tariff(
currency=str(raw_currency).upper(), currency=str(raw_currency).upper(),
delivery_days_min=int(period_min), delivery_days_min=int(period_min),
delivery_days_max=int(period_max), delivery_days_max=int(period_max),
tariff_code=_extract_tariff_code(tariff.get("tariff_code")),
bypass_parcel_type_filter=True,
) )
except (ArithmeticError, TypeError, ValueError) as exc: except (ArithmeticError, TypeError, ValueError) as exc:
raise CDEKMappingError("CDEK response fields have invalid values.") from exc raise CDEKMappingError("CDEK response fields have invalid values.") from exc
@@ -62,3 +82,21 @@ def _get_tariffs(payload: dict[str, Any]) -> list[dict[str, Any]]:
raise CDEKMappingError("CDEK tariff entry must be an object.") raise CDEKMappingError("CDEK tariff entry must be an object.")
tariffs.append(tariff) tariffs.append(tariff)
return tariffs return tariffs
def _tariff_code_matches(value: object, expected_tariff_code: int) -> bool:
if isinstance(value, bool) or value is None:
return False
try:
return int(value) == expected_tariff_code
except (TypeError, ValueError):
return False
def _extract_tariff_code(value: object) -> int | None:
if isinstance(value, bool) or value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
@@ -1,19 +1,67 @@
"""CDEK order registration payload mappers.""" """CDEK order registration payload mappers."""
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from typing import Any from typing import Any
from app.schemas.order import OrderCreateRequest, OrderCreateResponse from app.cities import cities_map
from app.schemas.payment import (
Address,
Contact,
Dimensions,
InitPaymentRequest,
SystemData,
)
class CDEKOrderMappingError(ValueError): class CDEKOrderMappingError(ValueError):
"""Raised when CDEK order payload cannot be mapped.""" """Raised when CDEK order payload cannot be mapped."""
def map_cdek_order_request(request: OrderCreateRequest) -> dict[str, Any]: _CDEK_WAYBILL_PRINT_TYPE = "WAYBILL"
return request.model_dump(mode="python", exclude_none=True) _CDEK_WAYBILL_RELATED_ENTITY_TYPE = "waybill"
def map_cdek_order_response(payload: dict[str, Any]) -> OrderCreateResponse: @dataclass(frozen=True)
class CDEKOrderRegistrationResult:
order_uuid: str
waybill_uuid: str | None
waybill_url: str | None
@dataclass(frozen=True)
class CDEKOrderInfo:
order_uuid: str
status_code: str | None
waybill_uuid: str | None
@dataclass(frozen=True)
class CDEKWaybillInfo:
waybill_uuid: str
url: str | None
def map_cdek_order_request(
request: InitPaymentRequest, order_uuid: str
) -> dict[str, Any]:
payload: dict[str, Any] = {
"number": order_uuid,
"type": 2,
"tariff_code": request.system_data.tariff.tariff_code,
"print": _CDEK_WAYBILL_PRINT_TYPE,
"sender": _map_party(request.sender_contact),
"recipient": _map_party(request.receiver_contact),
"from_location": _map_location(request.sender_address),
"to_location": _map_location(request.receiver_address),
"packages": [_map_package(request, order_uuid)],
}
if request.content.description:
payload["comment"] = request.content.description
return payload
def map_cdek_order_response(payload: dict[str, Any]) -> CDEKOrderRegistrationResult:
entity = payload.get("entity") entity = payload.get("entity")
if not isinstance(entity, dict): if not isinstance(entity, dict):
raise CDEKOrderMappingError("CDEK order response must include entity object.") raise CDEKOrderMappingError("CDEK order response must include entity object.")
@@ -22,4 +70,266 @@ def map_cdek_order_response(payload: dict[str, Any]) -> OrderCreateResponse:
if not isinstance(order_uuid, str) or not order_uuid: if not isinstance(order_uuid, str) or not order_uuid:
raise CDEKOrderMappingError("CDEK order response must include entity.uuid.") raise CDEKOrderMappingError("CDEK order response must include entity.uuid.")
return OrderCreateResponse(provider="cdek", order_uuid=order_uuid) waybill_uuid, waybill_url = _extract_waybill(payload)
return CDEKOrderRegistrationResult(
order_uuid=order_uuid,
waybill_uuid=waybill_uuid,
waybill_url=waybill_url,
)
_CDEK_DUPLICATE_ORDER_ERROR_CODES = frozenset({"v2_entity_already_exists"})
def map_cdek_existing_order_response(
payload: object,
) -> CDEKOrderRegistrationResult | None:
if not isinstance(payload, dict):
return None
if not _contains_cdek_duplicate_error_code(payload):
return None
try:
return map_cdek_order_response(payload)
except CDEKOrderMappingError:
order_uuid = _find_first_uuid(payload)
if order_uuid is None:
return None
waybill_uuid, waybill_url = _extract_waybill(payload)
return CDEKOrderRegistrationResult(
order_uuid=order_uuid,
waybill_uuid=waybill_uuid,
waybill_url=waybill_url,
)
def map_cdek_order_info_response(payload: dict[str, Any]) -> CDEKOrderInfo:
entity = payload.get("entity")
if not isinstance(entity, dict):
raise CDEKOrderMappingError(
"CDEK order info response must include entity object."
)
order_uuid = entity.get("uuid")
if not isinstance(order_uuid, str) or not order_uuid:
raise CDEKOrderMappingError(
"CDEK order info response must include entity.uuid."
)
status_code = _latest_status_code(entity.get("statuses"))
waybill_uuid, _ = _extract_waybill(payload)
return CDEKOrderInfo(
order_uuid=order_uuid,
status_code=status_code,
waybill_uuid=waybill_uuid,
)
def map_cdek_waybill_info_response(payload: dict[str, Any]) -> CDEKWaybillInfo:
entity = payload.get("entity")
if not isinstance(entity, dict):
raise CDEKOrderMappingError(
"CDEK waybill info response must include entity object."
)
waybill_uuid = entity.get("uuid")
if not isinstance(waybill_uuid, str) or not waybill_uuid:
raise CDEKOrderMappingError(
"CDEK waybill info response must include entity.uuid."
)
url_value = entity.get("url")
waybill_url = url_value if isinstance(url_value, str) and url_value else None
return CDEKWaybillInfo(waybill_uuid=waybill_uuid, url=waybill_url)
def _latest_status_code(statuses: object) -> str | None:
if not isinstance(statuses, list) or not statuses:
return None
dated: list[tuple[str, str]] = []
for entry in statuses:
if not isinstance(entry, dict):
continue
code = entry.get("code")
if not isinstance(code, str) or not code:
continue
date_time = entry.get("date_time")
date_time_key = date_time if isinstance(date_time, str) else ""
dated.append((date_time_key, code))
if not dated:
return None
dated.sort(key=lambda item: item[0])
return dated[-1][1]
def _extract_waybill(payload: dict[str, Any]) -> tuple[str | None, str | None]:
related_entities = payload.get("related_entities")
if not isinstance(related_entities, list):
return None, None
for entry in related_entities:
if not isinstance(entry, dict):
continue
if entry.get("type") != _CDEK_WAYBILL_RELATED_ENTITY_TYPE:
continue
uuid_value = entry.get("uuid")
url_value = entry.get("url")
waybill_uuid = uuid_value if isinstance(uuid_value, str) and uuid_value else None
waybill_url = url_value if isinstance(url_value, str) and url_value else None
if waybill_uuid is None and waybill_url is None:
continue
return waybill_uuid, waybill_url
return None, None
def compose_address_line(address: Address) -> str:
"""Build a CDEK address line from structured fields."""
parts = [address.city, address.street, address.house]
line = ", ".join(part for part in parts if part)
if address.apartment:
line = f"{line}, кв. {address.apartment}"
return line
def resolve_cdek_city_code(city_id: int) -> int:
"""Resolve CDEK city code from cities_map by city identifier."""
city_entry = cities_map.get(str(city_id))
if not isinstance(city_entry, dict):
raise CDEKOrderMappingError(
f"CDEK city mapping is not configured for city id {city_id}."
)
cdek_data = city_entry.get("cdek")
if not isinstance(cdek_data, dict):
raise CDEKOrderMappingError(
f"CDEK city mapping is not configured for city id {city_id}."
)
raw_city_code = cdek_data.get("code")
if raw_city_code is None or isinstance(raw_city_code, bool):
raise CDEKOrderMappingError(
f"CDEK city code is invalid for city id {city_id}."
)
try:
return int(raw_city_code)
except (TypeError, ValueError) as exc:
raise CDEKOrderMappingError(
f"CDEK city code is invalid for city id {city_id}."
) from exc
_GRAMS_IN_KILOGRAM = Decimal(1000)
_INTEGER_QUANTIZER = Decimal(1)
def kilograms_string_to_grams(value: str) -> int:
kilograms = _parse_positive_decimal(value, field_name="weight")
return int(
(kilograms * _GRAMS_IN_KILOGRAM).quantize(
_INTEGER_QUANTIZER, rounding=ROUND_HALF_UP
)
)
def centimeters_string_to_int(value: str, field_name: str) -> int:
measurement = _parse_positive_decimal(value, field_name=field_name)
return int(measurement.quantize(_INTEGER_QUANTIZER, rounding=ROUND_HALF_UP))
def _parse_positive_decimal(value: str, *, field_name: str) -> Decimal:
try:
parsed = Decimal(value)
except (InvalidOperation, TypeError, ValueError) as exc:
raise CDEKOrderMappingError(
f"{field_name} is not a valid decimal: {value!r}."
) from exc
if not parsed.is_finite() or parsed <= 0:
raise CDEKOrderMappingError(
f"{field_name} must be a positive decimal: {value!r}."
)
return parsed
def _map_party(contact: Contact) -> dict[str, Any]:
phone: dict[str, Any] = {"number": contact.phone}
if contact.phone_ext:
phone["additional"] = contact.phone_ext
party: dict[str, Any] = {
"name": contact.full_name,
"phones": [phone],
}
if contact.email:
party["email"] = contact.email
if contact.is_company:
party["contragent_type"] = "LEGAL_ENTITY"
party["company"] = contact.company_name
party["inn"] = contact.inn
party["kpp"] = contact.kpp
else:
party["company"] = contact.full_name
return party
def _map_location(address: Address) -> dict[str, Any]:
return {
"code": resolve_cdek_city_code(address.city_id),
"address": compose_address_line(address),
"postal_code": address.zip,
}
def _map_package(request: InitPaymentRequest, order_uuid: str) -> dict[str, Any]:
system_data: SystemData = request.system_data
weight_grams = kilograms_string_to_grams(request.system_data.weight)
description = request.content.description or order_uuid
package: dict[str, Any] = {
"number": order_uuid,
"weight": weight_grams,
"comment": description,
}
if system_data.parcel_type == "parcel" and system_data.dimensions is not None:
package.update(_map_dimensions(system_data.dimensions))
return package
def _map_dimensions(dimensions: Dimensions) -> dict[str, int]:
return {
"length": centimeters_string_to_int(dimensions.length, "length"),
"width": centimeters_string_to_int(dimensions.width, "width"),
"height": centimeters_string_to_int(dimensions.height, "height"),
}
def _contains_cdek_duplicate_error_code(payload: object) -> bool:
if isinstance(payload, dict):
code = payload.get("code")
if isinstance(code, str) and code in _CDEK_DUPLICATE_ORDER_ERROR_CODES:
return True
return any(
_contains_cdek_duplicate_error_code(value)
for value in payload.values()
)
if isinstance(payload, list):
return any(_contains_cdek_duplicate_error_code(item) for item in payload)
return False
def _find_first_uuid(payload: object) -> str | None:
if isinstance(payload, dict):
value = payload.get("uuid")
if isinstance(value, str) and value:
return value
for nested_value in payload.values():
nested_uuid = _find_first_uuid(nested_value)
if nested_uuid is not None:
return nested_uuid
if isinstance(payload, list):
for item in payload:
nested_uuid = _find_first_uuid(item)
if nested_uuid is not None:
return nested_uuid
return None
+5
View File
@@ -0,0 +1,5 @@
"""E-mail adapters."""
from app.adapters.email.smtp_client import SMTPEmailSender, SMTPEmailSenderError
__all__ = ["SMTPEmailSender", "SMTPEmailSenderError"]
+136
View File
@@ -0,0 +1,136 @@
"""SMTP e-mail adapter built on top of aiosmtplib."""
from email.message import EmailMessage
from typing import Protocol
import aiosmtplib
import structlog
log = structlog.get_logger(__name__)
class SMTPEmailSenderError(Exception):
"""Raised when the SMTP delivery fails."""
class SMTPSendFn(Protocol):
async def __call__(
self,
message: EmailMessage,
*,
hostname: str,
port: int,
username: str | None,
password: str | None,
use_tls: bool,
start_tls: bool,
timeout: float,
) -> object: ...
class SMTPEmailSender:
def __init__(
self,
*,
smtp_host: str,
smtp_port: int,
username: str,
password: str,
from_address: str,
use_tls: bool = True,
timeout_seconds: float = 10.0,
send: SMTPSendFn | None = None,
) -> None:
self._smtp_host = smtp_host
self._smtp_port = smtp_port
self._username = username
self._password = password
self._from_address = from_address
self._use_tls = use_tls
self._timeout_seconds = timeout_seconds
self._send = send or _default_send
async def send_email(
self,
*,
to: str,
subject: str,
body: str,
attachment_bytes: bytes | None = None,
attachment_filename: str | None = None,
) -> None:
message = self._build_message(
to=to,
subject=subject,
body=body,
attachment_bytes=attachment_bytes,
attachment_filename=attachment_filename,
)
try:
await self._send(
message,
hostname=self._smtp_host,
port=self._smtp_port,
username=self._username or None,
password=self._password or None,
use_tls=self._use_tls and self._smtp_port == 465,
start_tls=self._use_tls and self._smtp_port != 465,
timeout=self._timeout_seconds,
)
except Exception as exc:
log.warning(
"smtp_send_failed",
smtp_host=self._smtp_host,
smtp_port=self._smtp_port,
to=to,
error=str(exc),
)
raise SMTPEmailSenderError(
f"SMTP send to {to} failed: {exc}"
) from exc
def _build_message(
self,
*,
to: str,
subject: str,
body: str,
attachment_bytes: bytes | None = None,
attachment_filename: str | None = None,
) -> EmailMessage:
message = EmailMessage()
message["From"] = self._from_address
message["To"] = to
message["Subject"] = subject
message.set_content(body)
if attachment_bytes is not None and attachment_filename is not None:
message.add_attachment(
attachment_bytes,
maintype="application",
subtype="pdf",
filename=attachment_filename,
)
return message
async def _default_send(
message: EmailMessage,
*,
hostname: str,
port: int,
username: str | None,
password: str | None,
use_tls: bool,
start_tls: bool,
timeout: float,
) -> object:
return await aiosmtplib.send(
message,
hostname=hostname,
port=port,
username=username,
password=password,
use_tls=use_tls,
start_tls=start_tls,
timeout=timeout,
)
View File
+16
View File
@@ -0,0 +1,16 @@
"""PostgreSQL async engine and session factory management."""
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
from sqlalchemy.ext.asyncio import create_async_engine
from app.config import PostgresConfig
def create_postgres_engine(config: PostgresConfig) -> AsyncEngine:
return create_async_engine(config.dsn, pool_pre_ping=True)
def create_postgres_session_factory(
engine: AsyncEngine,
) -> async_sessionmaker[AsyncSession]:
return async_sessionmaker(engine, expire_on_commit=False)
+5
View File
@@ -0,0 +1,5 @@
"""TBank payment adapter package."""
from app.adapters.tbank.client import TBankAdapter
__all__ = ["TBankAdapter"]
+28
View File
@@ -0,0 +1,28 @@
"""TBank payment adapter errors."""
class TBankPaymentAdapterError(RuntimeError):
"""Raised when a TBank payment adapter call fails."""
class TBankPaymentRequestError(TBankPaymentAdapterError):
"""Raised when TBank rejects payment request data."""
def __init__(
self,
message: str,
*,
status_code: int | None = None,
error_code: str | None = None,
provider_message: str | None = None,
details: str | None = None,
) -> None:
super().__init__(message)
self.status_code = status_code
self.error_code = error_code
self.provider_message = provider_message
self.details = details
class TBankPaymentNotificationTokenError(TBankPaymentRequestError):
"""Raised when a TBank payment notification token is invalid."""
+257
View File
@@ -0,0 +1,257 @@
"""TBank payment adapter."""
import asyncio
from collections.abc import Awaitable, Callable
import hashlib
import hmac
from typing import Any
import httpx
from app.adapters.tbank.base import (
TBankPaymentAdapterError,
TBankPaymentNotificationTokenError,
TBankPaymentRequestError,
)
from app.config import TBankPaymentConfig
from app.schemas.payment import TBankPaymentNotification
class TBankAdapter:
def __init__(
self,
http_client: httpx.AsyncClient,
*,
init_url: str,
notification_url: str,
success_url: str,
terminal_key: str,
password: str,
timeout_seconds: float = 10.0,
retry_attempts: int = 2,
retry_backoff_seconds: float = 0.2,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
) -> None:
self._http_client = http_client
self._init_url = init_url
self._notification_url = notification_url
self._success_url = success_url
self._terminal_key = terminal_key
self._password = password
self._timeout_seconds = timeout_seconds
self._retry_attempts = retry_attempts
self._retry_backoff_seconds = retry_backoff_seconds
self._sleep = sleep
@classmethod
def from_config(
cls,
*,
http_client: httpx.AsyncClient,
config: TBankPaymentConfig,
) -> "TBankAdapter":
return cls(
http_client=http_client,
init_url=config.init_url,
notification_url=config.notification_url,
success_url=config.success_url,
terminal_key=config.auth.terminal_key,
password=config.auth.password,
timeout_seconds=config.timeout_seconds,
retry_attempts=config.retry_attempts,
retry_backoff_seconds=config.retry_backoff_seconds,
)
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str:
payload = self._build_payload(
order_uuid=order_uuid,
amount_kopecks=amount_kopecks,
)
for attempt in range(self._retry_attempts + 1):
try:
response = await self._http_client.post(
self._init_url,
json=payload,
headers={
"Accept": "application/json",
"Content-Type": "application/json",
},
timeout=self._timeout_seconds,
)
except (httpx.TimeoutException, httpx.TransportError) as exc:
if attempt < self._retry_attempts:
await self._sleep(self._retry_delay(attempt))
continue
raise TBankPaymentAdapterError(
"TBank payment init request failed after retry attempts."
) from exc
if self._should_retry(response.status_code):
if attempt < self._retry_attempts:
await self._sleep(self._retry_delay(attempt))
continue
raise TBankPaymentAdapterError(
"TBank payment init request failed with retriable status "
f"{response.status_code}."
)
if 400 <= response.status_code < 500:
raise _build_tbank_request_error(
"TBank payment init request was rejected.",
status_code=response.status_code,
payload=_response_json_or_none(response),
)
try:
response.raise_for_status()
raw_payload = response.json()
except (httpx.HTTPError, TypeError, ValueError) as exc:
raise TBankPaymentAdapterError(
"TBank payment init returned invalid payload."
) from exc
return self._map_payment_url(raw_payload)
raise TBankPaymentAdapterError("TBank payment init request failed unexpectedly.")
def verify_payment_notification(
self,
notification: TBankPaymentNotification,
) -> None:
payload = notification.model_dump(mode="python")
expected_token = _build_tbank_token(payload, password=self._password)
if not hmac.compare_digest(expected_token, notification.Token):
raise TBankPaymentNotificationTokenError(
"TBank payment notification token is invalid."
)
def _build_payload(self, *, order_uuid: str, amount_kopecks: int) -> dict[str, Any]:
if not order_uuid.strip():
raise TBankPaymentRequestError("TBank payment order id must be non-empty.")
if amount_kopecks <= 0:
raise TBankPaymentRequestError("TBank payment amount must be positive.")
payload: dict[str, Any] = {
"TerminalKey": self._terminal_key,
"Amount": amount_kopecks,
"OrderId": order_uuid,
"NotificationURL": self._notification_url,
"SuccessURL": f"{self._success_url}/{order_uuid}",
}
payload["Token"] = _build_tbank_token(payload, password=self._password)
return payload
def _retry_delay(self, attempt: int) -> float:
return self._retry_backoff_seconds * (attempt + 1)
@staticmethod
def _should_retry(status_code: int) -> bool:
return status_code == 429 or status_code >= 500
@staticmethod
def _map_payment_url(payload: object) -> str:
if not isinstance(payload, dict):
raise TBankPaymentAdapterError(
"TBank payment init payload must be a JSON object."
)
if payload.get("Success") is False:
raise _build_tbank_request_error(
"TBank payment init request was rejected.",
payload=payload,
)
payment_url = payload.get("PaymentURL")
if not isinstance(payment_url, str) or not payment_url.strip():
raise TBankPaymentAdapterError(
"TBank payment init response must include PaymentURL."
)
return payment_url.strip()
def _build_tbank_token(payload: dict[str, Any], *, password: str) -> str:
token_payload = {
key: value
for key, value in payload.items()
if key != "Token" and not isinstance(value, (dict, list))
}
token_payload["Password"] = password
token_source = "".join(
_stringify_token_value(token_payload[key]) for key in sorted(token_payload)
)
return hashlib.sha256(token_source.encode("utf-8")).hexdigest()
def _stringify_token_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def _response_json_or_none(response: httpx.Response) -> object | None:
try:
return response.json()
except (TypeError, ValueError):
return None
def _build_tbank_request_error(
message: str,
*,
status_code: int | None = None,
payload: object | None = None,
) -> TBankPaymentRequestError:
error_code = _payload_text_value(payload, "ErrorCode")
provider_message = _payload_text_value(payload, "Message")
details = _payload_text_value(payload, "Details")
return TBankPaymentRequestError(
_format_tbank_request_error_message(
message,
status_code=status_code,
error_code=error_code,
provider_message=provider_message,
details=details,
),
status_code=status_code,
error_code=error_code,
provider_message=provider_message,
details=details,
)
def _payload_text_value(payload: object | None, key: str) -> str | None:
if not isinstance(payload, dict):
return None
value = payload.get(key)
if value is None:
return None
text = str(value).strip()
if not text:
return None
return text
def _format_tbank_request_error_message(
message: str,
*,
status_code: int | None,
error_code: str | None,
provider_message: str | None,
details: str | None,
) -> str:
fields: list[str] = []
if status_code is not None:
fields.append(f"status_code={status_code}")
if error_code is not None:
fields.append(f"error_code={error_code}")
if provider_message is not None:
fields.append(f"message={provider_message}")
if details is not None:
fields.append(f"details={details}")
if not fields:
return message
return f"{message} {' '.join(fields)}"
+49
View File
@@ -48,6 +48,25 @@ class AdapterConfig(BaseModel):
cdek_cache_ttl_seconds: int = Field(default=900, gt=0) cdek_cache_ttl_seconds: int = Field(default=900, gt=0)
class TBankPaymentAuthConfig(BaseModel):
terminal_key: str = Field(..., min_length=1)
password: str = Field(..., min_length=1)
class TBankPaymentConfig(BaseModel):
init_url: str = Field(..., min_length=1)
notification_url: str = Field(..., min_length=1)
success_url: str = Field(..., min_length=1)
auth: TBankPaymentAuthConfig
timeout_seconds: float = Field(default=10.0, gt=0)
retry_attempts: int = Field(default=2, ge=0)
retry_backoff_seconds: float = Field(default=0.2, ge=0)
class PostgresConfig(BaseModel):
dsn: str = Field(..., min_length=1)
class DadataAddressSuggestionsConfig(BaseModel): class DadataAddressSuggestionsConfig(BaseModel):
url: str = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" url: str = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
api_key: str = "" api_key: str = ""
@@ -97,6 +116,26 @@ class ObservabilityConfig(BaseModel):
otlp_insecure: bool = True otlp_insecure: bool = True
class WaybillPollerConfig(BaseModel):
interval_seconds: float = Field(default=30.0, gt=0)
batch_size: int = Field(default=50, gt=0)
class EmailAdapterConfig(BaseModel):
smtp_host: str = Field(..., min_length=1)
smtp_port: int = Field(..., gt=0, le=65535)
username: str = ""
password: str = ""
from_address: str = Field(..., min_length=1)
use_tls: bool = True
timeout_seconds: float = Field(default=10.0, gt=0)
class WaybillEmailSenderConfig(BaseModel):
interval_seconds: float = Field(default=30.0, gt=0)
batch_size: int = Field(default=50, gt=0)
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
extra="ignore", extra="ignore",
@@ -107,10 +146,17 @@ class Settings(BaseSettings):
business_logic: BusinessLogicConfig = Field(default_factory=BusinessLogicConfig) business_logic: BusinessLogicConfig = Field(default_factory=BusinessLogicConfig)
repository: RepositoryConfig = Field(default_factory=RepositoryConfig) repository: RepositoryConfig = Field(default_factory=RepositoryConfig)
adapter: AdapterConfig = Field(default_factory=AdapterConfig) adapter: AdapterConfig = Field(default_factory=AdapterConfig)
tbank_payment: TBankPaymentConfig
postgres: PostgresConfig
address_suggestions: AddressSuggestionsConfig = Field( address_suggestions: AddressSuggestionsConfig = Field(
default_factory=AddressSuggestionsConfig default_factory=AddressSuggestionsConfig
) )
observability: ObservabilityConfig observability: ObservabilityConfig
waybill_poller: WaybillPollerConfig = Field(default_factory=WaybillPollerConfig)
email: EmailAdapterConfig
waybill_email_sender: WaybillEmailSenderConfig = Field(
default_factory=WaybillEmailSenderConfig
)
@classmethod @classmethod
def settings_customise_sources( def settings_customise_sources(
@@ -138,8 +184,11 @@ class _RequiredYamlSections(BaseModel):
business_logic: dict[str, Any] business_logic: dict[str, Any]
repository: dict[str, Any] repository: dict[str, Any]
adapter: dict[str, Any] adapter: dict[str, Any]
tbank_payment: dict[str, Any]
postgres: dict[str, Any]
address_suggestions: dict[str, Any] address_suggestions: dict[str, Any]
observability: dict[str, Any] observability: dict[str, Any]
email: dict[str, Any]
def _resolve_runtime_config_file() -> str: def _resolve_runtime_config_file() -> str:
+90 -13
View File
@@ -1,17 +1,26 @@
"""Delivery API controller skeleton.""" """Delivery API controller skeleton."""
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import PlainTextResponse
from app.adapters.postgres.engine import create_postgres_engine, create_postgres_session_factory
from app.adapters.address_suggestions.dadata import DadataAddressSuggestionProvider from app.adapters.address_suggestions.dadata import DadataAddressSuggestionProvider
from app.adapters.address_suggestions.tomtom import TomTomAddressSuggestionProvider from app.adapters.address_suggestions.tomtom import TomTomAddressSuggestionProvider
from app.adapters.address_suggestions.yandex_geosuggest import ( from app.adapters.address_suggestions.yandex_geosuggest import (
YandexGeosuggestAddressSuggestionProvider, YandexGeosuggestAddressSuggestionProvider,
) )
from app.adapters.delivery_providers.cdek import CDEKProvider from app.adapters.delivery_providers.cdek import CDEKProvider
from app.adapters.email import SMTPEmailSender
from app.adapters.tbank import TBankAdapter
from app.config import Settings from app.config import Settings
from app.controllers.http_client import build_controller_http_client from app.controllers.http_client import build_controller_http_client
from app.repositories.cache.redis_cache import PriceCache from app.repositories.cache.redis_cache import PriceCache
from app.schemas.order import OrderCreateRequest, OrderCreateResponse from app.repositories.order import OrderRepository
from app.schemas.payment import (
InitPaymentRequest,
InitPaymentResponse,
TBankPaymentNotification,
)
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
from app.schemas.response import AddressSuggestion, DeliveryPrice from app.schemas.response import AddressSuggestion, DeliveryPrice
from app.services.aggregator import ( from app.services.aggregator import (
@@ -20,7 +29,10 @@ from app.services.aggregator import (
AggregatorServiceError, AggregatorServiceError,
InvalidAddressSuggestRequestError, InvalidAddressSuggestRequestError,
InvalidDeliveryRequestError, InvalidDeliveryRequestError,
InvalidOrderCreateRequestError, InvalidInitPaymentRequestError,
InvalidTBankPaymentNotificationError,
InitPaymentUnavailableError,
TBankPaymentNotificationProcessingError,
UnsupportedAddressSuggestionCountryError, UnsupportedAddressSuggestionCountryError,
) )
@@ -38,6 +50,10 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
http_client=http_client, http_client=http_client,
adapter_config=settings.adapter, adapter_config=settings.adapter,
) )
payment_adapter = TBankAdapter.from_config(
http_client=http_client,
config=settings.tbank_payment,
)
dadata_provider = DadataAddressSuggestionProvider.from_config( dadata_provider = DadataAddressSuggestionProvider.from_config(
http_client=http_client, http_client=http_client,
config=settings.address_suggestions.dadata, config=settings.address_suggestions.dadata,
@@ -52,10 +68,26 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
) )
providers = (cdek_provider,) providers = (cdek_provider,)
cache = PriceCache.from_repository_config(settings.repository) cache = PriceCache.from_repository_config(settings.repository)
postgres_engine = create_postgres_engine(settings.postgres)
postgres_session_factory = create_postgres_session_factory(postgres_engine)
order_repository = OrderRepository(session_factory=postgres_session_factory)
email_sender = SMTPEmailSender(
smtp_host=settings.email.smtp_host,
smtp_port=settings.email.smtp_port,
username=settings.email.username,
password=settings.email.password,
from_address=settings.email.from_address,
use_tls=settings.email.use_tls,
timeout_seconds=settings.email.timeout_seconds,
)
service = AggregatorService( service = AggregatorService(
providers=providers, providers=providers,
cache=cache, cache=cache,
order_adapter=cdek_provider, payment_adapter=payment_adapter,
payment_price_validation_adapter=cdek_provider,
order_repository=order_repository,
order_registration_adapter=cdek_provider,
email_sender=email_sender,
address_suggestion_providers=( address_suggestion_providers=(
dadata_provider, dadata_provider,
yandex_geosuggest_provider, yandex_geosuggest_provider,
@@ -147,27 +179,72 @@ async def suggest_addresses(
@router.post( @router.post(
"/order", "/order",
response_model=OrderCreateResponse, response_model=InitPaymentResponse,
) )
async def create_delivery_order( async def init_payment(
order_request: OrderCreateRequest, payment_request: InitPaymentRequest,
service: AggregatorService = Depends(get_aggregator_service), service: AggregatorService = Depends(get_aggregator_service),
) -> OrderCreateResponse: ) -> InitPaymentResponse:
try: try:
return await service.create_order(order_request) return await service.init_payment(payment_request)
except InvalidOrderCreateRequestError as exc: except InvalidInitPaymentRequestError as exc:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail={ detail={
"code": "invalid_order_create_request", "code": "invalid_init_payment_request",
"message": "Order request contains invalid or unsupported CDEK data.", "message": "Payment request contains invalid or unsupported TBank data.",
},
) from exc
except InitPaymentUnavailableError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"code": "init_payment_unavailable",
"message": "Payment initialization is temporarily unavailable.",
}, },
) from exc ) from exc
except AggregatorServiceError as exc: except AggregatorServiceError as exc:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={ detail={
"code": "order_creation_unavailable", "code": "init_payment_unavailable",
"message": "CDEK order creation is temporarily unavailable.", "message": "Payment initialization is temporarily unavailable.",
}, },
) from exc ) from exc
@router.post(
"/tbank/notifications",
response_class=PlainTextResponse,
)
async def handle_tbank_payment_notification(
notification: TBankPaymentNotification,
service: AggregatorService = Depends(get_aggregator_service),
) -> PlainTextResponse:
try:
response_body = await service.handle_tbank_payment_notification(notification)
except InvalidTBankPaymentNotificationError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": "invalid_tbank_payment_notification",
"message": "TBank payment notification token is invalid.",
},
) from exc
except TBankPaymentNotificationProcessingError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"code": "tbank_payment_notification_processing_unavailable",
"message": "TBank payment notification processing is temporarily unavailable.",
},
) from exc
except AggregatorServiceError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"code": "tbank_payment_notification_processing_unavailable",
"message": "TBank payment notification processing is temporarily unavailable.",
},
) from exc
return PlainTextResponse(response_body)
+11
View File
@@ -0,0 +1,11 @@
"""Pure rules for CDEK order polling lifecycle."""
TERMINAL_ORDER_STATUSES: frozenset[str] = frozenset(
{"INVALID", "DELIVERED", "NOT_DELIVERED", "CANCELLED"}
)
def is_terminal_order_status(code: str | None) -> bool:
if code is None:
return False
return code in TERMINAL_ORDER_STATUSES
+19
View File
@@ -0,0 +1,19 @@
"""Pure rules for TBank payment notification handling."""
from enum import Enum
class TBankPaymentNotificationAction(str, Enum):
ACKNOWLEDGE_ONLY = "acknowledge_only"
REGISTER_CDEK_ORDER = "register_cdek_order"
def resolve_tbank_payment_notification_action(
*,
status: str,
success: bool,
error_code: str,
) -> TBankPaymentNotificationAction:
if status == "CONFIRMED" and success is True and error_code == "0":
return TBankPaymentNotificationAction.REGISTER_CDEK_ORDER
return TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
+56 -1
View File
@@ -12,6 +12,7 @@ DIMENSIONS_ROUND_SCALE = 1
MIN_WEIGHT_KG = Decimal("0.01") MIN_WEIGHT_KG = Decimal("0.01")
MIN_DIMENSION_CM = Decimal("0.1") MIN_DIMENSION_CM = Decimal("0.1")
INTEGER_PRICE_QUANTIZER = Decimal("1") INTEGER_PRICE_QUANTIZER = Decimal("1")
KOPECKS_IN_RUBLE = Decimal("100")
DOC_SERVICE_MARKERS = ("документ", "document") DOC_SERVICE_MARKERS = ("документ", "document")
_MISSING = object() _MISSING = object()
@@ -46,6 +47,8 @@ class ProviderPrice:
currency: str currency: str
delivery_days_min: int delivery_days_min: int
delivery_days_max: int delivery_days_max: int
tariff_code: int | None = None
bypass_parcel_type_filter: bool = False
def normalize_delivery_request( def normalize_delivery_request(
@@ -118,6 +121,48 @@ def filter_and_sort_prices(
) )
def calculate_expected_payment_amount_kopecks(
provider_price: object,
*,
price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
) -> int | None:
"""Return expected payment amount in kopecks for a RUB provider price."""
currency = _normalize_text(_get_optional_attr(provider_price, "currency")).upper()
if currency != "RUB":
return None
price = _try_to_decimal(_get_optional_attr(provider_price, "price"))
if price is None or not price.is_finite() or price <= 0:
return None
adjusted_price = _apply_price_multiplier_and_round(
price,
price_multiplier=_normalize_price_multiplier(price_multiplier),
)
if adjusted_price is None:
return None
return int(adjusted_price * KOPECKS_IN_RUBLE)
def is_init_payment_price_valid(
requested_amount_kopecks: object,
provider_price: object,
*,
price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
) -> bool:
expected_amount_kopecks = calculate_expected_payment_amount_kopecks(
provider_price,
price_multiplier=price_multiplier,
)
if expected_amount_kopecks is None:
return False
requested_amount = _try_to_int(requested_amount_kopecks)
return requested_amount == expected_amount_kopecks
def filter_prices_by_parcel_type( def filter_prices_by_parcel_type(
prices: Iterable[ProviderPrice], prices: Iterable[ProviderPrice],
*, *,
@@ -132,7 +177,8 @@ def filter_prices_by_parcel_type(
return [ return [
price price
for price in prices for price in prices
if _matches_parcel_type(price.service_name, normalized_parcel_type) if price.bypass_parcel_type_filter
or _matches_parcel_type(price.service_name, normalized_parcel_type)
] ]
@@ -179,9 +225,18 @@ def _normalize_price(
currency=currency, currency=currency,
delivery_days_min=min_days, delivery_days_min=min_days,
delivery_days_max=max_days, delivery_days_max=max_days,
tariff_code=_try_to_int(_get_optional_attr(candidate, "tariff_code")),
bypass_parcel_type_filter=_extract_bypass_parcel_type_filter(candidate),
) )
def _extract_bypass_parcel_type_filter(candidate: object) -> bool:
value = _get_optional_attr(candidate, "bypass_parcel_type_filter")
if value is _MISSING or value is None:
return False
return bool(value)
def _normalize_weight(weight: Decimal, scale: int) -> Decimal: def _normalize_weight(weight: Decimal, scale: int) -> Decimal:
rounded = _round_half_up(weight, scale) rounded = _round_half_up(weight, scale)
if rounded < MIN_WEIGHT_KG: if rounded < MIN_WEIGHT_KG:
+5
View File
@@ -0,0 +1,5 @@
"""Order repository exports."""
from app.repositories.order.repository import OrderData, OrderRepository
__all__ = ("OrderData", "OrderRepository")
+69
View File
@@ -0,0 +1,69 @@
"""SQLAlchemy models for order persistence."""
from datetime import datetime
from typing import Any
from uuid import UUID, uuid4
from sqlalchemy import BigInteger, DateTime, Integer, String, UniqueConstraint, Uuid, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.types import JSON
def _json_payload_type() -> JSON:
return JSON().with_variant(JSONB, "postgresql")
class Base(DeclarativeBase):
pass
class Order(Base):
__tablename__ = "orders"
__table_args__ = (
UniqueConstraint("order_uuid", name="uq_orders_order_uuid"),
)
id: Mapped[UUID] = mapped_column(
Uuid(as_uuid=True),
primary_key=True,
default=uuid4,
)
order_uuid: Mapped[str] = mapped_column(String(128), nullable=False)
payment_url: Mapped[str] = mapped_column(String(2048), nullable=False)
price: Mapped[int] = mapped_column(Integer, nullable=False)
tariff_code: Mapped[int] = mapped_column(Integer, nullable=False)
account_email: Mapped[str] = mapped_column(String(320), nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(
_json_payload_type(),
nullable=False,
)
payment_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
tbank_payment_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
cdek_order_uuid: Mapped[str | None] = mapped_column(String(128), nullable=True)
cdek_order_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
cdek_waybill_uuid: Mapped[str | None] = mapped_column(String(128), nullable=True)
cdek_waybill_url: Mapped[str | None] = mapped_column(String(2048), nullable=True)
cdek_polled_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
payment_email_sent_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
waybill_email_sent_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
+197
View File
@@ -0,0 +1,197 @@
"""PostgreSQL order repository."""
from collections.abc import Sequence
from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.domain.cdek_polling import TERMINAL_ORDER_STATUSES
from app.repositories.order.models import Order
@dataclass(frozen=True)
class OrderData:
order_uuid: str
payment_url: str
price: int
tariff_code: int
account_email: str
payload: dict[str, Any]
class OrderRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._session_factory = session_factory
def session(self) -> AbstractAsyncContextManager[AsyncSession]:
return self._session_factory.begin()
async def create_order(self, session: AsyncSession, order_data: OrderData) -> Order:
order = Order(
order_uuid=order_data.order_uuid,
payment_url=order_data.payment_url,
price=order_data.price,
tariff_code=order_data.tariff_code,
account_email=order_data.account_email,
payload=order_data.payload,
)
session.add(order)
await session.flush()
await session.refresh(order)
return order
async def get_order_by_order_uuid(
self,
session: AsyncSession,
order_uuid: str,
) -> Order | None:
result = await session.execute(
select(Order).where(Order.order_uuid == order_uuid)
)
return result.scalar_one_or_none()
async def mark_payment_status(
self,
session: AsyncSession,
order_uuid: str,
status: str,
payment_id: int,
) -> Order | None:
order = await self.get_order_by_order_uuid(session, order_uuid)
if order is None:
return None
order.payment_status = status
order.tbank_payment_id = payment_id
await session.flush()
return order
async def mark_cdek_order_registered(
self,
session: AsyncSession,
order_uuid: str,
cdek_order_uuid: str,
) -> Order | None:
order = await self.get_order_by_order_uuid(session, order_uuid)
if order is None:
return None
order.cdek_order_uuid = cdek_order_uuid
await session.flush()
return order
async def list_orders_pending_waybill(
self,
session: AsyncSession,
*,
limit: int,
) -> Sequence[Order]:
statement = (
select(Order)
.where(
Order.cdek_order_uuid.is_not(None),
Order.cdek_waybill_url.is_(None),
(
Order.cdek_order_status.is_(None)
| Order.cdek_order_status.not_in(TERMINAL_ORDER_STATUSES)
),
)
.order_by(Order.cdek_polled_at.asc().nulls_first())
.limit(limit)
.with_for_update(skip_locked=True)
)
result = await session.execute(statement)
return result.scalars().all()
async def record_order_poll(
self,
session: AsyncSession,
*,
order_uuid: str,
order_status: str | None,
waybill_uuid: str | None,
polled_at: datetime,
) -> Order | None:
order = await self.get_order_by_order_uuid(session, order_uuid)
if order is None:
return None
order.cdek_order_status = order_status
if waybill_uuid is not None and order.cdek_waybill_uuid is None:
order.cdek_waybill_uuid = waybill_uuid
order.cdek_polled_at = polled_at
await session.flush()
return order
async def record_waybill_poll(
self,
session: AsyncSession,
*,
order_uuid: str,
waybill_url: str | None,
polled_at: datetime,
) -> Order | None:
order = await self.get_order_by_order_uuid(session, order_uuid)
if order is None:
return None
if waybill_url is not None and order.cdek_waybill_url is None:
order.cdek_waybill_url = waybill_url
order.cdek_polled_at = polled_at
await session.flush()
return order
async def record_payment_email_sent(
self,
session: AsyncSession,
*,
order_uuid: str,
sent_at: datetime,
) -> Order | None:
order = await self.get_order_by_order_uuid(session, order_uuid)
if order is None:
return None
if order.payment_email_sent_at is None:
order.payment_email_sent_at = sent_at
await session.flush()
return order
async def list_orders_pending_waybill_email(
self,
session: AsyncSession,
*,
limit: int,
) -> Sequence[Order]:
statement = (
select(Order)
.where(
Order.cdek_waybill_url.is_not(None),
Order.waybill_email_sent_at.is_(None),
)
.order_by(Order.created_at.asc())
.limit(limit)
.with_for_update(skip_locked=True)
)
result = await session.execute(statement)
return result.scalars().all()
async def record_waybill_email_sent(
self,
session: AsyncSession,
*,
order_uuid: str,
sent_at: datetime,
) -> Order | None:
order = await self.get_order_by_order_uuid(session, order_uuid)
if order is None:
return None
if order.waybill_email_sent_at is None:
order.waybill_email_sent_at = sent_at
await session.flush()
return order
+1 -1
View File
@@ -21,7 +21,7 @@ def configure_logging(
foreign_pre_chain=list(_shared_processors()), foreign_pre_chain=list(_shared_processors()),
processors=[ processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta, structlog.stdlib.ProcessorFormatter.remove_processors_meta,
structlog.processors.JSONRenderer(sort_keys=True), structlog.processors.JSONRenderer(sort_keys=True, ensure_ascii=False),
], ],
) )
handler = logging.StreamHandler(resolved_stream) handler = logging.StreamHandler(resolved_stream)
-59
View File
@@ -1,59 +0,0 @@
"""Schemas for CDEK order registration."""
from typing import Literal
from pydantic import BaseModel, Field, model_validator
class OrderPhone(BaseModel):
number: str = Field(min_length=1)
class OrderParty(BaseModel):
name: str = Field(min_length=1)
email: str = Field(min_length=1)
phones: list[OrderPhone] = Field(min_length=1)
@model_validator(mode="before")
@classmethod
def reject_company_field(cls, value: object) -> object:
if isinstance(value, dict) and "company" in value:
raise ValueError("company is not allowed")
return value
class OrderLocation(BaseModel):
address: str = Field(min_length=1)
city: str = Field(min_length=1)
country_code: str = Field(min_length=2, max_length=2)
class OrderService(BaseModel):
code: str = Field(min_length=1)
parameter: str = Field(min_length=1)
class OrderPackage(BaseModel):
number: str = Field(min_length=1)
weight: int = Field(gt=0)
length: int = Field(gt=0)
width: int = Field(gt=0)
height: int = Field(gt=0)
comment: str | None = None
class OrderCreateRequest(BaseModel):
type: Literal[2]
tariff_code: Literal[535]
comment: str | None = None
sender: OrderParty
recipient: OrderParty
from_location: OrderLocation
to_location: OrderLocation
services: list[OrderService] = Field(min_length=1)
packages: list[OrderPackage] = Field(min_length=1)
class OrderCreateResponse(BaseModel):
provider: str = Field(min_length=1)
order_uuid: str = Field(min_length=1)
+145
View File
@@ -0,0 +1,145 @@
"""Schemas for delivery payment initialization."""
from datetime import datetime
from decimal import Decimal, InvalidOperation
from typing import Literal
from pydantic import (
BaseModel,
ConfigDict,
EmailStr,
Field,
field_validator,
model_validator,
)
from pydantic.alias_generators import to_camel
def _validate_positive_decimal_string(value: str) -> str:
try:
parsed = Decimal(value)
except (InvalidOperation, TypeError, ValueError) as exc:
raise ValueError("must be a positive decimal number") from exc
if not parsed.is_finite() or parsed <= 0:
raise ValueError("must be a positive decimal number")
return value
class _CamelModel(BaseModel):
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=False,
extra="forbid",
)
class Address(_CamelModel):
city_id: int = Field(gt=0, strict=True)
city: str = Field(min_length=1)
street: str = Field(min_length=1)
house: str = Field(min_length=1)
apartment: str | None = None
zip: str = Field(min_length=1)
comment: str | None = None
class Contact(_CamelModel):
full_name: str = Field(min_length=1)
email: str | None = None
phone: str = Field(min_length=1)
phone_ext: str | None = None
has_extra_phone: str | None = None
is_company: bool
company_name: str | None = None
inn: str | None = None
kpp: str | None = None
@model_validator(mode="after")
def _validate_company_requisites(self) -> "Contact":
if self.is_company:
missing = [
name
for name, value in (
("companyName", self.company_name),
("inn", self.inn),
("kpp", self.kpp),
)
if value is None or value == ""
]
if missing:
raise ValueError(
f"{', '.join(missing)} are required when isCompany is true"
)
return self
class Content(_CamelModel):
description: str | None = None
class SystemDataTariff(_CamelModel):
provider: str = Field(min_length=1)
service_name: str = Field(min_length=1)
price: int = Field(gt=0, strict=True, description="Payment amount in kopecks.")
delivery_days_min: int = Field(ge=0, strict=True)
delivery_days_max: int = Field(ge=0, strict=True)
tariff_code: int = Field(gt=0, strict=True)
class Dimensions(_CamelModel):
length: str = Field(min_length=1)
width: str = Field(min_length=1)
height: str = Field(min_length=1)
_validate_dimensions = field_validator("length", "width", "height")(
_validate_positive_decimal_string
)
class SystemData(_CamelModel):
tariff: SystemDataTariff
parcel_type: Literal["doc", "parcel"]
doc_packaging: Literal["envelope", "bag"] | None = None
weight: str = Field(min_length=1)
dimensions: Dimensions | None = None
_validate_weight = field_validator("weight")(_validate_positive_decimal_string)
@model_validator(mode="after")
def _validate_dimensions_for_parcel_type(self) -> "SystemData":
if self.parcel_type == "parcel" and self.dimensions is None:
raise ValueError("dimensions are required when parcelType is 'parcel'")
if self.parcel_type == "doc" and self.doc_packaging is None:
raise ValueError("docPackaging is required when parcelType is 'doc'")
return self
class InitPaymentRequest(_CamelModel):
sender_address: Address
sender_contact: Contact
receiver_address: Address
receiver_contact: Contact
content: Content
pickup_date: datetime
delivery_date: datetime | None = None
account_email: EmailStr
system_data: SystemData
agree_privacy: bool
agree_terms: bool
class InitPaymentResponse(BaseModel):
payment_url: str = Field(min_length=1)
class TBankPaymentNotification(BaseModel):
model_config = ConfigDict(extra="allow")
TerminalKey: str = Field(min_length=1)
OrderId: str = Field(min_length=1)
Success: bool
Status: str = Field(min_length=1)
PaymentId: int = Field(gt=0, strict=True)
ErrorCode: str = Field(min_length=1)
Amount: int
Token: str = Field(min_length=1)
+2
View File
@@ -12,6 +12,8 @@ class DeliveryPrice(BaseModel):
currency: str = Field(min_length=3, max_length=3) currency: str = Field(min_length=3, max_length=3)
delivery_days_min: int = Field(ge=0) delivery_days_min: int = Field(ge=0)
delivery_days_max: int = Field(ge=0) delivery_days_max: int = Field(ge=0)
tariff_code: int | None = Field(default=None, ge=0)
bypass_parcel_type_filter: bool = Field(default=False, exclude=True)
class AddressSuggestion(BaseModel): class AddressSuggestion(BaseModel):
+482 -29
View File
@@ -3,27 +3,57 @@
import asyncio import asyncio
import hashlib import hashlib
import json import json
from collections.abc import Iterable, Mapping, Sequence from collections.abc import Callable, Iterable, Mapping, Sequence
from contextlib import AbstractAsyncContextManager
from datetime import datetime, timezone
from decimal import Decimal from decimal import Decimal
from typing import Protocol from typing import Protocol
from uuid import uuid4
import structlog
from app.adapters.address_suggestions.base import ( from app.adapters.address_suggestions.base import (
AddressSuggestionClientError, AddressSuggestionClientError,
AddressSuggestionProvider, AddressSuggestionProvider,
AddressSuggestionRequestError, AddressSuggestionRequestError,
) )
from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError from app.adapters.delivery_providers.base import (
DeliveryProvider,
ProviderClientError,
ProviderRequestError,
)
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderRegistrationResult,
)
from app.adapters.tbank.base import (
TBankPaymentAdapterError,
TBankPaymentNotificationTokenError,
TBankPaymentRequestError,
)
from app.domain.payment_notifications import (
TBankPaymentNotificationAction,
resolve_tbank_payment_notification_action,
)
from app.domain.price import ( from app.domain.price import (
DEFAULT_PROVIDER_PRICE_MULTIPLIER, DEFAULT_PROVIDER_PRICE_MULTIPLIER,
DEFAULT_WEIGHT_ROUND_SCALE, DEFAULT_WEIGHT_ROUND_SCALE,
NormalizedDeliveryRequest, NormalizedDeliveryRequest,
calculate_expected_payment_amount_kopecks,
filter_and_sort_prices, filter_and_sort_prices,
is_init_payment_price_valid,
normalize_delivery_request, normalize_delivery_request,
) )
from app.schemas.order import OrderCreateRequest, OrderCreateResponse from app.repositories.order import OrderData
from app.schemas.payment import (
InitPaymentRequest,
InitPaymentResponse,
TBankPaymentNotification,
)
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
from app.schemas.response import AddressSuggestion, DeliveryPrice from app.schemas.response import AddressSuggestion, DeliveryPrice
logger = structlog.get_logger(__name__)
class AggregatorServiceError(RuntimeError): class AggregatorServiceError(RuntimeError):
"""Base exception for AggregatorService failures.""" """Base exception for AggregatorService failures."""
@@ -45,12 +75,20 @@ class AddressSuggestionsUnavailableError(AggregatorServiceError):
"""Raised when address suggestions cannot be completed.""" """Raised when address suggestions cannot be completed."""
class InvalidOrderCreateRequestError(AggregatorServiceError): class InvalidInitPaymentRequestError(AggregatorServiceError):
"""Raised when provider rejects order creation payload as invalid.""" """Raised when provider rejects payment init payload as invalid."""
class OrderCreationUnavailableError(AggregatorServiceError): class InitPaymentUnavailableError(AggregatorServiceError):
"""Raised when order creation cannot be completed.""" """Raised when payment initialization cannot be completed."""
class InvalidTBankPaymentNotificationError(AggregatorServiceError):
"""Raised when a TBank payment notification is invalid."""
class TBankPaymentNotificationProcessingError(AggregatorServiceError):
"""Raised when a TBank payment notification cannot be processed."""
class PriceCacheProtocol(Protocol): class PriceCacheProtocol(Protocol):
@@ -59,6 +97,65 @@ class PriceCacheProtocol(Protocol):
async def set(self, key: str, value: object, ttl: int | None = None) -> None: ... async def set(self, key: str, value: object, ttl: int | None = None) -> None: ...
class PaymentAdapterProtocol(Protocol):
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str: ...
def verify_payment_notification(
self,
notification: TBankPaymentNotification,
) -> None: ...
class PaymentPriceValidationAdapterProtocol(Protocol):
async def get_payment_price(
self,
request: InitPaymentRequest,
) -> DeliveryPrice | None: ...
class OrderRegistrationAdapterProtocol(Protocol):
async def register_order(
self, request: InitPaymentRequest, order_uuid: str
) -> CDEKOrderRegistrationResult: ...
class OrderRepositoryProtocol(Protocol):
def session(self) -> AbstractAsyncContextManager[object]: ...
async def create_order(self, session: object, order_data: OrderData) -> object: ...
async def get_order_by_order_uuid(
self,
session: object,
order_uuid: str,
) -> object | None: ...
async def mark_payment_status(
self,
session: object,
order_uuid: str,
status: str,
payment_id: int,
) -> object | None: ...
async def mark_cdek_order_registered(
self,
session: object,
order_uuid: str,
cdek_order_uuid: str,
) -> object | None: ...
class EmailSenderProtocol(Protocol):
async def send_email(
self,
*,
to: str,
subject: str,
body: str,
) -> None: ...
class FilterAndSortPricesFn(Protocol): class FilterAndSortPricesFn(Protocol):
def __call__( def __call__(
self, self,
@@ -69,31 +166,37 @@ class FilterAndSortPricesFn(Protocol):
) -> list[object]: ... ) -> list[object]: ...
class OrderRegistrationAdapterProtocol(Protocol):
async def register_order(
self, request: OrderCreateRequest
) -> OrderCreateResponse: ...
class AggregatorService: class AggregatorService:
def __init__( def __init__(
self, self,
providers: Sequence[DeliveryProvider], providers: Sequence[DeliveryProvider],
cache: PriceCacheProtocol | None = None, cache: PriceCacheProtocol | None = None,
order_adapter: OrderRegistrationAdapterProtocol | None = None, payment_adapter: PaymentAdapterProtocol | None = None,
payment_price_validation_adapter: (
PaymentPriceValidationAdapterProtocol | None
) = None,
order_repository: OrderRepositoryProtocol | None = None,
order_registration_adapter: OrderRegistrationAdapterProtocol | None = None,
email_sender: EmailSenderProtocol | None = None,
address_suggestion_providers: Sequence[AddressSuggestionProvider] = (), address_suggestion_providers: Sequence[AddressSuggestionProvider] = (),
address_suggestion_country_to_provider: Mapping[str, str] | None = None, address_suggestion_country_to_provider: Mapping[str, str] | None = None,
*, *,
weight_round_scale: int = DEFAULT_WEIGHT_ROUND_SCALE, weight_round_scale: int = DEFAULT_WEIGHT_ROUND_SCALE,
provider_price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER, provider_price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
filter_and_sort_prices_fn: FilterAndSortPricesFn = filter_and_sort_prices, filter_and_sort_prices_fn: FilterAndSortPricesFn = filter_and_sort_prices,
order_uuid_factory: Callable[[], str] = lambda: str(uuid4()),
) -> None: ) -> None:
self._providers = tuple(providers) self._providers = tuple(providers)
self._cache = cache self._cache = cache
self._order_adapter = order_adapter self._payment_adapter = payment_adapter
self._payment_price_validation_adapter = payment_price_validation_adapter
self._order_repository = order_repository
self._order_registration_adapter = order_registration_adapter
self._email_sender = email_sender
self._weight_round_scale = weight_round_scale self._weight_round_scale = weight_round_scale
self._provider_price_multiplier = provider_price_multiplier self._provider_price_multiplier = provider_price_multiplier
self._filter_and_sort_prices = filter_and_sort_prices_fn self._filter_and_sort_prices = filter_and_sort_prices_fn
self._order_uuid_factory = order_uuid_factory
self._address_suggestion_providers: dict[str, AddressSuggestionProvider] = { self._address_suggestion_providers: dict[str, AddressSuggestionProvider] = {
provider.name: provider for provider in address_suggestion_providers provider.name: provider for provider in address_suggestion_providers
} }
@@ -168,27 +271,377 @@ class AggregatorService:
return [self._coerce_address_suggestion(item) for item in suggestions] return [self._coerce_address_suggestion(item) for item in suggestions]
async def create_order(self, request: OrderCreateRequest) -> OrderCreateResponse: async def init_payment(self, request: InitPaymentRequest) -> InitPaymentResponse:
if self._order_adapter is None: if self._payment_adapter is None:
raise OrderCreationUnavailableError( raise InitPaymentUnavailableError("Payment adapter is not configured.")
"Order registration adapter is not configured."
order_uuid = self._order_uuid_factory()
await self._validate_init_payment_price(request, order_uuid)
try:
payment_url = await self._payment_adapter.create_payment_link(
order_uuid=order_uuid,
amount_kopecks=request.system_data.tariff.price,
)
except TBankPaymentRequestError as exc:
logger.exception(
"payment_init_rejected",
order_uuid=order_uuid,
provider_status_code=exc.status_code,
provider_error_code=exc.error_code,
provider_error_message=exc.provider_message,
provider_error_details=exc.details,
)
raise InvalidInitPaymentRequestError(
"Payment init request is invalid for the configured provider."
) from exc
except TBankPaymentAdapterError as exc:
raise InitPaymentUnavailableError(
"Payment initialization is temporarily unavailable."
) from exc
except Exception as exc:
raise InitPaymentUnavailableError(
"Payment initialization is temporarily unavailable."
) from exc
await self._persist_order(
request=request, order_uuid=order_uuid, payment_url=payment_url
)
return InitPaymentResponse(payment_url=payment_url)
async def _validate_init_payment_price(
self,
request: InitPaymentRequest,
order_uuid: str,
) -> None:
if self._payment_price_validation_adapter is None:
raise InitPaymentUnavailableError(
"Payment price validation adapter is not configured."
)
tariff_code = request.system_data.tariff.tariff_code
requested_price = request.system_data.tariff.price
try:
provider_price = await self._payment_price_validation_adapter.get_payment_price(
request
)
except ProviderRequestError as exc:
logger.warning(
"init_payment_price_validation_request_rejected",
order_uuid=order_uuid,
tariff_code=tariff_code,
requested_price_kopecks=requested_price,
error=str(exc),
)
raise InvalidInitPaymentRequestError(
"Payment init request is invalid for CDEK price validation."
) from exc
except ProviderClientError as exc:
logger.warning(
"init_payment_price_validation_unavailable",
order_uuid=order_uuid,
tariff_code=tariff_code,
requested_price_kopecks=requested_price,
error=str(exc),
)
raise InitPaymentUnavailableError(
"Payment price validation is temporarily unavailable."
) from exc
except Exception as exc:
logger.exception(
"init_payment_price_validation_unexpected_error",
order_uuid=order_uuid,
tariff_code=tariff_code,
requested_price_kopecks=requested_price,
)
raise InitPaymentUnavailableError(
"Payment price validation is temporarily unavailable."
) from exc
if provider_price is None:
logger.warning(
"init_payment_price_validation_tariff_not_found",
order_uuid=order_uuid,
tariff_code=tariff_code,
requested_price_kopecks=requested_price,
)
raise InvalidInitPaymentRequestError(
"CDEK did not return the requested tariff for payment validation."
)
expected_amount_kopecks = calculate_expected_payment_amount_kopecks(
provider_price,
price_multiplier=self._provider_price_multiplier,
)
if not is_init_payment_price_valid(
requested_price,
provider_price,
price_multiplier=self._provider_price_multiplier,
):
logger.warning(
"init_payment_price_mismatch",
order_uuid=order_uuid,
tariff_code=tariff_code,
requested_price_kopecks=requested_price,
expected_price_kopecks=expected_amount_kopecks,
provider_currency=getattr(provider_price, "currency", None),
provider_price=str(getattr(provider_price, "price", None)),
)
raise InvalidInitPaymentRequestError(
"Payment amount does not match CDEK validated delivery price."
)
async def handle_tbank_payment_notification(
self,
notification: TBankPaymentNotification,
) -> str:
if self._payment_adapter is None:
raise TBankPaymentNotificationProcessingError(
"Payment adapter is not configured."
) )
try: try:
created_order = await self._order_adapter.register_order(request) self._payment_adapter.verify_payment_notification(notification)
except ProviderRequestError as exc: except TBankPaymentNotificationTokenError as exc:
raise InvalidOrderCreateRequestError( raise InvalidTBankPaymentNotificationError(
"Order request is invalid for the configured provider." "TBank payment notification token is invalid."
) from exc ) from exc
except Exception as exc: except TBankPaymentAdapterError as exc:
raise OrderCreationUnavailableError( raise TBankPaymentNotificationProcessingError(
"Order creation is temporarily unavailable." "TBank payment notification verification failed."
) from exc ) from exc
return OrderCreateResponse.model_validate( action = resolve_tbank_payment_notification_action(
created_order, status=notification.Status,
from_attributes=True, success=notification.Success,
error_code=notification.ErrorCode,
) )
order = await self._load_order_and_mark_payment_status(notification)
if action is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY:
return "OK"
existing_cdek_order_uuid = getattr(order, "cdek_order_uuid", None)
if existing_cdek_order_uuid:
return "OK"
registration_result = await self._register_cdek_order(
order, order_uuid=notification.OrderId
)
await self._save_cdek_order_uuid(
order_uuid=notification.OrderId,
cdek_order_uuid=registration_result.order_uuid,
)
await self._send_payment_confirmation_email(
order, order_uuid=notification.OrderId
)
return "OK"
async def _send_payment_confirmation_email(
self,
order: object,
*,
order_uuid: str,
) -> None:
if self._email_sender is None or self._order_repository is None:
return
if getattr(order, "payment_email_sent_at", None) is not None:
return
account_email = getattr(order, "account_email", None)
if account_email is None:
return
try:
await self._email_sender.send_email(
to=account_email,
subject=f"Оплата по заказу {order_uuid} принята",
body=(
f"Здравствуйте!\n\n"
f"Оплата по заказу {order_uuid} успешно принята.\n"
f"Накладная будет отправлена на этот адрес в ближайшее время.\n\n"
f"Спасибо за заказ!"
),
)
async with self._order_repository.session() as session:
await self._order_repository.record_payment_email_sent(
session,
order_uuid=order_uuid,
sent_at=datetime.now(timezone.utc),
)
logger.info(
"payment_confirmation_email_sent",
order_uuid=order_uuid,
account_email=account_email,
)
except Exception:
logger.warning(
"payment_confirmation_email_failed",
order_uuid=order_uuid,
account_email=account_email,
exc_info=True,
)
async def _load_order_and_mark_payment_status(
self,
notification: TBankPaymentNotification,
) -> object:
if self._order_repository is None:
raise TBankPaymentNotificationProcessingError(
"Order repository is not configured."
)
try:
async with self._order_repository.session() as session:
order = await self._order_repository.get_order_by_order_uuid(
session,
notification.OrderId,
)
if order is None:
logger.warning(
"tbank_payment_notification_order_not_found",
order_uuid=notification.OrderId,
)
raise TBankPaymentNotificationProcessingError(
"Order was not found for TBank payment notification."
)
updated_order = await self._order_repository.mark_payment_status(
session,
notification.OrderId,
notification.Status,
notification.PaymentId,
)
if updated_order is None:
logger.warning(
"tbank_payment_notification_order_not_found",
order_uuid=notification.OrderId,
)
raise TBankPaymentNotificationProcessingError(
"Order was not found for TBank payment notification."
)
return order
except TBankPaymentNotificationProcessingError:
raise
except Exception as exc:
logger.exception(
"tbank_payment_notification_order_update_failed",
order_uuid=notification.OrderId,
payment_status=notification.Status,
tbank_payment_id=notification.PaymentId,
)
raise TBankPaymentNotificationProcessingError(
"TBank payment notification order update failed."
) from exc
async def _register_cdek_order(
self, order: object, *, order_uuid: str
) -> CDEKOrderRegistrationResult:
if self._order_registration_adapter is None:
raise TBankPaymentNotificationProcessingError(
"CDEK order registration adapter is not configured."
)
try:
request = self._to_init_payment_request_from_order(order)
return await self._order_registration_adapter.register_order(
request, order_uuid
)
except Exception as exc:
logger.exception(
"cdek_order_registration_failed",
order_uuid=getattr(order, "order_uuid", None),
)
raise TBankPaymentNotificationProcessingError(
"CDEK order registration failed."
) from exc
async def _save_cdek_order_uuid(
self,
*,
order_uuid: str,
cdek_order_uuid: str,
) -> None:
if self._order_repository is None:
raise TBankPaymentNotificationProcessingError(
"Order repository is not configured."
)
try:
async with self._order_repository.session() as session:
order = await self._order_repository.mark_cdek_order_registered(
session,
order_uuid,
cdek_order_uuid,
)
if order is None:
logger.warning(
"cdek_order_uuid_order_not_found",
order_uuid=order_uuid,
cdek_order_uuid=cdek_order_uuid,
)
raise TBankPaymentNotificationProcessingError(
"Order was not found while saving CDEK order UUID."
)
except TBankPaymentNotificationProcessingError:
raise
except Exception as exc:
logger.exception(
"cdek_order_uuid_persistence_failed",
order_uuid=order_uuid,
cdek_order_uuid=cdek_order_uuid,
)
raise TBankPaymentNotificationProcessingError(
"CDEK order UUID persistence failed."
) from exc
async def _persist_order(
self,
*,
request: InitPaymentRequest,
order_uuid: str,
payment_url: str,
) -> None:
if self._order_repository is None:
return
try:
async with self._order_repository.session() as session:
await self._order_repository.create_order(
session,
self._to_order_data(
request=request,
order_uuid=order_uuid,
payment_url=payment_url,
),
)
except Exception:
logger.exception(
"order_persistence_failed",
order_uuid=order_uuid,
)
@staticmethod
def _to_order_data(
*,
request: InitPaymentRequest,
order_uuid: str,
payment_url: str,
) -> OrderData:
return OrderData(
order_uuid=order_uuid,
payment_url=payment_url,
price=request.system_data.tariff.price,
tariff_code=request.system_data.tariff.tariff_code,
account_email=request.account_email,
payload=request.model_dump(mode="json", by_alias=True),
)
@staticmethod
def _to_init_payment_request_from_order(order: object) -> InitPaymentRequest:
payload = getattr(order, "payload")
return InitPaymentRequest.model_validate(payload)
async def _get_provider_prices( async def _get_provider_prices(
self, self,
+167
View File
@@ -0,0 +1,167 @@
"""Background service that e-mails CDEK waybill PDFs to customers."""
import asyncio
from collections.abc import Callable, Sequence
from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Protocol
import structlog
logger = structlog.get_logger(__name__)
_EMAIL_SUBJECT_TEMPLATE = "Накладная по заказу {order_uuid}"
_EMAIL_BODY_TEMPLATE = (
"Здравствуйте!\n\n"
"По вашему заказу {order_uuid} сформирована транспортная накладная CDEK.\n"
"PDF-файл накладной приложен к этому письму.\n"
"Также накладная доступна по ссылке: {waybill_url}\n"
)
_ATTACHMENT_FILENAME_TEMPLATE = "waybill_{order_uuid}.pdf"
class WaybillPDFDownloaderProtocol(Protocol):
async def download_waybill_pdf(self, url: str) -> bytes: ...
class EmailSenderProtocol(Protocol):
async def send_email(
self,
*,
to: str,
subject: str,
body: str,
attachment_bytes: bytes,
attachment_filename: str,
) -> None: ...
class OrderRecord(Protocol):
order_uuid: str
account_email: str
cdek_waybill_url: str | None
class WaybillEmailSenderRepositoryProtocol(Protocol):
def session(self) -> AbstractAsyncContextManager[object]: ...
async def list_orders_pending_waybill_email(
self, session: object, *, limit: int
) -> Sequence[OrderRecord]: ...
async def record_waybill_email_sent(
self,
session: object,
*,
order_uuid: str,
sent_at: datetime,
) -> object | None: ...
@dataclass(frozen=True)
class SendBatchSummary:
processed: int
succeeded: int
failed: int
class WaybillEmailSenderService:
def __init__(
self,
*,
order_repository: WaybillEmailSenderRepositoryProtocol,
waybill_downloader: WaybillPDFDownloaderProtocol,
email_sender: EmailSenderProtocol,
batch_size: int,
datetime_now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
) -> None:
self._repository = order_repository
self._waybill_downloader = waybill_downloader
self._email_sender = email_sender
self._batch_size = batch_size
self._datetime_now = datetime_now
async def poll_once(self) -> SendBatchSummary:
async with self._repository.session() as session:
orders = await self._repository.list_orders_pending_waybill_email(
session, limit=self._batch_size
)
succeeded = 0
failed = 0
for order in orders:
try:
await self._handle_order(session, order)
succeeded += 1
except Exception:
failed += 1
logger.exception(
"waybill_email_order_failed",
order_uuid=order.order_uuid,
account_email=order.account_email,
)
return SendBatchSummary(
processed=len(orders),
succeeded=succeeded,
failed=failed,
)
async def run_forever(
self,
*,
interval_seconds: float,
stop_event: asyncio.Event,
) -> None:
while not stop_event.is_set():
try:
summary = await self.poll_once()
logger.info(
"waybill_email_tick",
processed=summary.processed,
succeeded=summary.succeeded,
failed=summary.failed,
)
except Exception:
logger.exception("waybill_email_tick_failed")
try:
await asyncio.wait_for(
stop_event.wait(), timeout=interval_seconds
)
except asyncio.TimeoutError:
continue
async def _handle_order(self, session: object, order: OrderRecord) -> None:
waybill_url = order.cdek_waybill_url
if waybill_url is None:
return
pdf_bytes = await self._waybill_downloader.download_waybill_pdf(waybill_url)
subject = _EMAIL_SUBJECT_TEMPLATE.format(order_uuid=order.order_uuid)
body = _EMAIL_BODY_TEMPLATE.format(
order_uuid=order.order_uuid,
waybill_url=waybill_url,
)
filename = _ATTACHMENT_FILENAME_TEMPLATE.format(order_uuid=order.order_uuid)
await self._email_sender.send_email(
to=order.account_email,
subject=subject,
body=body,
attachment_bytes=pdf_bytes,
attachment_filename=filename,
)
sent_at = self._datetime_now()
await self._repository.record_waybill_email_sent(
session,
order_uuid=order.order_uuid,
sent_at=sent_at,
)
logger.info(
"waybill_email_sent",
order_uuid=order.order_uuid,
account_email=order.account_email,
sent_at=sent_at,
)
+169
View File
@@ -0,0 +1,169 @@
"""Background service that polls CDEK for waybill updates."""
import asyncio
from collections.abc import Callable, Sequence
from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Protocol
import structlog
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderInfo,
CDEKWaybillInfo,
)
logger = structlog.get_logger(__name__)
class CDEKOrderInfoAdapterProtocol(Protocol):
async def get_order(self, cdek_order_uuid: str) -> CDEKOrderInfo: ...
class CDEKWaybillInfoAdapterProtocol(Protocol):
async def get_waybill(self, cdek_waybill_uuid: str) -> CDEKWaybillInfo: ...
class OrderRecord(Protocol):
order_uuid: str
cdek_order_uuid: str | None
cdek_waybill_uuid: str | None
class WaybillPollerRepositoryProtocol(Protocol):
def session(self) -> AbstractAsyncContextManager[object]: ...
async def list_orders_pending_waybill(
self, session: object, *, limit: int
) -> Sequence[OrderRecord]: ...
async def record_order_poll(
self,
session: object,
*,
order_uuid: str,
order_status: str | None,
waybill_uuid: str | None,
polled_at: datetime,
) -> object | None: ...
async def record_waybill_poll(
self,
session: object,
*,
order_uuid: str,
waybill_url: str | None,
polled_at: datetime,
) -> object | None: ...
@dataclass(frozen=True)
class PollBatchSummary:
processed: int
succeeded: int
failed: int
class WaybillPollerService:
def __init__(
self,
*,
order_repository: WaybillPollerRepositoryProtocol,
order_info_adapter: CDEKOrderInfoAdapterProtocol,
waybill_info_adapter: CDEKWaybillInfoAdapterProtocol,
batch_size: int,
datetime_now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
) -> None:
self._repository = order_repository
self._order_info_adapter = order_info_adapter
self._waybill_info_adapter = waybill_info_adapter
self._batch_size = batch_size
self._datetime_now = datetime_now
async def poll_once(self) -> PollBatchSummary:
async with self._repository.session() as session:
orders = await self._repository.list_orders_pending_waybill(
session, limit=self._batch_size
)
succeeded = 0
failed = 0
for order in orders:
try:
await self._handle_order(session, order)
succeeded += 1
except Exception:
failed += 1
logger.exception(
"waybill_poll_order_failed",
order_uuid=order.order_uuid,
cdek_order_uuid=order.cdek_order_uuid,
cdek_waybill_uuid=order.cdek_waybill_uuid,
)
return PollBatchSummary(
processed=len(orders),
succeeded=succeeded,
failed=failed,
)
async def run_forever(
self,
*,
interval_seconds: float,
stop_event: asyncio.Event,
) -> None:
while not stop_event.is_set():
try:
summary = await self.poll_once()
logger.info(
"waybill_poll_tick",
processed=summary.processed,
succeeded=summary.succeeded,
failed=summary.failed,
)
except Exception:
logger.exception("waybill_poll_tick_failed")
try:
await asyncio.wait_for(
stop_event.wait(), timeout=interval_seconds
)
except asyncio.TimeoutError:
continue
async def _handle_order(self, session: object, order: OrderRecord) -> None:
polled_at = self._datetime_now()
if order.cdek_waybill_uuid is None:
cdek_order_uuid = order.cdek_order_uuid
if cdek_order_uuid is None:
return
info = await self._order_info_adapter.get_order(cdek_order_uuid)
await self._repository.record_order_poll(
session,
order_uuid=order.order_uuid,
order_status=info.status_code,
waybill_uuid=info.waybill_uuid,
polled_at=polled_at,
)
logger.info(
"waybill_poll_order_result",
order_uuid=order.order_uuid,
cdek_order_uuid=cdek_order_uuid,
cdek_order_status=info.status_code,
cdek_waybill_uuid=info.waybill_uuid,
)
return
waybill = await self._waybill_info_adapter.get_waybill(order.cdek_waybill_uuid)
await self._repository.record_waybill_poll(
session,
order_uuid=order.order_uuid,
waybill_url=waybill.url,
polled_at=polled_at,
)
logger.info(
"waybill_poll_waybill_result",
order_uuid=order.order_uuid,
cdek_waybill_uuid=order.cdek_waybill_uuid,
cdek_waybill_url=waybill.url,
)
View File
+91
View File
@@ -0,0 +1,91 @@
"""Background worker that e-mails CDEK waybill PDFs."""
import asyncio
import signal
import httpx
import structlog
from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient
from app.adapters.delivery_providers.cdek.client import CDEKClient
from app.adapters.email import SMTPEmailSender
from app.adapters.postgres.engine import (
create_postgres_engine,
create_postgres_session_factory,
)
from app.config import Settings, get_settings
from app.repositories.order import OrderRepository
from app.runtime.logging import configure_logging
from app.services.waybill_email_sender import WaybillEmailSenderService
logger = structlog.get_logger(__name__)
async def _run(settings: Settings, stop_event: asyncio.Event) -> None:
http_client = httpx.AsyncClient(timeout=settings.adapter.cdek_timeout_seconds)
auth_client = CDEKAuthClient(
http_client=http_client,
base_url=settings.adapter.cdek_base_url,
client_id=settings.adapter.cdek_client_id,
client_secret=settings.adapter.cdek_client_secret,
timeout_seconds=settings.adapter.cdek_timeout_seconds,
)
cdek_client = CDEKClient(
http_client=http_client,
auth_client=auth_client,
base_url=settings.adapter.cdek_base_url,
timeout_seconds=settings.adapter.cdek_timeout_seconds,
retry_attempts=settings.adapter.cdek_retry_attempts,
retry_backoff_seconds=settings.adapter.cdek_retry_backoff_seconds,
)
email_sender = SMTPEmailSender(
smtp_host=settings.email.smtp_host,
smtp_port=settings.email.smtp_port,
username=settings.email.username,
password=settings.email.password,
from_address=settings.email.from_address,
use_tls=settings.email.use_tls,
timeout_seconds=settings.email.timeout_seconds,
)
engine = create_postgres_engine(settings.postgres)
session_factory = create_postgres_session_factory(engine)
repository = OrderRepository(session_factory=session_factory)
service = WaybillEmailSenderService(
order_repository=repository,
waybill_downloader=cdek_client,
email_sender=email_sender,
batch_size=settings.waybill_email_sender.batch_size,
)
logger.info(
"waybill_email_sender_started",
interval_seconds=settings.waybill_email_sender.interval_seconds,
batch_size=settings.waybill_email_sender.batch_size,
)
try:
await service.run_forever(
interval_seconds=settings.waybill_email_sender.interval_seconds,
stop_event=stop_event,
)
finally:
await http_client.aclose()
await engine.dispose()
logger.info("waybill_email_sender_stopped")
async def main() -> None:
configure_logging()
settings = get_settings()
stop_event = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
try:
loop.add_signal_handler(sig, stop_event.set)
except NotImplementedError:
pass
await _run(settings, stop_event)
if __name__ == "__main__":
asyncio.run(main())
+81
View File
@@ -0,0 +1,81 @@
"""Background worker that polls CDEK for waybill updates."""
import asyncio
import signal
import httpx
import structlog
from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient
from app.adapters.delivery_providers.cdek.client import CDEKClient
from app.adapters.postgres.engine import (
create_postgres_engine,
create_postgres_session_factory,
)
from app.config import Settings, get_settings
from app.repositories.order import OrderRepository
from app.runtime.logging import configure_logging
from app.services.waybill_poller import WaybillPollerService
logger = structlog.get_logger(__name__)
async def _run(settings: Settings, stop_event: asyncio.Event) -> None:
http_client = httpx.AsyncClient(timeout=settings.adapter.cdek_timeout_seconds)
auth_client = CDEKAuthClient(
http_client=http_client,
base_url=settings.adapter.cdek_base_url,
client_id=settings.adapter.cdek_client_id,
client_secret=settings.adapter.cdek_client_secret,
timeout_seconds=settings.adapter.cdek_timeout_seconds,
)
cdek_client = CDEKClient(
http_client=http_client,
auth_client=auth_client,
base_url=settings.adapter.cdek_base_url,
timeout_seconds=settings.adapter.cdek_timeout_seconds,
retry_attempts=settings.adapter.cdek_retry_attempts,
retry_backoff_seconds=settings.adapter.cdek_retry_backoff_seconds,
)
engine = create_postgres_engine(settings.postgres)
session_factory = create_postgres_session_factory(engine)
repository = OrderRepository(session_factory=session_factory)
service = WaybillPollerService(
order_repository=repository,
order_info_adapter=cdek_client,
waybill_info_adapter=cdek_client,
batch_size=settings.waybill_poller.batch_size,
)
logger.info(
"waybill_poller_started",
interval_seconds=settings.waybill_poller.interval_seconds,
batch_size=settings.waybill_poller.batch_size,
)
try:
await service.run_forever(
interval_seconds=settings.waybill_poller.interval_seconds,
stop_event=stop_event,
)
finally:
await http_client.aclose()
await engine.dispose()
logger.info("waybill_poller_stopped")
async def main() -> None:
configure_logging()
settings = get_settings()
stop_event = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
try:
loop.add_signal_handler(sig, stop_event.set)
except NotImplementedError:
pass
await _run(settings, stop_event)
if __name__ == "__main__":
asyncio.run(main())
-51
View File
@@ -1,51 +0,0 @@
controller:
api_prefix: "/api/v1"
request_id_header: "X-Request-ID"
service:
provider_timeout_seconds: 10.0
max_parallel_providers: 8
business_logic:
weight_round_scale: 2
provider_price_multiplier: 1.0
repository:
redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900
adapter:
cdek_base_url: "https://api.cdek.ru/v2"
cdek_client_id: ""
cdek_client_secret: ""
cdek_retry_attempts: 2
cdek_retry_backoff_seconds: 0.2
cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900
address_suggestions:
country_to_provider:
RU: "dadata"
BY: "dadata"
KZ: "dadata"
AM: "yandex_geosuggest"
AZ: "yandex_geosuggest"
KG: "yandex_geosuggest"
MD: "yandex_geosuggest"
TJ: "yandex_geosuggest"
TM: "yandex_geosuggest"
UZ: "yandex_geosuggest"
dadata:
url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
api_key: ""
timeout_seconds: 10.0
yandex_geosuggest:
url: "https://suggest-maps.yandex.ru/v1/suggest"
api_key: ""
timeout_seconds: 10.0
observability:
enabled: false
service_name: "g2s-aggregator"
otlp_endpoint: "http://localhost:4317"
otlp_insecure: true
+116
View File
@@ -0,0 +1,116 @@
controller:
api_prefix: "/api/v1"
request_id_header: "X-Request-ID"
service:
provider_timeout_seconds: 10.0
max_parallel_providers: 8
business_logic:
weight_round_scale: 2
provider_price_multiplier: 1.0
repository:
redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900
adapter:
cdek_base_url: "https://api.edu.cdek.ru/v2"
cdek_client_id: "${CDEK_CLIENT_ID}"
cdek_client_secret: "${CDEK_CLIENT_SECRET}"
cdek_retry_attempts: 2
cdek_retry_backoff_seconds: 0.2
cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
notification_url: "https://aggregator.get2send.com/api/v1/delivery/tbank/notifications"
success_url: "https://aggregator.get2send.com/checkout/success"
auth:
terminal_key: "${TBANK_TERMINAL_KEY}"
password: "${TBANK_PASSWORD}"
timeout_seconds: 10.0
retry_attempts: 2
retry_backoff_seconds: 0.2
postgres:
dsn: "postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/g2s_aggregator"
address_suggestions:
country_to_provider:
RU: "dadata"
BY: "dadata"
KZ: "dadata"
AM: "yandex_geosuggest"
AZ: "yandex_geosuggest"
KG: "yandex_geosuggest"
MD: "yandex_geosuggest"
TJ: "yandex_geosuggest"
TM: "yandex_geosuggest"
UZ: "yandex_geosuggest"
AL: "tomtom"
AT: "tomtom"
BE: "tomtom"
BG: "tomtom"
CH: "tomtom"
CZ: "tomtom"
DE: "tomtom"
DK: "tomtom"
EE: "tomtom"
ES: "tomtom"
FI: "tomtom"
FR: "tomtom"
GB: "tomtom"
GR: "tomtom"
HR: "tomtom"
HU: "tomtom"
IE: "tomtom"
IT: "tomtom"
LT: "tomtom"
LV: "tomtom"
NL: "tomtom"
"NO": "tomtom"
PL: "tomtom"
PT: "tomtom"
RO: "tomtom"
RS: "tomtom"
SE: "tomtom"
SI: "tomtom"
SK: "tomtom"
UA: "tomtom"
dadata:
url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
api_key: "${DADATA_API_KEY}"
timeout_seconds: 7.5
yandex_geosuggest:
url: "https://suggest-maps.yandex.ru/v1/suggest"
api_key: "${YANDEX_GEOSUGGEST_API_KEY}"
timeout_seconds: 7.5
tomtom:
url: "https://api.tomtom.com/search/2/search"
api_key: "${TOMTOM_API_KEY}"
timeout_seconds: 7.5
waybill_poller:
interval_seconds: 10
batch_size: 50
email:
smtp_host: "smtp.yandex.ru"
smtp_port: 465
username: "${SMTP_USERNAME}"
password: "${SMTP_PASSWORD}"
from_address: "${SMTP_FROM_ADDRESS}"
use_tls: true
timeout_seconds: 10.0
waybill_email_sender:
interval_seconds: 10
batch_size: 50
observability:
enabled: true
service_name: "g2s-aggregator"
otlp_endpoint: "${OTLP_ENDPOINT}"
otlp_insecure: true
+19
View File
@@ -23,6 +23,20 @@ adapter:
cdek_timeout_seconds: 10.0 cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900 cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
success_url: "https://merchant.test/payment/success"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
timeout_seconds: 10.0
retry_attempts: 2
retry_backoff_seconds: 0.2
postgres:
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/g2s_aggregator_test"
address_suggestions: address_suggestions:
country_to_provider: country_to_provider:
RU: "dadata" RU: "dadata"
@@ -83,3 +97,8 @@ observability:
service_name: "g2s-aggregator-test" service_name: "g2s-aggregator-test"
otlp_endpoint: "http://localhost:4317" otlp_endpoint: "http://localhost:4317"
otlp_insecure: true otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
+91
View File
@@ -0,0 +1,91 @@
services:
app:
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9
container_name: g2s-aggregator
ports:
- "8003:8000"
depends_on:
redis:
condition: service_started
postgres:
condition: service_healthy
migrations:
condition: service_completed_successfully
volumes:
- ./config.yaml:/config.yaml
restart: unless-stopped
logging:
driver: "json-file"
options:
tag: "g2s-aggregator"
redis:
image: redis:7-alpine
container_name: redis
command: ["redis-server", "--save", "", "--appendonly", "no"]
ports:
- "6379:6379"
restart: unless-stopped
postgres:
image: postgres:16-alpine
container_name: postgres
environment:
POSTGRES_DB: g2s_aggregator
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5432:5432"
volumes:
- postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d g2s_aggregator"]
interval: 5s
timeout: 3s
retries: 10
migrations:
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9
container_name: g2s-aggregator-migrations
depends_on:
postgres:
condition: service_healthy
volumes:
- ./config.yaml:/config.yaml
command: ["poetry", "run", "alembic", "upgrade", "head"]
restart: "no"
waybill-poller:
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9
container_name: g2s-aggregator-waybill-poller
depends_on:
postgres:
condition: service_healthy
migrations:
condition: service_completed_successfully
volumes:
- ./config.yaml:/config.yaml
command: ["poetry", "run", "python", "-m", "app.workers.waybill_poller"]
restart: unless-stopped
waybill-email-sender:
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9
container_name: g2s-aggregator-waybill-email-sender
depends_on:
postgres:
condition: service_healthy
migrations:
condition: service_completed_successfully
volumes:
- ./config.yaml:/config.yaml
command:
["poetry", "run", "python", "-m", "app.workers.waybill_email_sender"]
restart: unless-stopped
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
container_name: otel-collector
user: "0"
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
restart: unless-stopped
command: ["--config=/etc/otel-collector-config.yaml"]
volumes:
postgres:
-16
View File
@@ -1,16 +0,0 @@
services:
app:
image: yusupal1ev/g2s-aggregator:0.0.0
container_name: g2s-aggregator
ports:
- "8000:8000"
depends_on:
- redis
volumes:
- ./config.yaml:/config.yaml
redis:
image: redis:7-alpine
container_name: redis
command: ["redis-server", "--save", "", "--appendonly", "no"]
ports:
- "6379:6379"
+88 -43
View File
@@ -12,66 +12,111 @@ grant_type=client_credentials&client_id={{client_id}}&client_secret={{client_sec
GET {{base_url}}/v2/orders?cdek_number=10240410458 GET {{base_url}}/v2/orders?cdek_number=10240410458
Authorization: Bearer {{auth_token}} Authorization: Bearer {{auth_token}}
### 3. Регистрация заказа (тип "доставка", до двери) ### 3. Инициализация оплаты доставки
POST {{base_url}}/v2/orders POST http://localhost:8000/api/v1/delivery/order
Authorization: Bearer {{auth_token}}
Content-Type: application/json Content-Type: application/json
{ {
"type": 2, "senderAddress": {
"tariff_code": 535, "cityId": 1,
"comment": "Тестовый заказ", "city": "Дубай",
"sender": { "street": "Sheikh Zayed Road",
"name": "Петр Петров", "house": "10",
"apartment": "201",
"zip": "12345",
"comment": "Домофон 12"
},
"senderContact": {
"fullName": "Петр Петров",
"email": "sender@example.com", "email": "sender@example.com",
"phones": [ "phone": "+79009876543",
{ "phoneExt": null,
"number": "+79009876543" "isCompany": false,
} "companyName": null,
] "inn": null,
"kpp": null
}, },
"recipient": { "receiverAddress": {
"name": "Иван Иванов", "cityId": 2,
"city": "Шарджа",
"street": "Al Wahda",
"house": "5",
"apartment": null,
"zip": "54321",
"comment": null
},
"receiverContact": {
"fullName": "Иван Иванов",
"email": "ivan@example.com", "email": "ivan@example.com",
"phones": [ "phone": "+79001234567",
{ "phoneExt": "101",
"number": "+79001234567" "isCompany": false,
} "companyName": null,
] "inn": null,
"kpp": null
}, },
"from_location": { "content": {
"address": "ул. Ленина, 1", "description": "Наушники"
"city": "Москва",
"country_code": "RU"
}, },
"to_location": { "pickupDate": "2026-05-15T10:00:00.000Z",
"address": "ул. Пушкина, 10", "deliveryDate": "2026-05-18T18:00:00.000Z",
"city": "Новосибирск", "accountEmail": "client@example.com",
"country_code": "RU" "systemData": {
"tariff": {
"provider": "СДЭК",
"serviceName": "Экспресс лайт",
"price": 125000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": 535
}, },
"services": [ "parcelType": "parcel",
{ "docPackaging": null,
"code": "INSURANCE", "weight": "1.0",
"parameter": "1000" "dimensions": {
"length": "20",
"width": "15",
"height": "10"
} }
],
"packages": [
{
"number": "1",
"weight": 1000,
"length": 20,
"width": 15,
"height": 10,
"comment": "Упаковка 1"
} }
]
} }
> {% > {%
client.global.set("order_uuid", response.body.entity.uuid); client.global.set("payment_url", response.body.payment_url);
%} %}
### 5. Список доступных тарифов ### 5. Список доступных тарифов
GET {{base_url}}/v2/calculator/alltariffs GET {{base_url}}/v2/calculator/alltariffs
Authorization: Bearer {{auth_token}} Authorization: Bearer {{auth_token}}
X-User-Lang: rus X-User-Lang: rus
### 6. Формирование накладной по заказу
POST {{base_url}}/v2/print/orders
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{
"orders": [
{
"order_uuid": "abd93f2d-7c5a-4820-9fdd-cab39aadbb1f"
}
],
"copy_count": 1
}
### 7. Получение накладной по UUID
GET {{base_url}}/v2/print/orders/31ff53f5-4c01-43aa-afa2-b7efdf0c06da
Authorization: Bearer {{auth_token}}
### 8. Просмотр заказа CDEK по UUID (с related_entities)
GET {{base_url}}/v2/orders/1e2282ba-529e-4ee7-9d82-2d7837145755
Authorization: Bearer {{auth_token}}
### 9. Скачивание готовой квитанции (PDF)
# Подставь WAYBILL_UUID из related_entities[type=waybill] (### 8).
# Файл сохраняется рядом с http-client.http как waybill.pdf.
GET {{base_url}}/v2/print/orders/31ff53f5-4c01-43aa-afa2-b7efdf0c06da.pdf
Authorization: Bearer {{auth_token}}
Accept: application/pdf
>>! waybill.pdf
Generated
+527 -14
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. # This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
[[package]] [[package]]
name = "aioredis" name = "aioredis"
@@ -6,6 +6,7 @@ version = "2.0.1"
description = "asyncio (PEP 3156) Redis support" description = "asyncio (PEP 3156) Redis support"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
groups = ["main"]
files = [ files = [
{file = "aioredis-2.0.1-py3-none-any.whl", hash = "sha256:9ac0d0b3b485d293b8ca1987e6de8658d7dafcca1cddfcd1d506cae8cdebfdd6"}, {file = "aioredis-2.0.1-py3-none-any.whl", hash = "sha256:9ac0d0b3b485d293b8ca1987e6de8658d7dafcca1cddfcd1d506cae8cdebfdd6"},
{file = "aioredis-2.0.1.tar.gz", hash = "sha256:eaa51aaf993f2d71f54b70527c440437ba65340588afeb786cd87c55c89cd98e"}, {file = "aioredis-2.0.1.tar.gz", hash = "sha256:eaa51aaf993f2d71f54b70527c440437ba65340588afeb786cd87c55c89cd98e"},
@@ -16,7 +17,59 @@ async-timeout = "*"
typing-extensions = "*" typing-extensions = "*"
[package.extras] [package.extras]
hiredis = ["hiredis (>=1.0)"] hiredis = ["hiredis (>=1.0) ; implementation_name == \"cpython\""]
[[package]]
name = "aiosmtplib"
version = "4.0.2"
description = "asyncio SMTP client"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "aiosmtplib-4.0.2-py3-none-any.whl", hash = "sha256:72491f96e6de035c28d29870186782eccb2f651db9c5f8a32c9db689327f5742"},
{file = "aiosmtplib-4.0.2.tar.gz", hash = "sha256:f0b4933e7270a8be2b588761e5b12b7334c11890ee91987c2fb057e72f566da6"},
]
[package.extras]
docs = ["furo (>=2023.9.10)", "sphinx (>=7.0.0)", "sphinx-autodoc-typehints (>=1.24.0)", "sphinx-copybutton (>=0.5.0)"]
uvloop = ["uvloop (>=0.18)"]
[[package]]
name = "aiosqlite"
version = "0.22.1"
description = "asyncio bridge to the standard sqlite3 module"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb"},
{file = "aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650"},
]
[package.extras]
dev = ["attribution (==1.8.0)", "black (==25.11.0)", "build (>=1.2)", "coverage[toml] (==7.10.7)", "flake8 (==7.3.0)", "flake8-bugbear (==24.12.12)", "flit (==3.12.0)", "mypy (==1.19.0)", "ufmt (==2.8.0)", "usort (==1.0.8.post1)"]
docs = ["sphinx (==8.1.3)", "sphinx-mdinclude (==0.6.2)"]
[[package]]
name = "alembic"
version = "1.18.4"
description = "A database migration tool for SQLAlchemy."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a"},
{file = "alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc"},
]
[package.dependencies]
Mako = "*"
SQLAlchemy = ">=1.4.23"
typing-extensions = ">=4.12"
[package.extras]
tz = ["tzdata"]
[[package]] [[package]]
name = "annotated-doc" name = "annotated-doc"
@@ -24,6 +77,7 @@ version = "0.0.4"
description = "Document parameters, class attributes, return types, and variables inline, with Annotated." description = "Document parameters, class attributes, return types, and variables inline, with Annotated."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"},
{file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"},
@@ -35,6 +89,7 @@ version = "0.7.0"
description = "Reusable constraint types to use with typing.Annotated" description = "Reusable constraint types to use with typing.Annotated"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
@@ -46,6 +101,7 @@ version = "4.12.1"
description = "High-level concurrency and networking framework on top of asyncio or Trio" description = "High-level concurrency and networking framework on top of asyncio or Trio"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"},
{file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"},
@@ -55,7 +111,7 @@ files = [
idna = ">=2.8" idna = ">=2.8"
[package.extras] [package.extras]
trio = ["trio (>=0.31.0)", "trio (>=0.32.0)"] trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""]
[[package]] [[package]]
name = "asgiref" name = "asgiref"
@@ -63,6 +119,7 @@ version = "3.11.1"
description = "ASGI specs, helper code, and adapters" description = "ASGI specs, helper code, and adapters"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133"}, {file = "asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133"},
{file = "asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce"}, {file = "asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce"},
@@ -77,17 +134,89 @@ version = "5.0.1"
description = "Timeout context manager for asyncio programs" description = "Timeout context manager for asyncio programs"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"},
{file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"},
] ]
[[package]]
name = "asyncpg"
version = "0.31.0"
description = "An asyncio PostgreSQL driver"
optional = false
python-versions = ">=3.9.0"
groups = ["main"]
files = [
{file = "asyncpg-0.31.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:831712dd3cf117eec68575a9b50da711893fd63ebe277fc155ecae1c6c9f0f61"},
{file = "asyncpg-0.31.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0b17c89312c2f4ccea222a3a6571f7df65d4ba2c0e803339bfc7bed46a96d3be"},
{file = "asyncpg-0.31.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3faa62f997db0c9add34504a68ac2c342cfee4d57a0c3062fcf0d86c7f9cb1e8"},
{file = "asyncpg-0.31.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ea599d45c361dfbf398cb67da7fd052affa556a401482d3ff1ee99bd68808a1"},
{file = "asyncpg-0.31.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:795416369c3d284e1837461909f58418ad22b305f955e625a4b3a2521d80a5f3"},
{file = "asyncpg-0.31.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a8d758dac9d2e723e173d286ef5e574f0b350ec00e9186fce84d0fc5f6a8e6b8"},
{file = "asyncpg-0.31.0-cp310-cp310-win32.whl", hash = "sha256:2d076d42eb583601179efa246c5d7ae44614b4144bc1c7a683ad1222814ed095"},
{file = "asyncpg-0.31.0-cp310-cp310-win_amd64.whl", hash = "sha256:9ea33213ac044171f4cac23740bed9a3805abae10e7025314cfbd725ec670540"},
{file = "asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d"},
{file = "asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab"},
{file = "asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c"},
{file = "asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109"},
{file = "asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da"},
{file = "asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9"},
{file = "asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24"},
{file = "asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047"},
{file = "asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad"},
{file = "asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d"},
{file = "asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a"},
{file = "asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671"},
{file = "asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec"},
{file = "asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20"},
{file = "asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8"},
{file = "asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186"},
{file = "asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b"},
{file = "asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e"},
{file = "asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403"},
{file = "asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4"},
{file = "asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2"},
{file = "asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602"},
{file = "asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696"},
{file = "asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab"},
{file = "asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44"},
{file = "asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5"},
{file = "asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2"},
{file = "asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2"},
{file = "asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218"},
{file = "asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d"},
{file = "asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b"},
{file = "asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be"},
{file = "asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2"},
{file = "asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31"},
{file = "asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7"},
{file = "asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e"},
{file = "asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c"},
{file = "asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a"},
{file = "asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d"},
{file = "asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3"},
{file = "asyncpg-0.31.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ebb3cde58321a1f89ce41812be3f2a98dddedc1e76d0838aba1d724f1e4e1a95"},
{file = "asyncpg-0.31.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e6974f36eb9a224d8fb428bcf66bd411aa12cf57c2967463178149e73d4de366"},
{file = "asyncpg-0.31.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc2b685f400ceae428f79f78b58110470d7b4466929a7f78d455964b17ad1008"},
{file = "asyncpg-0.31.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb223567dea5f47c45d347f2bde5486be8d9f40339f27217adb3fb1c3be51298"},
{file = "asyncpg-0.31.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:22be6e02381bab3101cd502d9297ac71e2f966c86e20e78caead9934c98a8af6"},
{file = "asyncpg-0.31.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:37a58919cfef2448a920df00d1b2f821762d17194d0dbf355d6dde8d952c04f9"},
{file = "asyncpg-0.31.0-cp39-cp39-win32.whl", hash = "sha256:c1a9c5b71d2371a2290bc93336cd05ba4ec781683cab292adbddc084f89443c6"},
{file = "asyncpg-0.31.0-cp39-cp39-win_amd64.whl", hash = "sha256:c1e1ab5bc65373d92dd749d7308c5b26fb2dc0fbe5d3bf68a32b676aa3bcd24a"},
{file = "asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735"},
]
[package.extras]
gssauth = ["gssapi ; platform_system != \"Windows\"", "sspilib ; platform_system == \"Windows\""]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.2.25" version = "2026.2.25"
description = "Python package for providing Mozilla's CA Bundle." description = "Python package for providing Mozilla's CA Bundle."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"]
files = [ files = [
{file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, {file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"},
{file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"},
@@ -99,6 +228,7 @@ version = "3.4.5"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"]
files = [ files = [
{file = "charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765"}, {file = "charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765"},
{file = "charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990"}, {file = "charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990"},
@@ -221,6 +351,7 @@ version = "8.3.1"
description = "Composable command line interface toolkit" description = "Composable command line interface toolkit"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"]
files = [ files = [
{file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"},
{file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"},
@@ -235,17 +366,57 @@ version = "0.4.6"
description = "Cross-platform colored terminal text." description = "Cross-platform colored terminal text."
optional = false optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main"]
markers = "platform_system == \"Windows\" or sys_platform == \"win32\""
files = [ files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
] ]
[[package]]
name = "dnspython"
version = "2.8.0"
description = "DNS toolkit"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"},
{file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"},
]
[package.extras]
dev = ["black (>=25.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.17.0)", "mypy (>=1.17)", "pylint (>=3)", "pytest (>=8.4)", "pytest-cov (>=6.2.0)", "quart-trio (>=0.12.0)", "sphinx (>=8.2.0)", "sphinx-rtd-theme (>=3.0.0)", "twine (>=6.1.0)", "wheel (>=0.45.0)"]
dnssec = ["cryptography (>=45)"]
doh = ["h2 (>=4.2.0)", "httpcore (>=1.0.0)", "httpx (>=0.28.0)"]
doq = ["aioquic (>=1.2.0)"]
idna = ["idna (>=3.10)"]
trio = ["trio (>=0.30)"]
wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""]
[[package]]
name = "email-validator"
version = "2.3.0"
description = "A robust email address syntax and deliverability validation library."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"},
{file = "email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"},
]
[package.dependencies]
dnspython = ">=2.0.0"
idna = ">=2.0.0"
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.135.1" version = "0.135.1"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"]
files = [ files = [
{file = "fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e"}, {file = "fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e"},
{file = "fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd"}, {file = "fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd"},
@@ -269,6 +440,7 @@ version = "1.73.0"
description = "Common protobufs used in Google APIs" description = "Common protobufs used in Google APIs"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"]
files = [ files = [
{file = "googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8"}, {file = "googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8"},
{file = "googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a"}, {file = "googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a"},
@@ -280,12 +452,87 @@ protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4
[package.extras] [package.extras]
grpc = ["grpcio (>=1.44.0,<2.0.0)"] grpc = ["grpcio (>=1.44.0,<2.0.0)"]
[[package]]
name = "greenlet"
version = "3.4.0"
description = "Lightweight in-process concurrent programming"
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""
files = [
{file = "greenlet-3.4.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:d18eae9a7fb0f499efcd146b8c9750a2e1f6e0e93b5a382b3481875354a430e6"},
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:636d2f95c309e35f650e421c23297d5011716be15d966e6328b367c9fc513a82"},
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:234582c20af9742583c3b2ddfbdbb58a756cfff803763ffaae1ac7990a9fac31"},
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ac6a5f618be581e1e0713aecec8e54093c235e5fa17d6d8eb7ffc487e2300508"},
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:523677e69cd4711b5a014e37bc1fb3a29947c3e3a5bb6a527e1cc50312e5a398"},
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:d336d46878e486de7d9458653c722875547ac8d36a1cff9ffaf4a74a3c1f62eb"},
{file = "greenlet-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b45e45fe47a19051a396abb22e19e7836a59ee6c5a90f3be427343c37908d65b"},
{file = "greenlet-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5434271357be07f3ad0936c312645853b7e689e679e29310e2de09a9ea6c3adf"},
{file = "greenlet-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:a19093fbad824ed7c0f355b5ff4214bffda5f1a7f35f29b31fcaa240cc0135ab"},
{file = "greenlet-3.4.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:805bebb4945094acbab757d34d6e1098be6de8966009ab9ca54f06ff492def58"},
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:439fc2f12b9b512d9dfa681c5afe5f6b3232c708d13e6f02c845e0d9f4c2d8c6"},
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70ed1cb0295bee1df57b63bf7f46b4e56a5c93709eea769c1fec1bb23a95875"},
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c5696c42e6bb5cfb7c6ff4453789081c66b9b91f061e5e9367fa15792644e76"},
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c660bce1940a1acae5f51f0a064f1bc785d07ea16efcb4bc708090afc4d69e83"},
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:89995ce5ddcd2896d89615116dd39b9703bfa0c07b583b85b89bf1b5d6eddf81"},
{file = "greenlet-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee407d4d1ca9dc632265aee1c8732c4a2d60adff848057cdebfe5fe94eb2c8a2"},
{file = "greenlet-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:956215d5e355fffa7c021d168728321fd4d31fd730ac609b1653b450f6a4bc71"},
{file = "greenlet-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:5cb614ace7c27571270354e9c9f696554d073f8aa9319079dcba466bbdead711"},
{file = "greenlet-3.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:04403ac74fe295a361f650818de93be11b5038a78f49ccfb64d3b1be8fbf1267"},
{file = "greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a"},
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97"},
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996"},
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d"},
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc"},
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077"},
{file = "greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de"},
{file = "greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08"},
{file = "greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2"},
{file = "greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e"},
{file = "greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1"},
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1"},
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82"},
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f"},
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf"},
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55"},
{file = "greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729"},
{file = "greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c"},
{file = "greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940"},
{file = "greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a"},
{file = "greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e"},
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d"},
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615"},
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19"},
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf"},
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd"},
{file = "greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf"},
{file = "greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda"},
{file = "greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d"},
{file = "greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802"},
{file = "greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece"},
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8"},
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2"},
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa"},
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed"},
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72"},
{file = "greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f"},
{file = "greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a"},
{file = "greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705"},
{file = "greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff"},
]
[package.extras]
docs = ["Sphinx", "furo"]
test = ["objgraph", "psutil", "setuptools"]
[[package]] [[package]]
name = "grpcio" name = "grpcio"
version = "1.78.0" version = "1.78.0"
description = "HTTP/2-based RPC framework" description = "HTTP/2-based RPC framework"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "grpcio-1.78.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:7cc47943d524ee0096f973e1081cb8f4f17a4615f2116882a5f1416e4cfe92b5"}, {file = "grpcio-1.78.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:7cc47943d524ee0096f973e1081cb8f4f17a4615f2116882a5f1416e4cfe92b5"},
{file = "grpcio-1.78.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c3f293fdc675ccba4db5a561048cca627b5e7bd1c8a6973ffedabe7d116e22e2"}, {file = "grpcio-1.78.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c3f293fdc675ccba4db5a561048cca627b5e7bd1c8a6973ffedabe7d116e22e2"},
@@ -362,6 +609,7 @@ version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
@@ -373,6 +621,7 @@ version = "1.0.9"
description = "A minimal low-level HTTP client." description = "A minimal low-level HTTP client."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
@@ -394,6 +643,7 @@ version = "0.7.1"
description = "A collection of framework independent HTTP protocol utils." description = "A collection of framework independent HTTP protocol utils."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78"}, {file = "httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78"},
{file = "httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4"}, {file = "httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4"},
@@ -446,6 +696,7 @@ version = "0.28.1"
description = "The next generation HTTP client." description = "The next generation HTTP client."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
@@ -458,7 +709,7 @@ httpcore = "==1.*"
idna = "*" idna = "*"
[package.extras] [package.extras]
brotli = ["brotli", "brotlicffi"] brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"] http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"] socks = ["socksio (==1.*)"]
@@ -470,6 +721,7 @@ version = "3.11"
description = "Internationalized Domain Names in Applications (IDNA)" description = "Internationalized Domain Names in Applications (IDNA)"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"},
{file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"},
@@ -484,6 +736,7 @@ version = "8.7.1"
description = "Read metadata from Python packages" description = "Read metadata from Python packages"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"},
{file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"},
@@ -493,13 +746,13 @@ files = [
zipp = ">=3.20" zipp = ">=3.20"
[package.extras] [package.extras]
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
cover = ["pytest-cov"] cover = ["pytest-cov"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
enabler = ["pytest-enabler (>=3.4)"] enabler = ["pytest-enabler (>=3.4)"]
perf = ["ipython"] perf = ["ipython"]
test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"]
type = ["mypy (<1.19)", "pytest-mypy (>=1.0.1)"] type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
@@ -507,17 +760,138 @@ version = "2.3.0"
description = "brain-dead simple config-ini parsing" description = "brain-dead simple config-ini parsing"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"]
files = [ files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
] ]
[[package]]
name = "mako"
version = "1.3.10"
description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59"},
{file = "mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28"},
]
[package.dependencies]
MarkupSafe = ">=0.9.2"
[package.extras]
babel = ["Babel"]
lingua = ["lingua"]
testing = ["pytest"]
[[package]]
name = "markupsafe"
version = "3.0.3"
description = "Safely add untrusted strings to HTML/XML markup."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"},
{file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"},
{file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"},
{file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"},
{file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"},
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"},
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"},
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"},
{file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"},
{file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"},
{file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"},
{file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"},
{file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"},
{file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"},
{file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"},
{file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"},
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"},
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"},
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"},
{file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"},
{file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"},
{file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"},
{file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"},
{file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"},
{file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"},
{file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"},
{file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"},
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"},
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"},
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"},
{file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"},
{file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"},
{file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"},
{file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"},
{file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"},
{file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"},
{file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"},
{file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"},
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"},
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"},
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"},
{file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"},
{file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"},
{file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"},
{file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"},
{file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"},
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"},
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"},
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"},
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"},
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"},
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"},
{file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"},
{file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"},
{file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"},
{file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"},
{file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"},
{file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"},
{file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"},
{file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"},
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"},
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"},
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"},
{file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"},
{file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"},
{file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"},
{file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"},
{file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"},
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"},
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"},
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"},
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"},
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"},
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"},
{file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"},
{file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"},
{file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"},
{file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"},
{file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"},
{file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"},
{file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"},
{file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"},
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"},
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"},
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"},
{file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"},
{file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"},
{file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"},
{file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"},
]
[[package]] [[package]]
name = "opentelemetry-api" name = "opentelemetry-api"
version = "1.40.0" version = "1.40.0"
description = "OpenTelemetry Python API" description = "OpenTelemetry Python API"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9"}, {file = "opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9"},
{file = "opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f"}, {file = "opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f"},
@@ -533,6 +907,7 @@ version = "1.40.0"
description = "OpenTelemetry Collector Exporters" description = "OpenTelemetry Collector Exporters"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "opentelemetry_exporter_otlp-1.40.0-py3-none-any.whl", hash = "sha256:48c87e539ec9afb30dc443775a1334cc5487de2f72a770a4c00b1610bf6c697d"}, {file = "opentelemetry_exporter_otlp-1.40.0-py3-none-any.whl", hash = "sha256:48c87e539ec9afb30dc443775a1334cc5487de2f72a770a4c00b1610bf6c697d"},
{file = "opentelemetry_exporter_otlp-1.40.0.tar.gz", hash = "sha256:7caa0870b95e2fcb59d64e16e2b639ecffb07771b6cd0000b5d12e5e4fef765a"}, {file = "opentelemetry_exporter_otlp-1.40.0.tar.gz", hash = "sha256:7caa0870b95e2fcb59d64e16e2b639ecffb07771b6cd0000b5d12e5e4fef765a"},
@@ -548,6 +923,7 @@ version = "1.40.0"
description = "OpenTelemetry Protobuf encoding" description = "OpenTelemetry Protobuf encoding"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149"}, {file = "opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149"},
{file = "opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa"}, {file = "opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa"},
@@ -562,6 +938,7 @@ version = "1.40.0"
description = "OpenTelemetry Collector Protobuf over gRPC Exporter" description = "OpenTelemetry Collector Protobuf over gRPC Exporter"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "opentelemetry_exporter_otlp_proto_grpc-1.40.0-py3-none-any.whl", hash = "sha256:2aa0ca53483fe0cf6405087a7491472b70335bc5c7944378a0a8e72e86995c52"}, {file = "opentelemetry_exporter_otlp_proto_grpc-1.40.0-py3-none-any.whl", hash = "sha256:2aa0ca53483fe0cf6405087a7491472b70335bc5c7944378a0a8e72e86995c52"},
{file = "opentelemetry_exporter_otlp_proto_grpc-1.40.0.tar.gz", hash = "sha256:bd4015183e40b635b3dab8da528b27161ba83bf4ef545776b196f0fb4ec47740"}, {file = "opentelemetry_exporter_otlp_proto_grpc-1.40.0.tar.gz", hash = "sha256:bd4015183e40b635b3dab8da528b27161ba83bf4ef545776b196f0fb4ec47740"},
@@ -585,6 +962,7 @@ version = "1.40.0"
description = "OpenTelemetry Collector Protobuf over HTTP Exporter" description = "OpenTelemetry Collector Protobuf over HTTP Exporter"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069"}, {file = "opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069"},
{file = "opentelemetry_exporter_otlp_proto_http-1.40.0.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c"}, {file = "opentelemetry_exporter_otlp_proto_http-1.40.0.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c"},
@@ -608,6 +986,7 @@ version = "0.61b0"
description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python" description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "opentelemetry_instrumentation-0.61b0-py3-none-any.whl", hash = "sha256:92a93a280e69788e8f88391247cc530fd81f16f2b011979d4d6398f805cfbc63"}, {file = "opentelemetry_instrumentation-0.61b0-py3-none-any.whl", hash = "sha256:92a93a280e69788e8f88391247cc530fd81f16f2b011979d4d6398f805cfbc63"},
{file = "opentelemetry_instrumentation-0.61b0.tar.gz", hash = "sha256:cb21b48db738c9de196eba6b805b4ff9de3b7f187e4bbf9a466fa170514f1fc7"}, {file = "opentelemetry_instrumentation-0.61b0.tar.gz", hash = "sha256:cb21b48db738c9de196eba6b805b4ff9de3b7f187e4bbf9a466fa170514f1fc7"},
@@ -625,6 +1004,7 @@ version = "0.61b0"
description = "ASGI instrumentation for OpenTelemetry" description = "ASGI instrumentation for OpenTelemetry"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "opentelemetry_instrumentation_asgi-0.61b0-py3-none-any.whl", hash = "sha256:e4b3ce6b66074e525e717efff20745434e5efd5d9df6557710856fba356da7a4"}, {file = "opentelemetry_instrumentation_asgi-0.61b0-py3-none-any.whl", hash = "sha256:e4b3ce6b66074e525e717efff20745434e5efd5d9df6557710856fba356da7a4"},
{file = "opentelemetry_instrumentation_asgi-0.61b0.tar.gz", hash = "sha256:9d08e127244361dc33976d39dd4ca8f128b5aa5a7ae425208400a80a095019b5"}, {file = "opentelemetry_instrumentation_asgi-0.61b0.tar.gz", hash = "sha256:9d08e127244361dc33976d39dd4ca8f128b5aa5a7ae425208400a80a095019b5"},
@@ -646,6 +1026,7 @@ version = "0.61b0"
description = "OpenTelemetry FastAPI Instrumentation" description = "OpenTelemetry FastAPI Instrumentation"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "opentelemetry_instrumentation_fastapi-0.61b0-py3-none-any.whl", hash = "sha256:a1a844d846540d687d377516b2ff698b51d87c781b59f47c214359c4a241047c"}, {file = "opentelemetry_instrumentation_fastapi-0.61b0-py3-none-any.whl", hash = "sha256:a1a844d846540d687d377516b2ff698b51d87c781b59f47c214359c4a241047c"},
{file = "opentelemetry_instrumentation_fastapi-0.61b0.tar.gz", hash = "sha256:3a24f35b07c557ae1bbc483bf8412221f25d79a405f8b047de8b670722e2fa9f"}, {file = "opentelemetry_instrumentation_fastapi-0.61b0.tar.gz", hash = "sha256:3a24f35b07c557ae1bbc483bf8412221f25d79a405f8b047de8b670722e2fa9f"},
@@ -667,6 +1048,7 @@ version = "0.61b0"
description = "OpenTelemetry HTTPX Instrumentation" description = "OpenTelemetry HTTPX Instrumentation"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "opentelemetry_instrumentation_httpx-0.61b0-py3-none-any.whl", hash = "sha256:dee05c93a6593a5dc3ae5d9d5c01df8b4e2c5d02e49275e5558534ee46343d5e"}, {file = "opentelemetry_instrumentation_httpx-0.61b0-py3-none-any.whl", hash = "sha256:dee05c93a6593a5dc3ae5d9d5c01df8b4e2c5d02e49275e5558534ee46343d5e"},
{file = "opentelemetry_instrumentation_httpx-0.61b0.tar.gz", hash = "sha256:6569ec097946c5551c2a4252f74c98666addd1bf047c1dde6b4ef426719ff8dd"}, {file = "opentelemetry_instrumentation_httpx-0.61b0.tar.gz", hash = "sha256:6569ec097946c5551c2a4252f74c98666addd1bf047c1dde6b4ef426719ff8dd"},
@@ -688,6 +1070,7 @@ version = "0.61b0"
description = "OpenTelemetry Redis instrumentation" description = "OpenTelemetry Redis instrumentation"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "opentelemetry_instrumentation_redis-0.61b0-py3-none-any.whl", hash = "sha256:8d4e850bbb5f8eeafa44c0eac3a007990c7125de187bc9c3659e29ff7e091172"}, {file = "opentelemetry_instrumentation_redis-0.61b0-py3-none-any.whl", hash = "sha256:8d4e850bbb5f8eeafa44c0eac3a007990c7125de187bc9c3659e29ff7e091172"},
{file = "opentelemetry_instrumentation_redis-0.61b0.tar.gz", hash = "sha256:ae0fbb56be9a641e621d55b02a7d62977a2c77c5ee760addd79b9b266e46e523"}, {file = "opentelemetry_instrumentation_redis-0.61b0.tar.gz", hash = "sha256:ae0fbb56be9a641e621d55b02a7d62977a2c77c5ee760addd79b9b266e46e523"},
@@ -708,6 +1091,7 @@ version = "1.40.0"
description = "OpenTelemetry Python Proto" description = "OpenTelemetry Python Proto"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f"}, {file = "opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f"},
{file = "opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd"}, {file = "opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd"},
@@ -722,6 +1106,7 @@ version = "1.40.0"
description = "OpenTelemetry Python SDK" description = "OpenTelemetry Python SDK"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1"}, {file = "opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1"},
{file = "opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2"}, {file = "opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2"},
@@ -738,6 +1123,7 @@ version = "0.61b0"
description = "OpenTelemetry Semantic Conventions" description = "OpenTelemetry Semantic Conventions"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2"}, {file = "opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2"},
{file = "opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a"}, {file = "opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a"},
@@ -753,6 +1139,7 @@ version = "0.61b0"
description = "Web util for OpenTelemetry" description = "Web util for OpenTelemetry"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "opentelemetry_util_http-0.61b0-py3-none-any.whl", hash = "sha256:8e715e848233e9527ea47e275659ea60a57a75edf5206a3b937e236a6da5fc33"}, {file = "opentelemetry_util_http-0.61b0-py3-none-any.whl", hash = "sha256:8e715e848233e9527ea47e275659ea60a57a75edf5206a3b937e236a6da5fc33"},
{file = "opentelemetry_util_http-0.61b0.tar.gz", hash = "sha256:1039cb891334ad2731affdf034d8fb8b48c239af9b6dd295e5fabd07f1c95572"}, {file = "opentelemetry_util_http-0.61b0.tar.gz", hash = "sha256:1039cb891334ad2731affdf034d8fb8b48c239af9b6dd295e5fabd07f1c95572"},
@@ -764,6 +1151,7 @@ version = "26.0"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"},
{file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"},
@@ -775,6 +1163,7 @@ version = "1.6.0"
description = "plugin and hook calling mechanisms for python" description = "plugin and hook calling mechanisms for python"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
@@ -790,6 +1179,7 @@ version = "6.33.5"
description = "" description = ""
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b"}, {file = "protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b"},
{file = "protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c"}, {file = "protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c"},
@@ -809,6 +1199,7 @@ version = "2.12.5"
description = "Data validation using Python type hints" description = "Data validation using Python type hints"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"},
{file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"},
@@ -822,7 +1213,7 @@ typing-inspection = ">=0.4.2"
[package.extras] [package.extras]
email = ["email-validator (>=2.0.0)"] email = ["email-validator (>=2.0.0)"]
timezone = ["tzdata"] timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""]
[[package]] [[package]]
name = "pydantic-core" name = "pydantic-core"
@@ -830,6 +1221,7 @@ version = "2.41.5"
description = "Core functionality for Pydantic validation and serialization" description = "Core functionality for Pydantic validation and serialization"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"},
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"},
@@ -963,6 +1355,7 @@ version = "2.13.1"
description = "Settings management using Pydantic" description = "Settings management using Pydantic"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"]
files = [ files = [
{file = "pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237"}, {file = "pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237"},
{file = "pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025"}, {file = "pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025"},
@@ -986,6 +1379,7 @@ version = "2.19.2"
description = "Pygments is a syntax highlighting package written in Python." description = "Pygments is a syntax highlighting package written in Python."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
@@ -1000,6 +1394,7 @@ version = "9.0.2"
description = "pytest: simple powerful testing with Python" description = "pytest: simple powerful testing with Python"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"]
files = [ files = [
{file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"},
{file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"},
@@ -1021,6 +1416,7 @@ version = "1.2.2"
description = "Read key-value pairs from a .env file and set them as environment variables" description = "Read key-value pairs from a .env file and set them as environment variables"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"]
files = [ files = [
{file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"}, {file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"},
{file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"}, {file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"},
@@ -1035,6 +1431,7 @@ version = "6.0.3"
description = "YAML parser and emitter for Python" description = "YAML parser and emitter for Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
@@ -1117,6 +1514,7 @@ version = "7.3.0"
description = "Python client for Redis database and key-value store" description = "Python client for Redis database and key-value store"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"]
files = [ files = [
{file = "redis-7.3.0-py3-none-any.whl", hash = "sha256:9d4fcb002a12a5e3c3fbe005d59c48a2cc231f87fbb2f6b70c2d89bb64fec364"}, {file = "redis-7.3.0-py3-none-any.whl", hash = "sha256:9d4fcb002a12a5e3c3fbe005d59c48a2cc231f87fbb2f6b70c2d89bb64fec364"},
{file = "redis-7.3.0.tar.gz", hash = "sha256:4d1b768aafcf41b01022410b3cc4f15a07d9b3d6fe0c66fc967da2c88e551034"}, {file = "redis-7.3.0.tar.gz", hash = "sha256:4d1b768aafcf41b01022410b3cc4f15a07d9b3d6fe0c66fc967da2c88e551034"},
@@ -1136,6 +1534,7 @@ version = "2.32.5"
description = "Python HTTP for Humans." description = "Python HTTP for Humans."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
{file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
@@ -1151,12 +1550,115 @@ urllib3 = ">=1.21.1,<3"
socks = ["PySocks (>=1.5.6,!=1.5.7)"] socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "sqlalchemy"
version = "2.0.49"
description = "Database Abstraction Library"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "sqlalchemy-2.0.49-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:42e8804962f9e6f4be2cbaedc0c3718f08f60a16910fa3d86da5a1e3b1bfe60f"},
{file = "sqlalchemy-2.0.49-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc992c6ed024c8c3c592c5fc9846a03dd68a425674900c70122c77ea16c5fb0b"},
{file = "sqlalchemy-2.0.49-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eb188b84269f357669b62cb576b5b918de10fb7c728a005fa0ebb0b758adce1"},
{file = "sqlalchemy-2.0.49-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:62557958002b69699bdb7f5137c6714ca1133f045f97b3903964f47db97ea339"},
{file = "sqlalchemy-2.0.49-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da9b91bca419dc9b9267ffadde24eae9b1a6bffcd09d0a207e5e3af99a03ce0d"},
{file = "sqlalchemy-2.0.49-cp310-cp310-win32.whl", hash = "sha256:5e61abbec255be7b122aa461021daa7c3f310f3e743411a67079f9b3cc91ece3"},
{file = "sqlalchemy-2.0.49-cp310-cp310-win_amd64.whl", hash = "sha256:0c98c59075b890df8abfcc6ad632879540f5791c68baebacb4f833713b510e75"},
{file = "sqlalchemy-2.0.49-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe"},
{file = "sqlalchemy-2.0.49-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014"},
{file = "sqlalchemy-2.0.49-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536"},
{file = "sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88"},
{file = "sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700"},
{file = "sqlalchemy-2.0.49-cp311-cp311-win32.whl", hash = "sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a"},
{file = "sqlalchemy-2.0.49-cp311-cp311-win_amd64.whl", hash = "sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af"},
{file = "sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b"},
{file = "sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982"},
{file = "sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672"},
{file = "sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e"},
{file = "sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750"},
{file = "sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0"},
{file = "sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4"},
{file = "sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120"},
{file = "sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2"},
{file = "sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3"},
{file = "sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7"},
{file = "sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33"},
{file = "sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b"},
{file = "sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148"},
{file = "sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518"},
{file = "sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d"},
{file = "sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0"},
{file = "sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08"},
{file = "sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d"},
{file = "sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba"},
{file = "sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e"},
{file = "sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a"},
{file = "sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066"},
{file = "sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187"},
{file = "sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401"},
{file = "sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5"},
{file = "sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5"},
{file = "sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977"},
{file = "sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01"},
{file = "sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61"},
{file = "sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a"},
{file = "sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158"},
{file = "sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7"},
{file = "sqlalchemy-2.0.49-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8a97ac839c2c6672c4865e48f3cbad7152cee85f4233fb4ca6291d775b9b954a"},
{file = "sqlalchemy-2.0.49-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c338ec6ec01c0bc8e735c58b9f5d51e75bacb6ff23296658826d7cfdfdb8678a"},
{file = "sqlalchemy-2.0.49-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:566df36fd0e901625523a5a1835032f1ebdd7f7886c54584143fa6c668b4df3b"},
{file = "sqlalchemy-2.0.49-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d99945830a6f3e9638d89a28ed130b1eb24c91255e4f24366fbe699b983f29e4"},
{file = "sqlalchemy-2.0.49-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:01146546d84185f12721a1d2ce0c6673451a7894d1460b592d378ca4871a0c72"},
{file = "sqlalchemy-2.0.49-cp38-cp38-win32.whl", hash = "sha256:69469ce8ce7a8df4d37620e3163b71238719e1e2e5048d114a1b6ce0fbf8c662"},
{file = "sqlalchemy-2.0.49-cp38-cp38-win_amd64.whl", hash = "sha256:b95b2f470c1b2683febd2e7eab1d3f0e078c91dbdd0b00e9c645d07a413bb99f"},
{file = "sqlalchemy-2.0.49-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:43d044780732d9e0381ac8d5316f95d7f02ef04d6e4ef6dc82379f09795d993f"},
{file = "sqlalchemy-2.0.49-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d6be30b2a75362325176c036d7fb8d19e8846c77e87683ffaa8177b35135613"},
{file = "sqlalchemy-2.0.49-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d898cc2c76c135ef65517f4ddd7a3512fb41f23087b0650efb3418b8389a3cd1"},
{file = "sqlalchemy-2.0.49-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:059d7151fff513c53a4638da8778be7fce81a0c4854c7348ebd0c4078ddf28fe"},
{file = "sqlalchemy-2.0.49-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:334edbcff10514ad1d66e3a70b339c0a29886394892490119dbb669627b17717"},
{file = "sqlalchemy-2.0.49-cp39-cp39-win32.whl", hash = "sha256:74ab4ee7794d7ed1b0c37e7333640e0f0a626fc7b398c07a7aef52f484fddde3"},
{file = "sqlalchemy-2.0.49-cp39-cp39-win_amd64.whl", hash = "sha256:88690f4e1f0fbf5339bedbb127e240fec1fd3070e9934c0b7bef83432f779d2f"},
{file = "sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0"},
{file = "sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f"},
]
[package.dependencies]
greenlet = {version = ">=1", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""}
typing-extensions = ">=4.6.0"
[package.extras]
aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"]
aioodbc = ["aioodbc", "greenlet (>=1)"]
aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"]
asyncio = ["greenlet (>=1)"]
asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"]
mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"]
mssql = ["pyodbc"]
mssql-pymssql = ["pymssql"]
mssql-pyodbc = ["pyodbc"]
mypy = ["mypy (>=0.910)"]
mysql = ["mysqlclient (>=1.4.0)"]
mysql-connector = ["mysql-connector-python"]
oracle = ["cx_oracle (>=8)"]
oracle-oracledb = ["oracledb (>=1.0.1)"]
postgresql = ["psycopg2 (>=2.7)"]
postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"]
postgresql-pg8000 = ["pg8000 (>=1.29.1)"]
postgresql-psycopg = ["psycopg (>=3.0.7)"]
postgresql-psycopg2binary = ["psycopg2-binary"]
postgresql-psycopg2cffi = ["psycopg2cffi"]
postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"]
pymysql = ["pymysql"]
sqlcipher = ["sqlcipher3_binary"]
[[package]] [[package]]
name = "starlette" name = "starlette"
version = "0.52.1" version = "0.52.1"
description = "The little ASGI library that shines." description = "The little ASGI library that shines."
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"]
files = [ files = [
{file = "starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74"}, {file = "starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74"},
{file = "starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933"}, {file = "starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933"},
@@ -1174,6 +1676,7 @@ version = "25.5.0"
description = "Structured Logging for Python" description = "Structured Logging for Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f"}, {file = "structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f"},
{file = "structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98"}, {file = "structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98"},
@@ -1185,6 +1688,7 @@ version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+" description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
@@ -1196,6 +1700,7 @@ version = "0.4.2"
description = "Runtime typing introspection tools" description = "Runtime typing introspection tools"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"},
{file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"},
@@ -1210,16 +1715,17 @@ version = "2.6.3"
description = "HTTP library with thread-safe connection pooling, file post, and more." description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
{file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
] ]
[package.extras] [package.extras]
brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"] brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""]
h2 = ["h2 (>=4,<5)"] h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["backports-zstd (>=1.0.0)"] zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
@@ -1227,6 +1733,7 @@ version = "0.41.0"
description = "The lightning-fast ASGI server." description = "The lightning-fast ASGI server."
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"]
files = [ files = [
{file = "uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187"}, {file = "uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187"},
{file = "uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a"}, {file = "uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a"},
@@ -1239,12 +1746,12 @@ h11 = ">=0.8"
httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standard\""} httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standard\""}
python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""}
uvloop = {version = ">=0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} uvloop = {version = ">=0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""}
watchfiles = {version = ">=0.20", optional = true, markers = "extra == \"standard\""} watchfiles = {version = ">=0.20", optional = true, markers = "extra == \"standard\""}
websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""}
[package.extras] [package.extras]
standard = ["colorama (>=0.4)", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1)", "watchfiles (>=0.20)", "websockets (>=10.4)"] standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.20)", "websockets (>=10.4)"]
[[package]] [[package]]
name = "uvloop" name = "uvloop"
@@ -1252,6 +1759,8 @@ version = "0.22.1"
description = "Fast implementation of asyncio event loop on top of libuv" description = "Fast implementation of asyncio event loop on top of libuv"
optional = false optional = false
python-versions = ">=3.8.1" python-versions = ">=3.8.1"
groups = ["main"]
markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\""
files = [ files = [
{file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c"}, {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c"},
{file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792"}, {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792"},
@@ -1315,6 +1824,7 @@ version = "1.1.1"
description = "Simple, modern and high performance file watching and code reload in python." description = "Simple, modern and high performance file watching and code reload in python."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c"}, {file = "watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c"},
{file = "watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43"}, {file = "watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43"},
@@ -1436,6 +1946,7 @@ version = "16.0"
description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"]
files = [ files = [
{file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"}, {file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"},
{file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"}, {file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"},
@@ -1506,6 +2017,7 @@ version = "1.17.3"
description = "Module for decorators, wrappers and monkey patching." description = "Module for decorators, wrappers and monkey patching."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04"}, {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04"},
{file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"}, {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"},
@@ -1596,13 +2108,14 @@ version = "3.23.0"
description = "Backport of pathlib-compatible object wrapper for zip files" description = "Backport of pathlib-compatible object wrapper for zip files"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"},
{file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"},
] ]
[package.extras] [package.extras]
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
cover = ["pytest-cov"] cover = ["pytest-cov"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
enabler = ["pytest-enabler (>=2.2)"] enabler = ["pytest-enabler (>=2.2)"]
@@ -1610,6 +2123,6 @@ test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_it
type = ["pytest-mypy"] type = ["pytest-mypy"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.1"
python-versions = "^3.14" python-versions = "^3.14"
content-hash = "3156d28f769918f0fc547653c11a84e33728d9abed9e7be1a885aa7ae512d6dd" content-hash = "37dfb9818087d72976a203956d4f1ef6df05377e66dc521b7884b718df44138e"
+8
View File
@@ -4,6 +4,7 @@ version = "0.1.0"
description = "" description = ""
authors = ["Your Name <you@example.com>"] authors = ["Your Name <you@example.com>"]
readme = "README.md" readme = "README.md"
package-mode = false
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.14" python = "^3.14"
@@ -22,9 +23,16 @@ opentelemetry-instrumentation-httpx = "^0.61b0"
opentelemetry-instrumentation-redis = "^0.61b0" opentelemetry-instrumentation-redis = "^0.61b0"
pytest = "^9.0.2" pytest = "^9.0.2"
redis = "^7.3.0" redis = "^7.3.0"
sqlalchemy = "^2.0.49"
asyncpg = "^0.31.0"
alembic = "^1.18.4"
aiosqlite = "^0.22.1"
aiosmtplib = "^4.0.2"
email-validator = "^2.2.0"
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["."] pythonpath = ["."]
addopts = ["--import-mode=importlib"]
[build-system] [build-system]
+12 -3
View File
@@ -32,9 +32,18 @@
| 023 | DONE | 2026-03-29 | Add Yandex Geosuggest address suggestion adapter and CIS routing | `spec/tasks/023_add_yandex_geosuggest_address_suggestion_adapter.md` | | 023 | DONE | 2026-03-29 | Add Yandex Geosuggest address suggestion adapter and CIS routing | `spec/tasks/023_add_yandex_geosuggest_address_suggestion_adapter.md` |
| 024 | DONE | 2026-03-29 | Add TomTom address suggestion adapter and Europe routing | `spec/tasks/024_add_tomtom_address_suggestion_adapter.md` | | 024 | DONE | 2026-03-29 | Add TomTom address suggestion adapter and Europe routing | `spec/tasks/024_add_tomtom_address_suggestion_adapter.md` |
| 025 | DONE | 2026-04-03 | Remove company from Create Delivery Order parties | `spec/tasks/025_remove_company_from_create_delivery_order.md` | | 025 | DONE | 2026-04-03 | Remove company from Create Delivery Order parties | `spec/tasks/025_remove_company_from_create_delivery_order.md` |
| 026 | DONE | 2026-04-05 | Align CDEK order contract with single phone and kilogram package weight | `spec/tasks/026_align_cdek_order_contract_single_phone_and_weight_units.md` |
| 027 | DONE | 2026-04-11 | Add TBank payment adapter, init_payment endpoint and rename order flow | `spec/tasks/027_add_tbank_payment_adapter_and_order_payment_link.md` |
| 028 | DONE | 2026-04-12 | Add PostgreSQL adapter, order repository and persist order after payment link creation | `spec/tasks/028_add_postgresql_order_persistence.md` |
| 029 | DONE | 2026-04-18 | Add TBank payment notification and success URLs | `spec/tasks/029_add_tbank_payment_urls.md` |
| 030 | DONE | 2026-04-18 | Add TBank payment notification webhook and CDEK order creation | `spec/tasks/030_add_tbank_payment_notification_webhook.md` |
| 031 | TODO | 2026-04-18 | Validate init-payment price with CDEK tariff | `spec/tasks/031_validate_init_payment_price_with_cdek.md` |
| 032 | TODO | 2026-05-13 | Rework init-payment contract to camelCase and structured address/contact | `spec/tasks/032_rework_init_payment_contract_camelcase.md` |
| 033 | DONE | 2026-05-23 | Add CDEK waybill polling worker | `spec/tasks/033_add_waybill_polling_worker.md` |
| 034 | DONE | 2026-05-23 | Add CDEK waybill e-mail sender worker | `spec/tasks/034_add_waybill_email_sender_worker.md` |
## Summary ## Summary
- Total: **26** - Total: **35**
- TODO: **0** - TODO: **2**
- DONE: **26** - DONE: **33**
+149 -41
View File
@@ -17,8 +17,13 @@
- Принимать запрос на расчёт стоимости доставки (идентификаторы городов отправления/назначения, вес, габариты) - Принимать запрос на расчёт стоимости доставки (идентификаторы городов отправления/назначения, вес, габариты)
- Поддерживать необязательный параметр `parcel_type` в запросе расчёта стоимости доставки для фильтрации тарифов по типу отправления - Поддерживать необязательный параметр `parcel_type` в запросе расчёта стоимости доставки для фильтрации тарифов по типу отправления
- Предоставлять отдельный endpoint подсказок адреса, чтобы frontend мог получить точное значение для `from_location.address` и `to_location.address` перед созданием заказа - Предоставлять отдельный endpoint подсказок адреса, чтобы frontend мог получить точное значение для `from_location.address` и `to_location.address` перед инициализацией оплаты доставки
- Принимать запрос на создание заказа CDEK по контракту из `http-client.http` для сценария "доставка, до двери" - Принимать запрос на инициализацию оплаты доставки через TBank и возвращать ссылку на оплату без регистрации заказа в CDEK
- Принимать сумму оплаты в поле `price` в копейках
- После успешного получения ссылки на оплату от TBank сохранять все данные заявки вместе со ссылкой на оплату в PostgreSQL; ошибка сохранения не блокирует возврат ссылки клиенту
- Принимать HTTP-уведомления TBank о статусах платежа на отдельный webhook endpoint
- При получении валидного уведомления TBank со статусом `CONFIRMED` находить сохранённую заявку в PostgreSQL и регистрировать заказ в CDEK
- Остальные валидные статусы платежа TBank подтверждать без регистрации заказа в CDEK
- Выбирать сервис подсказок адреса по `country_code` через маппинг стран в конфиге - Выбирать сервис подсказок адреса по `country_code` через маппинг стран в конфиге
- Для `RU`, `BY` и `KZ`, сопоставленных с provider id `dadata`, использовать `dadata.ru` - Для `RU`, `BY` и `KZ`, сопоставленных с provider id `dadata`, использовать `dadata.ru`
- Для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ`, сопоставленных с provider id `yandex_geosuggest`, использовать Yandex Geosuggest - Для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ`, сопоставленных с provider id `yandex_geosuggest`, использовать Yandex Geosuggest
@@ -38,8 +43,11 @@
### Фаза 1 ### Фаза 1
- **CDEK** — https://apidoc.cdek.ru/ - **CDEK** — https://apidoc.cdek.ru/
- Аутентификация: OAuth2 (client credentials) - Аутентификация: OAuth2 (client credentials)
- Операции: расчёт тарифа, регистрация заказа - Операции: расчёт тарифа
- Cache TTL: 15 минут - Cache TTL: 15 минут
- **TBank** — https://securepay.tinkoff.ru/v2/Init
- Аутентификация: `TerminalKey` и token на основе password
- Операции: инициализация платежа, получение payment URL, проверка подписи HTTP-уведомлений
### Фаза 2+ ### Фаза 2+
- Дополнительные провайдеры (Boxberry, DHL и др.) — подключаются через интерфейс `DeliveryProvider` без изменений в логике агрегации - Дополнительные провайдеры (Boxberry, DHL и др.) — подключаются через интерфейс `DeliveryProvider` без изменений в логике агрегации
@@ -51,10 +59,11 @@
### Controller (`app/controllers/v1/delivery.py`) ### Controller (`app/controllers/v1/delivery.py`)
- `POST /api/v1/delivery/price` — принимает `DeliveryCalculationRequest`, возвращает `list[DeliveryPrice]` - `POST /api/v1/delivery/price` — принимает `DeliveryCalculationRequest`, возвращает `list[DeliveryPrice]`
- `POST /api/v1/delivery/suggest-address` — принимает `AddressSuggestRequest`, возвращает `list[AddressSuggestion]` - `POST /api/v1/delivery/suggest-address` — принимает `AddressSuggestRequest`, возвращает `list[AddressSuggestion]`
- `POST /api/v1/delivery/order` — принимает `OrderCreateRequest`, возвращает `OrderCreateResponse` - `POST /api/v1/delivery/order` — принимает `InitPaymentRequest`, возвращает `InitPaymentResponse`
- `POST /api/v1/delivery/tbank/notifications` — принимает `TBankPaymentNotification`, возвращает plain text `OK` при успешной обработке
- Парсинг и валидация входных данных через Pydantic - Парсинг и валидация входных данных через Pydantic
- Маппинг исключений сервиса в HTTP-ответы - Маппинг исключений сервиса в HTTP-ответы
- Каждый endpoint вызывает ровно один метод Service: `AggregatorService.get_all_prices()`, `AggregatorService.suggest_addresses()` или `AggregatorService.create_order()` - Каждый endpoint вызывает ровно один метод Service: `AggregatorService.get_all_prices()`, `AggregatorService.suggest_addresses()`, `AggregatorService.init_payment()` или `AggregatorService.handle_tbank_payment_notification()`
### Service (`app/services/aggregator.py`) ### Service (`app/services/aggregator.py`)
- `AggregatorService.get_all_prices(request: DeliveryCalculationRequest) -> list[DeliveryPrice]` - `AggregatorService.get_all_prices(request: DeliveryCalculationRequest) -> list[DeliveryPrice]`
@@ -64,8 +73,12 @@
- Сортирует тарифы по цене - Сортирует тарифы по цене
- `AggregatorService.suggest_addresses(request: AddressSuggestRequest) -> list[AddressSuggestion]` - `AggregatorService.suggest_addresses(request: AddressSuggestRequest) -> list[AddressSuggestion]`
- `AggregatorService.suggest_addresses()` выбирает address suggestion provider по `country_code` через injected config mapping и оркестрирует ровно один adapter call - `AggregatorService.suggest_addresses()` выбирает address suggestion provider по `country_code` через injected config mapping и оркестрирует ровно один adapter call
- `AggregatorService.create_order(request: OrderCreateRequest) -> OrderCreateResponse` - `AggregatorService.init_payment(request: InitPaymentRequest) -> InitPaymentResponse`
- `AggregatorService.create_order()` оркестрирует регистрацию заказа в CDEK через injected adapter dependency - `AggregatorService.init_payment()` оркестрирует инициализацию платежа через injected TBank adapter dependency, затем сохраняет данные заявки вместе с `payment_url` через injected order repository; ошибка сохранения логируется, но не блокирует возврат `payment_url`
- `AggregatorService.handle_tbank_payment_notification(notification: TBankPaymentNotification) -> str`
- `AggregatorService.handle_tbank_payment_notification()` оркестрирует проверку подписи уведомления через injected TBank adapter, определение действия по статусу через Business Logic, чтение сохранённой заявки через injected order repository и регистрацию заказа через injected CDEK adapter при статусе `CONFIRMED`
- Повторное валидное уведомление `CONFIRMED` для заявки с уже сохранённым `cdek_order_uuid` не должно повторно создавать заказ в CDEK
- Валидные уведомления TBank со статусами, отличными от `CONFIRMED`, подтверждаются ответом `OK` без обращения к CDEK
- Service не содержит бизнес-логики и provider HTTP-деталей - Service не содержит бизнес-логики и provider HTTP-деталей
### Business Logic (`app/domain/`) ### Business Logic (`app/domain/`)
@@ -74,6 +87,7 @@
- Логика сравнения цен - Логика сравнения цен
- Правила применения конфигурируемого мультипликатора к ценам провайдеров - Правила применения конфигурируемого мультипликатора к ценам провайдеров
- Округление цены после применения мультипликатора до целого значения по детерминированному правилу - Округление цены после применения мультипликатора до целого значения по детерминированному правилу
- Правило выбора действия для TBank payment notification по статусу платежа
- Нормализация входных данных (например, идентификаторов городов и правил округления веса) - Нормализация входных данных (например, идентификаторов городов и правил округления веса)
- Чистые функции, без IO, без зависимостей от фреймворков - Чистые функции, без IO, без зависимостей от фреймворков
@@ -82,6 +96,15 @@
- Операции: `get(key)`, `set(key, value, ttl)`, `invalidate(key)` - Операции: `get(key)`, `set(key, value, ttl)`, `invalidate(key)`
- Без бизнес-решений; формирование ключа — ответственность Adapter - Без бизнес-решений; формирование ключа — ответственность Adapter
### Repository (`app/repositories/order/`)
- `OrderRepository` — сохранение данных заявки в PostgreSQL
- Операции: `create_order(session, order_data)` — сохраняет запись заявки с данными `InitPaymentRequest` и `payment_url`
- Операции: `get_order_by_order_uuid(session, order_uuid)` — получает сохранённую заявку для payment notification flow
- Операции: `mark_payment_status(session, order_uuid, status, payment_id)` — сохраняет последний статус платежа TBank
- Операции: `mark_cdek_order_registered(session, order_uuid, cdek_order_uuid)` — сохраняет UUID заказа CDEK после успешной регистрации
- SQLAlchemy ORM model таблицы `orders` в `models.py`
- Без бизнес-решений; только CRUD-примитивы
### Adapter (`app/adapters/delivery_providers`) ### Adapter (`app/adapters/delivery_providers`)
- `base.py` — абстрактный интерфейс `DeliveryProvider`: - `base.py` — абстрактный интерфейс `DeliveryProvider`:
```python ```python
@@ -92,11 +115,33 @@
- `cdek/client.py` — HTTP-клиент (httpx AsyncClient), аутентификация, ретраи, таймаут (10с) - `cdek/client.py` — HTTP-клиент (httpx AsyncClient), аутентификация, ретраи, таймаут (10с)
- `cdek/auth.py` — управление OAuth2-токеном - `cdek/auth.py` — управление OAuth2-токеном
- `cdek/mapper.py` — ответ CDEK → `list[DeliveryPrice]` - `cdek/mapper.py` — ответ CDEK → `list[DeliveryPrice]`
- `cdek/order_mapper.py` — request/response mapping для регистрации заказа CDEK - `cdek/order_mapper.py` — request/response mapping для CDEK order contract; публичный `init-payment` flow не вызывает регистрацию заказа в CDEK
- Каждый адаптер владеет своей конфигурацией; наружу экспонирует только service-facing methods, необходимые соответствующему use-case - Каждый адаптер владеет своей конфигурацией; наружу экспонирует только service-facing methods, необходимые соответствующему use-case
- Для расчёта тарифа CDEK adapter принимает city identifiers из `DeliveryCalculationRequest`, находит запись в `cities_map`, берёт `cdek.code` и передаёт его в CDEK API - Для расчёта тарифа CDEK adapter принимает city identifiers из `DeliveryCalculationRequest`, находит запись в `cities_map`, берёт `cdek.code` и передаёт его в CDEK API
Для сценария создания заказа CDEK adapter принимает валидированную order model, отправляет контракт `Регистрация заказа (тип "доставка", до двери)` из `http-client.http` и возвращает внутреннюю response model без утечки HTTP-деталей в Service. Для CDEK order contract mapper принимает новый `InitPaymentRequest` и собирает provider payload:
- `number = orderUuid`, `type = 2`, `tariff_code = systemData.tariff.tariffCode`.
- `sender`/`recipient` маппятся из `senderContact`/`receiverContact`: `name = fullName`, `email`, `phones = [{number, additional: phoneExt}]`. При `isCompany=true` добавляются `contragent_type="LEGAL_ENTITY"`, `company`, `inn`, `kpp`.
- `from_location`/`to_location` собираются из `senderAddress`/`receiverAddress`: `code` берётся из `cities_map` по `cityId`, `address` склеивается строкой `"{city}, {street}, {house}, кв. {apartment}"` (хвост `кв.` опускается при пустом `apartment`), `postal_code = zip`.
- `packages[0]` содержит `number = orderUuid`, `weight = round(float(systemData.weight) * 1000)` в граммах; при `parcelType='parcel'` добавляются `length`, `width`, `height` из `systemData.dimensions`; при `parcelType='doc'` габариты не передаются.
- `packages[0].items[0]` пробрасывает `content.description` как `name`.
- `shipment_point.date = pickupDate.date()`, `delivery_point.date = deliveryDate.date()` (если задан).
- Поле верхнего уровня `comment` собирается из `content.description` (если есть).
- Возвращает `entity.uuid` строкой.
### Adapter (`app/adapters/tbank`)
- `base.py` — исключения TBank payment adapter
- `client.py` — `TBankAdapter`
- `TBankAdapter.create_payment_link(order_uuid: str, amount_kopecks: int) -> str` инициализирует платёж TBank и возвращает payment URL
- `TBankAdapter.verify_payment_notification(notification: TBankPaymentNotification) -> None` проверяет token HTTP-уведомления TBank по top-level scalar fields, исключая `Token` и вложенные объекты
- TBank adapter инкапсулирует HTTP-взаимодействие с TBank Init API, auth token, retries, timeout, serialization и error handling
- TBank adapter владеет собственной секцией конфигурации `tbank_payment` с полями `init_url`, `auth.terminal_key`, `auth.password`, `timeout_seconds`, `retry_attempts`, `retry_backoff_seconds`
### Adapter (`app/adapters/postgres`)
- Управление подключением к PostgreSQL: создание `AsyncEngine` и `async_sessionmaker` из конфигурации
- Инкапсулирует инфраструктурные детали SQLAlchemy async engine
- Владеет собственной секцией конфигурации `postgres` с обязательным полем `dsn`
- Без SQL-запросов, без бизнес-логики
### Adapter (`app/adapters/address_suggestions`) ### Adapter (`app/adapters/address_suggestions`)
- `base.py` — абстрактный интерфейс `AddressSuggestionProvider`: - `base.py` — абстрактный интерфейс `AddressSuggestionProvider`:
@@ -165,39 +210,89 @@ flat: str | None
postal_code: str | None postal_code: str | None
``` ```
### Входная: `OrderCreateRequest` ### Входная: `InitPaymentRequest`
Контракт ручки `/api/v1/delivery/order` использует camelCase в JSON; Pydantic-
модели хранят snake_case поля и принимают входной JSON через alias-generator.
``` ```
type: Literal[2] orderUuid: str
tariff_code: Literal[535] senderAddress:
cityId: int
city: str
street: str
house: str
apartment: str | None
zip: str
comment: str | None comment: str | None
sender: senderContact:
name: str fullName: str
email: str email: str | None
phones: list[{number: str}] phone: str
recipient: phoneExt: str | None
name: str isCompany: bool
email: str companyName: str | None # обязателен при isCompany=true
phones: list[{number: str}] inn: str | None # обязателен при isCompany=true
from_location: kpp: str | None # обязателен при isCompany=true
address: str receiverAddress: <структура senderAddress>
city: str receiverContact: <структура senderContact>
country_code: str content:
to_location: description: str | None
address: str pickupDate: datetime # ISO 8601
city: str deliveryDate: datetime | None # ISO 8601
country_code: str accountEmail: str
services: list[{code: str, parameter: str}] systemData:
packages: list[{number: str, weight: int, length: int, width: int, height: int, comment: str | None}] tariff:
```
`from_location.address` и `to_location.address` должны содержать точные значения адреса, выбранные клиентом; order flow не выполняет address suggestion lookup.
### Выходная: `OrderCreateResponse`
```
provider: str provider: str
order_uuid: str serviceName: str
price: int # копейки
deliveryDaysMin: int
deliveryDaysMax: int
tariffCode: int
parcelType: Literal[doc, parcel]
docPackaging: Literal[envelope, bag] | None # для doc
weight: str
dimensions: # null/отсутствует для doc
length: str
width: str
height: str
``` ```
`systemData.tariff.price` задаётся в копейках, является обязательным целым
числом и должен быть больше 0; backend использует его как сумму платежа
TBank без пересчёта.
`senderAddress.cityId` и `receiverAddress.cityId` используют общий справочник
`cities_map` (тот же идентификатор, что и в `DeliveryCalculationRequest`).
Для `parcelType='doc'` `systemData.dimensions` отсутствует или равен `null`,
для `parcelType='parcel'` — обязателен.
При `isCompany=true` поля `companyName`, `inn`, `kpp` обязательны.
`pickupDate` и `deliveryDate` принимаются и сохраняются как ISO datetime.
### Выходная: `InitPaymentResponse`
```
payment_url: str
```
### Входная: `TBankPaymentNotification`
```
TerminalKey: str
OrderId: str
Success: bool
Status: str
PaymentId: int
ErrorCode: str
Amount: int
Token: str
```
`PaymentId` приходит из TBank как JSON-число (long integer) и валидируется как положительное целое.
Модель уведомления должна допускать дополнительные top-level поля TBank. Проверка token использует все top-level scalar fields кроме `Token`, добавляет `Password` из `tbank_payment.auth.password`, сортирует ключи по алфавиту, конкатенирует строковые представления значений (булевы — как lowercase `true`/`false`) и сравнивает SHA-256 hash с `Token`. Вложенные объекты, включая `Data` и `Receipt`, не участвуют в проверке token.
### Выходная: TBank notification response
```
OK
```
Успешно обработанное HTTP-уведомление TBank возвращает `HTTP 200` и plain text body `OK`.
--- ---
## Технологический стек ## Технологический стек
@@ -208,6 +303,8 @@ order_uuid: str
| HTTP-клиент | httpx (async) | | HTTP-клиент | httpx (async) |
| Валидация | Pydantic v2 | | Валидация | Pydantic v2 |
| Кеш | Redis | | Кеш | Redis |
| База данных | PostgreSQL (asyncpg + SQLAlchemy 2.0 async) |
| Миграции | Alembic (async) |
| Конфигурация | pydantic-settings | | Конфигурация | pydantic-settings |
| Сервер | Uvicorn | | Сервер | Uvicorn |
@@ -241,15 +338,25 @@ app/
│ ├── auth.py │ ├── auth.py
│ ├── mapper.py │ ├── mapper.py
│ └── order_mapper.py │ └── order_mapper.py
│ └── tbank/
│ ├── base.py
│ └── client.py
│ └── postgres/
│ └── engine.py # AsyncEngine & session factory
├── repositories/ ├── repositories/
── cache/ ── cache/
└── redis_cache.py # Repository └── redis_cache.py # Repository (Redis)
│ └── order/
│ ├── models.py # SQLAlchemy ORM model
│ └── repository.py # Repository (PostgreSQL)
├── schemas/ ├── schemas/
│ ├── address.py │ ├── address.py
│ ├── request.py │ ├── request.py
│ ├── response.py │ ├── response.py
│ └── order.py │ └── payment.py
── config.py ── config.py
alembic/ # Alembic migrations
alembic.ini
``` ```
--- ---
@@ -260,4 +367,5 @@ app/
# docker-compose сервисы # docker-compose сервисы
app # FastAPI-приложение app # FastAPI-приложение
redis # Кеш тарифов redis # Кеш тарифов
postgres # База данных заявок
``` ```
@@ -0,0 +1,52 @@
---
id: 026
title: Align CDEK order contract with single phone and kilogram package weight
status: DONE
created: 2026-04-05
---
## Context
Текущий order flow принимает `sender.phones` и `recipient.phones` как список, требует обязательное поле `services` и пробрасывает `packages.weight` в CDEK payload без явной фиксации единиц измерения. Новый контракт должен принимать один телефон на сторону, разрешать отсутствие `services` и гарантировать, что во входном API вес упаковки задаётся в килограммах, а в CDEK отправляется в граммах.
## Goal
Обновить flow `POST /api/v1/delivery/order` по слоям Controller, Service и Adapter так, чтобы public/internal request contract использовал `phone` вместо `phones`, поле `services` было необязательным, а CDEK order mapper конвертировал `packages.weight` из килограммов во входном запросе в граммы во внешнем provider payload.
## Constraints
- Изменения ограничены существующими модулями order flow: `app/schemas/order.py`, `app/controllers/v1/delivery.py`, `app/services/aggregator.py`, `app/adapters/delivery_providers/cdek/`, `http-client.http` и связанными тестами.
- `POST /api/v1/delivery/order` должен оставаться в существующем controller и по-прежнему вызывать ровно один метод Service: `AggregatorService.create_order()`.
- Service остаётся orchestration layer и не получает новую business logic; он только принимает обновлённую order model и делегирует её adapter.
- Во внутреннем order flow и публичном API поле `phones` должно быть удалено; передача нескольких телефонов больше не поддерживается.
- CDEK adapter может сериализовать внутренний `phone` в provider-specific поле `phones`, если это требуется внешним контрактом CDEK, но множественность телефонов не должна возвращаться во внутренние модели и controller contract.
- `services` должно быть необязательным полем request schema; при отсутствии значения нельзя подставлять фиктивные service entries.
- Конвертация единиц `packages.weight` должна выполняться только на границе CDEK adapter mapping: входной order request использует килограммы, исходящий payload в CDEK использует граммы.
- Scope задачи не включает изменение response contract, price flow, address suggestion flow, provider routing, кеширование, новые провайдеры и расширение поддерживаемых `type`/`tariff_code`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- `OrderCreateRequest` использует `sender.phone` и `recipient.phone` вместо `sender.phones` и `recipient.phones`.
- Если request payload содержит `sender.phones` или `recipient.phones`, endpoint возвращает 422 на уровне schema validation.
- `POST /api/v1/delivery/order` принимает валидный payload без поля `services`.
- Если `services` отсутствует или равен `null`, service и adapter flow успешно обрабатывают запрос без добавления `services` в исходящий CDEK payload.
- `AggregatorService.create_order()` продолжает только оркестрировать вызов injected order adapter и не содержит преобразования `phone`/`phones` или килограммов в граммы.
- CDEK order mapper формирует provider payload с полями `sender.phones` и `recipient.phones`, каждое из которых содержит ровно один элемент, полученный из соответствующего внутреннего поля `phone`.
- Для каждого элемента `packages` исходящий payload CDEK содержит `weight`, равный значению входного `packages.weight`, умноженному на `1000`.
- `http-client.http` содержит актуальный пример Create Delivery Order с `phone` вместо `phones`, без обязательного `services` и с весом, отражающим controller contract в килограммах.
## Definition of Done
- [ ] Обновлён public/internal order request contract на `phone` вместо `phones`.
- [ ] `services` сделано необязательным без изменения endpoint path и service orchestration.
- [ ] Реализован mapping одного телефона в provider payload `phones` и конвертация `packages.weight` из килограммов в граммы.
- [ ] Обновлены controller, service и adapter tests под новый контракт и unit conversion.
- [ ] Обновлён пример запроса в `http-client.http`.
## Tests
- Обновить `tests/controllers/v1/test_order.py` для success case с `phone`, сценариев 422 при передаче `sender.phones` и `recipient.phones`, а также для запроса без `services`.
- Обновить `tests/services/test_order.py` для проверки, что service принимает обновлённую order model, делегирует её adapter без новой логики и корректно работает с `services=None`.
- Обновить `tests/adapters/delivery_providers/cdek/test_order_client.py` для проверки mapping `phone -> phones[0]`, отсутствия `services` в исходящем payload при `None` и конвертации `packages.weight` из килограммов в граммы.
- При необходимости обновить другие order-related tests и fixtures, завязанные на старые поля `phones` и обязательность `services`.
## Commands
- `poetry run pytest tests/controllers/v1/test_order.py -q`
- `poetry run pytest tests/services/test_order.py -q`
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_order_client.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,72 @@
---
id: 027
title: Add TBank payment adapter, init_payment endpoint and rename order flow
status: DONE
created: 2026-04-11
---
## Context
В проекте ещё нет платёжного адаптера, каталог `app/adapters/` содержит только `delivery_providers/` и `address_suggestions/`. Текущий endpoint `POST /api/v1/delivery/order` регистрирует заказ в CDEK. В новом флоу endpoint будет инициировать оплату через TBank и возвращать ссылку на оплату, без регистрации заказа в CDEK. В связи с изменением бизнес-смысла необходимо переименовать endpoint, сервисный метод, все DTO и модели флоу.
## Goal
1. Добавить новый payment adapter `TBankAdapter` для генерации ссылки на оплату TBank.
2. Переименовать endpoint `POST /api/v1/delivery/order``POST /api/v1/delivery/init-payment`.
3. Переименовать сервисный метод `AggregatorService.create_order()``AggregatorService.init_payment()`.
4. Переименовать все DTO и модели флоу: `OrderCreateRequest``InitPaymentRequest`, `OrderCreateResponse``InitPaymentResponse`, `InvalidOrderCreateRequestError``InvalidInitPaymentRequestError`, `OrderCreationUnavailableError``InitPaymentUnavailableError`.
5. Переименовать файл схем `app/schemas/order.py``app/schemas/payment.py`.
6. Добавить в `InitPaymentRequest` обязательное поле `price: int` (в копейках) и вызвать `payment_adapter.create_payment_link()` из `init_payment()`, вернув `payment_url` в `InitPaymentResponse`.
## Constraints
- Новый payment adapter должен располагаться в отдельном каталоге `app/adapters/tbank/` с модулями `base.py` и `client.py`; не размещать payment logic внутри `delivery_providers/` или `address_suggestions/`.
- Payment adapter MUST инкапсулировать HTTP взаимодействие с TBank API, auth, retries, serialization и error handling; наружу должен экспонироваться только service-facing method (`create_payment_link(order_uuid: str, amount_kopecks: int) -> str`), без утечки HTTP деталей в Service.
- Payment adapter MUST владеть собственной секцией конфигурации (`TBankPaymentConfig`) с обязательными полями auth и URL; конфигурация читается из `config.yaml`, который остаётся в `.gitignore`.
- `InitPaymentRequest` MUST содержать обязательное поле `price: int` в копейках с валидацией `gt=0`; единица измерения явно зафиксирована в schema и в `spec/overview.md`.
- `AggregatorService.init_payment()` MUST вызывать `payment_adapter.create_payment_link()`. Payment adapter передаётся в service через dependency injection (новый аргумент конструктора).
- Ошибки payment adapter MUST маппиться в `InvalidInitPaymentRequestError` (→ 400) и `InitPaymentUnavailableError` (→ 503) на уровне controller.
- Controller `POST /api/v1/delivery/init-payment` MUST вызывать ровно один метод Service (`AggregatorService.init_payment()`); старый endpoint `/order` удаляется, новые дополнительные endpoints запрещены.
- Service слой не содержит HTTP, retries или TBank-специфичных деталей.
- Scope задачи не включает: повторные попытки оплаты, refund flow, webhook обработку платёжных уведомлений, persistence заказов, изменения price flow, address suggestion flow, а также поддержку других платёжных провайдеров.
- Не изменять файлы в `spec/` кроме создания этой задачи и обновления `spec/overview.md` при необходимости (обновление выполняется Planner).
## Acceptance criteria
- В `app/adapters/tbank/` существуют `base.py` с исключениями адаптера и `client.py` с реализацией `TBankAdapter`.
- `TBankAdapter` реализует async метод `create_payment_link(order_uuid: str, amount_kopecks: int) -> str`, принимает идентификатор заказа и сумму в копейках и возвращает URL.
- `TBankAdapter` имеет собственную конфигурацию `TBankPaymentConfig` с обязательными полями auth и URL; конфигурация читается из yaml.
- Файл `app/schemas/order.py` переименован в `app/schemas/payment.py`; все импорты обновлены.
- `InitPaymentRequest` (бывший `OrderCreateRequest`) содержит обязательное поле `price: int` (в копейках) с валидацией `gt=0`; запрос без `price` или с нецелым/отрицательным значением возвращает 422.
- `InitPaymentResponse` (бывший `OrderCreateResponse`) содержит поле `payment_url: str` (минимум 1 символ) и возвращается из endpoint `POST /api/v1/delivery/init-payment`.
- `AggregatorService.init_payment()` (бывший `create_order()`) принимает `InitPaymentRequest`, вызывает `payment_adapter.create_payment_link()` с `order_uuid` и `price`, и возвращает `InitPaymentResponse` с `payment_url`.
- Сервисные исключения переименованы: `InvalidInitPaymentRequestError` (бывший `InvalidOrderCreateRequestError`), `InitPaymentUnavailableError` (бывший `OrderCreationUnavailableError`).
- Endpoint `POST /api/v1/delivery/order` удалён; endpoint `POST /api/v1/delivery/init-payment` возвращает `InitPaymentResponse`.
- Ошибки TBank provider request мапятся в 400, transport/недоступность — в 503 на уровне controller.
- Controller продолжает вызывать ровно один метод Service.
- Пример запроса в `http-client.http` обновлён: использует `/init-payment`, содержит поле `price` в копейках и отражает обновлённый response contract.
- `config.yaml` пример (в `.gitignore`) содержит секцию с параметрами TBank adapter.
## Definition of Done
- [ ] Создан модуль `app/adapters/tbank/` с `base.py` и `client.py`, реализующий `TBankAdapter`.
- [ ] Добавлена конфигурация TBank adapter (`TBankPaymentConfig`) в `app/config.py` и соответствующий пример в `config.yaml`.
- [ ] `app/schemas/order.py` переименован в `app/schemas/payment.py`; все импорты в контроллере, сервисе и тестах обновлены.
- [ ] `OrderCreateRequest``InitPaymentRequest`, `OrderCreateResponse``InitPaymentResponse` во всех файлах.
- [ ] `InvalidOrderCreateRequestError``InvalidInitPaymentRequestError`, `OrderCreationUnavailableError``InitPaymentUnavailableError` во всех файлах.
- [ ] `AggregatorService.create_order()``AggregatorService.init_payment()`; метод вызывает TBank adapter и возвращает `InitPaymentResponse`.
- [ ] Payment adapter injected в service через wiring в `app/controllers/v1/delivery.py::_build_aggregator_service`.
- [ ] Endpoint `/order` удалён, добавлен `/init-payment`; controller function переименована в `init_payment`.
- [ ] Controller маппит `InvalidInitPaymentRequestError` → 400, `InitPaymentUnavailableError` → 503.
- [ ] Обновлены unit tests для schemas, service, controller, а также добавлены unit tests для TBank adapter (HTTP client с stub transport).
- [ ] Обновлён пример запроса в `http-client.http`.
## Tests
- Добавить `tests/adapters/tbank/test_client.py` с проверкой: формирование payment request payload (auth, сумма в копейках, order uuid), маппинг успешного ответа в URL, маппинг 4xx в provider request error, маппинг 5xx/transport в client error, retry behavior если реализован.
- Переименовать `tests/services/test_order.py``tests/services/test_init_payment.py`; обновить: stub payment adapter; success case включает вызов payment adapter и возврат `payment_url`; маппинг payment adapter errors в `InvalidInitPaymentRequestError` / `InitPaymentUnavailableError`.
- Переименовать `tests/controllers/v1/test_order.py``tests/controllers/v1/test_init_payment.py`; обновить: endpoint `/init-payment`; payload содержит `price`; success response включает `payment_url`; запрос без `price` и с `price <= 0` возвращает 422; сценарии 400/503 продолжают работать.
- Обновить `tests/config/test_config_sections.py` если добавлена новая обязательная секция в yaml.
- При необходимости обновить `tests/smoke/test_app_import.py` для проверки wiring payment adapter.
## Commands
- `poetry run pytest tests/adapters/tbank/test_client.py -q`
- `poetry run pytest tests/services/test_init_payment.py -q`
- `poetry run pytest tests/controllers/v1/test_init_payment.py -q`
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,75 @@
---
id: 028
title: Add PostgreSQL adapter, order repository and persist order after payment link creation
status: DONE
created: 2026-04-12
---
## Context
После создания ссылки на оплату через TBank adapter данные заявки нигде не сохраняются. Необходимо добавить PostgreSQL-адаптер для управления подключением к базе данных, репозиторий для сохранения данных заявки и интегрировать сохранение в существующий flow `AggregatorService.init_payment()`. Зависимости `sqlalchemy` (2.0.49) и `asyncpg` (0.31.0) уже присутствуют в `pyproject.toml`. Alembic (1.18.4) также доступен для миграций.
## Goal
1. Добавить PostgreSQL-адаптер (`app/adapters/postgres/`) для управления async-сессиями SQLAlchemy (`AsyncEngine`, `async_sessionmaker`).
2. Добавить репозиторий заявок (`app/repositories/order/`) с методом `create_order()` для сохранения данных заявки вместе со ссылкой на оплату в PostgreSQL.
3. Определить SQLAlchemy model для таблицы заявок в `app/repositories/order/models.py`.
4. Создать Alembic-миграцию для создания таблицы заявок.
5. Расширить `AggregatorService.init_payment()`: после успешного получения `payment_url` от TBank adapter сохранять данные `InitPaymentRequest` вместе с `payment_url` через order repository.
6. Добавить секцию конфигурации PostgreSQL (`PostgresConfig`) в `app/config.py` и пример в `config.yaml`.
7. Добавить сервис PostgreSQL в `docker-compose.yml`.
## Constraints
- PostgreSQL adapter (`app/adapters/postgres/`) MUST содержать только управление подключением (engine, session factory). Без бизнес-логики, без SQL-запросов.
- Repository (`app/repositories/order/`) MUST содержать только операции с базой данных. Без бизнес-решений, без workflow-логики.
- Service MUST оркестрировать вызовы TBank adapter и order repository. Если сохранение в БД завершается ошибкой после успешного получения `payment_url`, Service MUST всё равно вернуть `payment_url` клиенту (сохранение не должно блокировать ответ); ошибку сохранения логировать.
- SQLAlchemy model MUST использовать `sqlalchemy.orm.DeclarativeBase` (SQLAlchemy 2.0 style).
- Для миграций использовать Alembic с async-конфигурацией (`asyncpg`).
- Конфигурация PostgreSQL MUST быть в отдельной секции `postgres` в `config.yaml` с обязательным полем `dsn`; `config.yaml` уже в `.gitignore`.
- `_RequiredYamlSections` в `app/config.py` MUST быть обновлён для включения секции `postgres`.
- Order repository передаётся в `AggregatorService` через dependency injection (новый параметр конструктора).
- PostgreSQL adapter создаётся в wiring (`_build_aggregator_service`) в controller и передаёт session factory в order repository.
- Таблица заявок MUST содержать как минимум: `id` (UUID, PK), `order_uuid` (str, unique), `payment_url` (str), `price` (int, копейки), `tariff_code` (int), `sender` (JSONB), `recipient` (JSONB), `from_location` (JSONB), `to_location` (JSONB), `packages` (JSONB), `services` (JSONB, nullable), `comment` (str, nullable), `created_at` (timestamp with timezone, server default).
- Scope НЕ включает: чтение/обновление/удаление заявок, API-endpoint для списка заявок, webhook-обработку платёжных уведомлений, изменения price flow, address suggestion flow.
- НЕ изменять существующие тесты TBank adapter, не изменять поведение price и address suggestion endpoints.
## Acceptance criteria
- В `app/adapters/postgres/` существует модуль с функцией создания `AsyncEngine` и `async_sessionmaker` из конфигурации.
- В `app/repositories/order/` существует `OrderRepository` с async-методом `create_order(session, order_data)`, сохраняющим запись заявки.
- В `app/repositories/order/models.py` определена SQLAlchemy ORM model таблицы `orders` со всеми обязательными полями.
- Alembic инициализирован с async-конфигурацией; существует миграция для создания таблицы `orders`.
- `AggregatorService.__init__()` принимает опциональный `order_repository` через DI.
- `AggregatorService.init_payment()` после успешного получения `payment_url` вызывает `order_repository.create_order()` с данными из `InitPaymentRequest` и `payment_url`.
- Если `order_repository.create_order()` выбрасывает исключение, `init_payment()` логирует ошибку и возвращает `InitPaymentResponse(payment_url=...)` без ошибки клиенту.
- В `app/config.py` добавлена `PostgresConfig` с полем `dsn: str`.
- Секция `postgres` присутствует в `_RequiredYamlSections`.
- В `docker-compose.yml` добавлен сервис `postgres` и `app` зависит от него.
- Wiring в `_build_aggregator_service` создаёт PostgreSQL engine, session factory, `OrderRepository` и передаёт его в `AggregatorService`.
- Запросы к эндпоинту `POST /api/v1/delivery/order` продолжают возвращать `InitPaymentResponse` с `payment_url`.
## Definition of Done
- [ ] Создан модуль `app/adapters/postgres/` с engine/session factory.
- [ ] Создан `app/repositories/order/repository.py` с `OrderRepository.create_order()`.
- [ ] Создан `app/repositories/order/models.py` с ORM model таблицы `orders`.
- [ ] Alembic инициализирован (`alembic.ini`, `alembic/`), создана миграция для таблицы `orders`.
- [ ] Добавлена `PostgresConfig` в `app/config.py`; `_RequiredYamlSections` обновлён.
- [ ] `AggregatorService` принимает `order_repository` через DI и использует его в `init_payment()`.
- [ ] Ошибки сохранения заявки не блокируют возврат `payment_url` клиенту.
- [ ] Обновлён wiring в `app/controllers/v1/delivery.py`.
- [ ] Добавлен сервис `postgres` в `docker-compose.yml`.
- [ ] `config.yaml` пример содержит секцию `postgres`.
- [ ] `config.test.yaml` содержит секцию `postgres` (может использовать sqlite или тестовый DSN).
- [ ] Все существующие тесты продолжают проходить.
- [ ] Добавлены новые тесты.
## Tests
- Добавить `tests/repositories/order/test_repository.py`: проверка `create_order()` с in-memory SQLite async engine (SQLAlchemy async); проверка, что все обязательные поля сохраняются; проверка обработки дублирования `order_uuid` (unique constraint).
- Обновить `tests/services/test_init_payment.py`: добавить test case, где `order_repository.create_order()` вызывается после успешного создания payment link; добавить test case, где `order_repository.create_order()` выбрасывает исключение, а `init_payment()` всё равно возвращает `payment_url`.
- Обновить `tests/config/test_config_sections.py` для проверки наличия секции `postgres` в yaml.
- При необходимости обновить `tests/smoke/test_app_import.py` для проверки wiring order repository.
## Commands
- `poetry run pytest tests/repositories/order/test_repository.py -q`
- `poetry run pytest tests/services/test_init_payment.py -q`
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `poetry run pytest -q`
- `python3 spec/gen_spec_index.py --check`
+54
View File
@@ -0,0 +1,54 @@
---
id: 029
title: Add TBank payment notification and success URLs
status: DONE
created: 2026-04-18
---
## Context
TBank Init API должен получать URLs для обработки webhook-уведомлений и возврата клиента после успешной оплаты. Сейчас `TBankAdapter` и секция `tbank_payment` не фиксируют передачу `NotificationURL` и `SuccessURL`.
## Goal
Добавить в конфигурацию TBank payment обязательные параметры `notification_url` и `success_url` и передавать их в payload инициализации платежа как `NotificationURL` и `SuccessURL`.
## Constraints
- Изменения ограничены TBank payment config, TBank adapter payload mapping, wiring конфигурации и тестами.
- `NotificationURL` и `SuccessURL` MUST приходить из секции `tbank_payment` YAML-конфига.
- TBank adapter MUST продолжать инкапсулировать HTTP-взаимодействие, auth token, retries, timeout, serialization и error handling.
- Service и Controller MUST NOT содержать TBank-specific payload fields или provider HTTP-детали.
- Не изменять публичный request/response contract `POST /api/v1/delivery/order`.
- Не изменять price flow, address suggestion flow, CDEK adapter и order repository.
- Не добавлять новые endpoints.
## Acceptance criteria
- `TBankPaymentConfig` содержит обязательные поля `notification_url: str` и `success_url: str`.
- Runtime YAML-конфиг и test YAML-конфиг содержат секцию `tbank_payment` с `notification_url` и `success_url`.
- `TBankAdapter` при вызове TBank Init API отправляет `NotificationURL` со значением `tbank_payment.notification_url`.
- `TBankAdapter` при вызове TBank Init API отправляет `SuccessURL` со значением `tbank_payment.success_url`.
- Auth token TBank формируется с учётом тех же полей payload, которые отправляются в Init API, включая `NotificationURL` и `SuccessURL`, если текущая реализация token generation строится по request payload.
- Отсутствие `notification_url` или `success_url` в YAML-конфиге приводит к детерминированной ошибке валидации конфигурации.
- `AggregatorService.init_payment()` продолжает вызывать `payment_adapter.create_payment_link(order_uuid, price)` без дополнительных URL-аргументов.
- Endpoint `POST /api/v1/delivery/order` продолжает возвращать только `InitPaymentResponse(payment_url=...)`.
## Definition of Done
- [ ] В `app/config.py` добавлены поля `notification_url` и `success_url` в TBank payment config.
- [ ] YAML-конфиги обновлены новыми параметрами TBank payment.
- [ ] `TBankAdapter` передаёт `NotificationURL` и `SuccessURL` в payload TBank Init API.
- [ ] Token generation для TBank request остаётся корректной после добавления новых payload fields.
- [ ] Service и Controller не знают о `NotificationURL` и `SuccessURL`.
- [ ] Добавлены или обновлены тесты конфигурации.
- [ ] Добавлены или обновлены тесты TBank adapter payload.
- [ ] Все команды из раздела Commands проходят.
## Tests
- Обновить `tests/config/test_config_sections.py`: проверить обязательность `tbank_payment.notification_url` и `tbank_payment.success_url`.
- Обновить `tests/adapters/tbank/test_client.py`: проверить, что successful Init request payload содержит `NotificationURL` и `SuccessURL` из конфигурации.
- Обновить `tests/adapters/tbank/test_client.py`: проверить, что token generation остаётся детерминированной после добавления новых payload fields.
- При необходимости обновить `tests/smoke/test_app_import.py`, если smoke config требует новые обязательные поля.
## Commands
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/adapters/tbank/test_client.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `poetry run pytest -q`
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,81 @@
---
id: 030
title: Add TBank payment notification webhook and CDEK order creation
status: DONE
created: 2026-04-18
---
## Context
TBank Init API уже получает `NotificationURL`, а данные заявки сохраняются в PostgreSQL после создания payment link. Сейчас приложение не принимает HTTP-уведомления TBank и не создаёт заказ в CDEK после подтверждения оплаты.
## Goal
Добавить `POST /api/v1/delivery/tbank/notifications`, который принимает payment notification от TBank, проверяет token уведомления, при валидном статусе `CONFIRMED` находит сохранённую заявку по `OrderId` и регистрирует заказ в CDEK. Валидные уведомления с другими статусами должны подтверждаться без регистрации заказа в CDEK.
## Constraints
- Controller отвечает только за routing, DTO validation и HTTP error mapping; endpoint вызывает ровно один метод Service: `AggregatorService.handle_tbank_payment_notification()`.
- Service выполняет только orchestration: verification через TBank adapter, выбор действия через pure Business Logic, чтение/обновление заявки через OrderRepository и вызов injected CDEK order adapter.
- Правило `CONFIRMED` -> создать заказ CDEK, остальные статусы -> подтвердить без CDEK должно быть pure Business Logic в `app/domain/`.
- TBank-specific token verification должна быть инкапсулирована в `app/adapters/tbank/` и использовать `tbank_payment.auth.password`.
- Проверка token MUST следовать контракту TBank HTTP-уведомлений: использовать top-level scalar fields кроме `Token`, не включать вложенные объекты (`Data`, `Receipt`), добавить `Password`, отсортировать ключи по алфавиту, конкатенировать значения, посчитать SHA-256 и сравнить с `Token`.
- Успешно обработанное уведомление MUST возвращать `HTTP 200` с plain text body `OK`.
- Валидные уведомления со статусами, отличными от `CONFIRMED`, MUST возвращать `OK` без чтения CDEK и без создания заказа.
- Повторное валидное уведомление `CONFIRMED` для заявки с уже сохранённым `cdek_order_uuid` MUST возвращать `OK` без повторного вызова CDEK.
- Если token невалиден, endpoint MUST возвращать deterministic `400` и не обращаться к repository или CDEK.
- Если заявка для `OrderId` не найдена или CDEK registration не завершилась успешно, endpoint MUST не возвращать `OK`, чтобы TBank мог повторить notification delivery.
- Repository содержит только CRUD/query/update primitives; без workflow logic и business decisions.
- CDEK order payload MUST передавать `order_uuid` как external идентификатор заказа CDEK (поле `number` в CDEK order contract), чтобы повторные вызовы CDEK при HTTP timeout/ретрае обрабатывались идемпотентно на стороне CDEK и никогда не создавали дубль заказа.
- Service MUST трактовать ответ CDEK "заказ с таким external id уже существует" (возврат существующего `entity.uuid` либо CDEK-specific duplicate response) как успех, сохранять возвращённый `cdek_order_uuid` и отвечать `OK`, а не как ошибку с повторной регистрацией.
- Не изменять public contract `POST /api/v1/delivery/order`, price flow и address suggestion flow.
- Не добавлять новые payment providers, refund flow, recurring payments, ручной retry endpoint или endpoint чтения заявок.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Существует schema `TBankPaymentNotification` с обязательными полями `TerminalKey`, `OrderId`, `Success`, `Status`, `PaymentId`, `ErrorCode`, `Amount`, `Token` и поддержкой дополнительных top-level полей TBank. `PaymentId` валидируется как положительное целое (TBank присылает long integer) и сохраняется в `tbank_payment_id` колонке `BIGINT`.
- `POST /api/v1/delivery/tbank/notifications` реализован в существующем controller `app/controllers/v1/delivery.py` и возвращает `PlainTextResponse("OK")` при успешной обработке.
- Controller делегирует обработку ровно в `AggregatorService.handle_tbank_payment_notification()` и не содержит branching по TBank status.
- `TBankAdapter` умеет проверять token payment notification через `tbank_payment.auth.password`; невалидный token маппится в service/controller error path без repository/CDEK side effects.
- В `app/domain/` есть pure function, которая для `Status == "CONFIRMED"`, `Success == true` и `ErrorCode == "0"` возвращает действие регистрации CDEK, а для остальных статусов возвращает действие acknowledge-only.
- `OrderRepository` предоставляет primitive для получения заявки по `order_uuid`, сохранения последнего TBank payment status/payment id и сохранения `cdek_order_uuid`.
- Таблица `orders` содержит минимум поля `payment_status`, `tbank_payment_id` (BIGINT), `cdek_order_uuid`, `updated_at` в исходной миграции; отдельная миграция не вводится, так как production БД ещё не развёрнута.
- При валидном `CONFIRMED` notification service получает order по `OrderId`, реконструирует `InitPaymentRequest` из сохранённых данных, вызывает injected CDEK order adapter registration и сохраняет полученный `cdek_order_uuid`.
- Если для `OrderId` уже сохранён `cdek_order_uuid`, повторный `CONFIRMED` notification возвращает `OK` без повторного вызова CDEK.
- CDEK order mapper проставляет `InitPaymentRequest.order_uuid` в поле external номера заказа CDEK (`number`), так что повторный POST с тем же external id не создаёт второй заказ в CDEK.
- Если CDEK create фактически завершился успешно, но сохранение `cdek_order_uuid` в DB не прошло, следующий валидный `CONFIRMED` notification MUST завершиться сохранением того же `cdek_order_uuid` (полученного по тому же external id) без создания второго заказа в CDEK.
- Валидные notification со статусами `AUTHORIZED`, `REJECTED`, `CANCELED`, `DEADLINE_EXPIRED` и неизвестными status values возвращают `OK` без регистрации CDEK order.
- Ошибка поиска заявки или ошибка CDEK registration возвращает deterministic non-OK HTTP response и логируется.
- `NotificationURL` в runtime/test/example конфигурации, если он присутствует в репозитории, указывает на `/api/v1/delivery/tbank/notifications`.
## Definition of Done
- [ ] Добавлена schema `TBankPaymentNotification`.
- [ ] Добавлена pure Business Logic для выбора действия по TBank payment notification status.
- [ ] Добавлена token verification logic в TBank adapter.
- [ ] Расширена SQLAlchemy model и обновлена существующая Alembic migration `20260412_028_create_orders_table` payment/CDEK status fields (production БД отсутствует, новая миграция не нужна).
- [ ] Расширен `OrderRepository` primitives для read/update операций, нужных webhook flow.
- [ ] Реализован `AggregatorService.handle_tbank_payment_notification()` с DI dependencies.
- [ ] Добавлен endpoint `POST /api/v1/delivery/tbank/notifications` в существующий delivery controller.
- [ ] Wiring использует существующие `TBankAdapter`, `OrderRepository` и CDEK provider/adapter без provider HTTP details в Service/Controller.
- [ ] Повторные `CONFIRMED` notification обрабатываются идемпотентно.
- [ ] CDEK order mapper проставляет `order_uuid` в поле external номера заказа CDEK.
- [ ] Service корректно обрабатывает ответ CDEK "заказ уже существует" как успех и сохраняет возвращённый `cdek_order_uuid`.
- [ ] Валидные non-`CONFIRMED` statuses подтверждаются без CDEK side effects.
- [ ] Обновлены tests для domain, adapter, repository, service, controller и smoke wiring.
- [ ] Все команды из раздела Commands проходят.
## Tests
- Добавить `tests/domain/test_payment_notifications.py`: `CONFIRMED` + `Success=true` + `ErrorCode=0` -> create CDEK order action; остальные known/unknown statuses -> acknowledge-only.
- Обновить или добавить `tests/adapters/tbank/test_notifications.py`: valid token, invalid token, exclusion of `Token`, exclusion of nested `Data`/`Receipt`, inclusion of extra scalar fields, deterministic SHA-256 comparison.
- Обновить `tests/repositories/order/test_repository.py`: получение заявки по `order_uuid`, сохранение `payment_status`/`tbank_payment_id`, сохранение `cdek_order_uuid`, duplicate/idempotency scenario.
- Обновить `tests/adapters/delivery_providers/cdek/test_order_mapper.py`: CDEK order payload содержит `InitPaymentRequest.order_uuid` в поле external номера заказа (`number`).
- Добавить `tests/services/test_tbank_notifications.py`: valid `CONFIRMED` вызывает repository lookup, CDEK registration и save `cdek_order_uuid`; duplicate `CONFIRMED` не вызывает CDEK; non-`CONFIRMED` возвращает `OK` без CDEK; invalid token не обращается к repository; missing order/CDEK failure возвращает service error; сценарий "CDEK create succeeded, save `cdek_order_uuid` raised" — следующий `CONFIRMED` notification вызывает CDEK повторно с тем же external id, получает тот же `cdek_order_uuid`, сохраняет его и возвращает `OK` (суммарно ровно один реальный заказ в CDEK).
- Добавить `tests/controllers/v1/test_tbank_notifications.py`: endpoint возвращает plain text `OK` для successful service response, 400 для invalid token, 503 для temporary processing failure, controller делегирует ровно один service method.
- Обновить `tests/smoke/test_app_import.py` при необходимости для проверки wiring нового endpoint.
## Commands
- `poetry run pytest tests/domain/test_payment_notifications.py -q`
- `poetry run pytest tests/adapters/tbank/test_notifications.py -q`
- `poetry run pytest tests/repositories/order/test_repository.py -q`
- `poetry run pytest tests/services/test_tbank_notifications.py -q`
- `poetry run pytest tests/controllers/v1/test_tbank_notifications.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `poetry run pytest -q`
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,71 @@
---
id: 031
title: Validate init-payment price with CDEK tariff
status: TODO
created: 2026-04-18
---
## Context
`POST /api/v1/delivery/order` сейчас принимает `price` от клиента и использует это значение как сумму платежа TBank. Клиент может передать произвольную сумму, не соответствующую тарифу CDEK.
## Goal
Перед созданием ссылки на оплату валидировать `InitPaymentRequest.price` через CDEK `POST /calculator/tarifflist` для данных заявки. Если сумма не совпадает с валидированной суммой CDEK, не создавать payment link и вернуть детерминированную ошибку.
## Constraints
- Controller не должен содержать business logic или provider-specific branching.
- Endpoint `POST /api/v1/delivery/order` должен по-прежнему вызывать ровно один метод Service: `AggregatorService.init_payment()`.
- Service выполняет только orchestration: вызывает injected CDEK adapter для расчёта цены, делегирует сравнение в Business Logic, затем вызывает TBank adapter только при успешной валидации.
- Pure правило сравнения цены должно жить в `app/domain/`: сравнение выполняется в копейках, с применением существующего provider price multiplier и округления `ROUND_HALF_UP`.
- Внешний IO для CDEK должен оставаться только в CDEK adapter; TBank-specific logic остаётся только в TBank adapter.
- CDEK validation MUST использовать существующий endpoint CDEK `POST /calculator/tarifflist`; запрещено использовать `/orders`, `/calculator/tariff` или registration flow для проверки цены.
- CDEK adapter должен переиспользовать существующие auth, retry, timeout, HTTP error handling и response mapping для tariff list там, где это совместимо с `InitPaymentRequest`.
- CDEK validation должна использовать данные существующего `InitPaymentRequest`: `tariff_code`, `from_location`, `to_location`, `packages` и `services` при наличии.
- Публичный request/response contract `POST /api/v1/delivery/order` не менять, кроме ужесточения runtime validation поля `price`.
- При mismatch цены нельзя вызывать TBank adapter и нельзя сохранять заявку в PostgreSQL.
- При ошибке CDEK validation нельзя вызывать TBank adapter и нельзя сохранять заявку в PostgreSQL.
- Не изменять price flow, address suggestion flow, payment notification webhook flow и CDEK order registration flow.
- Не добавлять новые endpoints, payment providers, ручной retry endpoint или endpoint чтения заявок.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- CDEK adapter предоставляет service-facing метод для расчёта валидной цены доставки по `InitPaymentRequest` через `POST /calculator/tarifflist` без регистрации заказа в CDEK.
- Новый метод переиспользует существующую tariff-list инфраструктуру CDEK adapter: OAuth2 token, retry/timeout policy, handling 4xx/5xx/transport errors и mapping `tariff_codes` в internal price model.
- CDEK validation request использует `request.tariff_code`, `from_location`, `to_location`, `packages` и `services` из `InitPaymentRequest`.
- CDEK validation request отправляется на тот же configured base URL path `/calculator/tarifflist`, который используется текущим CDEK price flow.
- Если CDEK не возвращает цену для `request.tariff_code`, service завершает `init_payment()` детерминированной `InvalidInitPaymentRequestError`.
- Business Logic рассчитывает expected payment amount в копейках: provider price в `RUB` после существующего multiplier и `ROUND_HALF_UP` конвертируется в копейки и сравнивается с `InitPaymentRequest.price`.
- Если `InitPaymentRequest.price` не равен expected amount, `AggregatorService.init_payment()` поднимает `InvalidInitPaymentRequestError`.
- При успешной price validation `AggregatorService.init_payment()` вызывает TBank adapter с исходным `order_uuid` и валидированным `price`.
- При успешной price validation и успешном TBank response сохранение заявки в PostgreSQL остаётся прежним.
- При CDEK provider request error service маппит ошибку в `InvalidInitPaymentRequestError`.
- При CDEK transport, timeout или 5xx error service маппит ошибку в `InitPaymentUnavailableError`.
- Controller маппит `InvalidInitPaymentRequestError` в HTTP 400 и `InitPaymentUnavailableError` в HTTP 503 по текущему error contract.
- Tests подтверждают, что при mismatch цены TBank adapter и OrderRepository не вызываются.
## Definition of Done
- [ ] Добавлен service-facing CDEK adapter method для расчёта цены по `InitPaymentRequest` через `POST /calculator/tarifflist`.
- [ ] Метод переиспользует существующие auth, retry, timeout, error handling и tariff response mapping CDEK adapter.
- [ ] Добавлена pure Business Logic для сравнения requested price с CDEK validated price в копейках.
- [ ] `AggregatorService.init_payment()` выполняет CDEK price validation до вызова TBank adapter.
- [ ] Ошибки CDEK validation маппятся в существующие service/controller error paths.
- [ ] TBank adapter вызывается только после успешной CDEK price validation.
- [ ] OrderRepository вызывается только после успешной CDEK price validation и успешного получения `payment_url`.
- [ ] Публичный contract `POST /api/v1/delivery/order` не изменён, кроме runtime rejection невалидной цены.
- [ ] Обновлены tests для domain, CDEK adapter, service и controller.
- [ ] Все команды из раздела Commands проходят.
## Tests
- Добавить или обновить `tests/domain/test_payment_price_validation.py`: exact match, mismatch, multiplier с `ROUND_HALF_UP`, конвертация `RUB` в копейки, не-`RUB` currency как deterministic validation failure.
- Добавить или обновить `tests/adapters/delivery_providers/cdek/test_payment_price_validation.py`: validation request отправляется на `/calculator/tarifflist`; payload строится из `InitPaymentRequest`, используется `tariff_code`, `packages`, `services`, `from_location`, `to_location`; success/error mapping покрыт stubs/mocks.
- Обновить `tests/services/test_init_payment.py`: success вызывает CDEK validation до TBank; mismatch возвращает `InvalidInitPaymentRequestError` без TBank и repository calls; CDEK request error маппится в `InvalidInitPaymentRequestError`; CDEK client error маппится в `InitPaymentUnavailableError`.
- Обновить `tests/controllers/v1/test_init_payment.py`: price mismatch возвращает HTTP 400; временная ошибка CDEK validation возвращает HTTP 503; controller продолжает делегировать ровно один service method.
- При необходимости обновить `tests/smoke/test_app_import.py` для wiring нового CDEK validation dependency.
## Commands
- `poetry run pytest tests/domain/test_payment_price_validation.py -q`
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_payment_price_validation.py -q`
- `poetry run pytest tests/services/test_init_payment.py -q`
- `poetry run pytest tests/controllers/v1/test_init_payment.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `poetry run pytest -q`
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,102 @@
---
id: 032
title: Rework init-payment contract to camelCase and structured address/contact
status: TODO
created: 2026-05-13
---
## Context
Фронт переходит на новый контракт ручки `POST /api/v1/delivery/order`. В нём
адрес/контакт структурированы по-новому, появились флаг юр-лица и реквизиты
(`isCompany`/`companyName`/`inn`/`kpp`/`phoneExt`), описание груза и вес
(`content.description`), даты `pickupDate`/`deliveryDate`,
`accountEmail`, блок `systemData` с зафиксированным тарифом, parcelType,
docPackaging и dimensions. Поле наименования — camelCase. Это breaking change
без обратной совместимости.
## Goal
Привести бэкенд к новому контракту `/api/v1/delivery/order`, сохранив текущий
flow «валидация цены через CDEK → создание payment URL в TBank → persist в
PostgreSQL → возврат `payment_url`; webhook `CONFIRMED` → регистрация заказа в
CDEK».
## Constraints
- camelCase в API; внутренние имена остаются Python-friendly через Pydantic
alias-generator. JSON принимается ТОЛЬКО в camelCase.
- `orderUuid` и `systemData.tariff.tariffCode` приходят от фронта.
- `systemData.tariff.price` — итоговая сумма в копейках; backend использует её
как `amount_kopecks` для TBank без пересчёта.
- Расширить `senderAddress` и `receiverAddress` обязательным полем `cityId: int`
(то же, что в `/price`), чтобы резолвить CDEK city code из `cities_map`.
- При `isCompany=true` поля `companyName`, `inn`, `kpp` обязательны.
- При `parcelType='doc'` `dimensions` принимает значение `null`; CDEK packages
отправляются без length/width/height.
- При `parcelType='parcel'` `dimensions` обязателен.
- Адрес для CDEK собирается строкой `"{city}, {street}, {house}, кв. {apartment}"`;
если `apartment` пустой/отсутствует — без хвоста `, кв. ...`.
- `pickupDate` → CDEK `shipment_point.date`; `deliveryDate` → CDEK
`delivery_point.date` (если задан).
- `content.description` пробрасывается в CDEK `packages[0].items[0].name` и
`comment` верхнего уровня.
- `phoneExt` пробрасывается в CDEK `phones[0].additional`.
- Сохранять весь принятый payload в JSONB-колонке `payload` записи `orders`.
- ORM-модель `orders` рефакторится: вместо колонок `sender/recipient/
from_location/to_location/packages/services/comment/delivery_type` —
одна колонка `payload JSONB NOT NULL`, плюс `account_email VARCHAR`.
Колонки `order_uuid`, `payment_url`, `price`, `tariff_code`, `payment_status`,
`tbank_payment_id`, `cdek_order_uuid`, `created_at`, `updated_at` сохраняются.
## Acceptance criteria
- `POST /api/v1/delivery/order` принимает новый camelCase payload и возвращает
`InitPaymentResponse` без изменений (`{ "payment_url": str }`).
- Pydantic-модели валидируют: обязательность реквизитов юр-лица при
`isCompany=true`; `parcelType='doc'` ⇒ `dimensions=null`/отсутствует;
`parcelType='parcel'` ⇒ `dimensions` обязателен; `price > 0`.
- Service вызывает `payment_price_validation_adapter.get_payment_price(request)`
и `payment_adapter.create_payment_link(order_uuid, amount_kopecks)` с
`amount_kopecks = request.systemData.tariff.price`.
- CDEK order mapper из нового `InitPaymentRequest` формирует payload с полями:
`number`, `type=2`, `tariff_code`, `sender`, `recipient`,
`from_location.code/address/postal_code`, `to_location.code/address/postal_code`,
`packages[0]` с `weight` (граммы) и опциональными dimensions,
`packages[0].items` с описанием груза, `shipment_point.date`,
`delivery_point.date` (если есть), `comment`.
- При `isCompany=true` CDEK получает `contragent_type="LEGAL_ENTITY"`, `company`,
`inn`, `kpp` для соответствующей стороны.
- ORM `Order` хранит весь payload в `payload` JSONB; репозиторий сохраняет и
читает его без потерь; миграция переводит таблицу со старой структуры на
новую (drop старые колонки, добавить `payload`, `account_email`).
## Definition of Done
- [ ] Pydantic-модели `payment.py` переписаны под новый camelCase-контракт с
обязательными валидациями.
- [ ] CDEK order mapper и `_build_payment_price_payload` собирают payload из
новой структуры.
- [ ] `AggregatorService.init_payment` использует `systemData.tariff.price` и
`orderUuid` напрямую; persist использует обновлённый `OrderData`.
- [ ] ORM-модель `Order`, `OrderData` и репозиторий перешли на `payload` JSONB
и `account_email`.
- [ ] Alembic-миграция `20260513_032_rework_orders_payload.py` применяется и
откатывается.
- [ ] Тесты на schemas, mapper, payment validation, controller и service
обновлены и проходят.
- [ ] `http-client.http` обновлён под новый payload.
## Tests
- `tests/controllers/v1/test_init_payment.py` — fixture/assertion обновлены под
camelCase; добавлены кейсы валидации юр-лица и doc/parcel dimensions.
- `tests/adapters/delivery_providers/cdek/test_order_mapper.py` —
mapping `phoneExt → additional`, контрагент юр-лица, address composition,
doc без dimensions, shipment/delivery point dates.
- `tests/adapters/delivery_providers/cdek/test_payment_price_validation.py` —
validation payload собирается из `senderAddress.cityId`, `systemData.weight`,
`systemData.dimensions`.
- `tests/services/test_init_payment.py` — `init_payment` берёт сумму из
`systemData.tariff.price`, `orderUuid` пробрасывается.
- `tests/repositories/order/test_repository.py` (если есть) — обновлён под
новую модель `Order`.
## Commands
- `pytest tests/`
- `alembic upgrade head` (smoke на пустой базе)
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,94 @@
---
id: 033
title: Add CDEK waybill polling worker
status: DONE
created: 2026-05-23
---
## Context
`POST /v2/orders` в CDEK асинхронный. Когда `entity.related_entities[type=waybill].uuid`
синхронно приходит в ответе, поле `url` практически всегда пустое — PDF генерится
позже и его url доступен только при `GET /v2/print/orders/{waybill_uuid}` после
перехода накладной в `READY`. Бывают и случаи, когда waybill_uuid тоже не приходит
синхронно, или заказ уходит в терминальный `INVALID` без генерации накладной.
Ранее `_save_cdek_order_uuid` (`app/services/aggregator.py`) писал waybill-поля из
синхронного ответа POST, из-за чего запись прыгала между двумя источниками и
требовала покрытия граничных случаев в двух местах.
## Goal
Перевести запись waybill-данных под единоличную ответственность отдельного
фонового worker-а. При регистрации заказа сохранять только `cdek_order_uuid`,
а waybill_uuid / waybill_url пополнять поллингом до тех пор, пока заказ не
дойдёт до waybill_url IS NOT NULL либо до терминального статуса.
## Constraints
- Запуск — отдельный процесс `python -m app.workers.waybill_poller`, отдельный
сервис в `docker-compose.yml`, одна реплика.
- Никаких записей waybill-полей при регистрации заказа: `_save_cdek_order_uuid`
должен сохранять только `cdek_order_uuid`.
- Поллер — единственный writer полей `cdek_order_status`, `cdek_waybill_uuid`,
`cdek_waybill_url`, `cdek_polled_at`.
- На терминальных статусах заказа (`INVALID`, `DELIVERED`, `NOT_DELIVERED`,
`CANCELLED`) опросы по этому заказу прекращаются.
- Бизнес-логика терминальности — pure функция в `app/domain/cdek_polling.py`,
без зависимостей на репозиторий/адаптер.
- Соблюсти слои AGENTS.md: новые HTTP-вызовы CDEK живут только в адаптере,
работа с БД — только в репозитории, оркестрация — в сервисе.
## Acceptance criteria
- При успешной регистрации заказа в БД заполняется только `cdek_order_uuid`;
`cdek_waybill_uuid` и `cdek_waybill_url` остаются `NULL`.
- Worker раз в `waybill_poller.interval_seconds` секунд выбирает заказы с
`cdek_order_uuid IS NOT NULL AND cdek_waybill_url IS NULL` и нетерминальным
`cdek_order_status` (с упорядочиванием `cdek_polled_at NULLS FIRST` и лимитом
`waybill_poller.batch_size`).
- Если у заказа `cdek_waybill_uuid IS NULL`, worker делает `GET /v2/orders/{uuid}`
и сохраняет последний статус заказа + waybill_uuid из `related_entities`.
- Если `cdek_waybill_uuid IS NOT NULL` и url пуст, worker делает
`GET /v2/print/orders/{waybill_uuid}` и сохраняет `entity.url`.
- Ошибка CDEK по одному заказу логируется и не валит весь батч.
- Worker корректно завершает работу по SIGTERM/SIGINT: закрывает HTTP-клиент и
async engine.
## Definition of Done
- [ ] Миграция `20260524_034_add_cdek_polling_columns.py` добавляет
`cdek_order_status` и `cdek_polled_at`; модель синхронизирована.
- [ ] `_save_cdek_order_uuid` и `OrderRepository.mark_cdek_order_registered`
больше не принимают waybill-поля.
- [ ] `CDEKClient.get_order` / `get_waybill` и соответствующие мапперы
(`map_cdek_order_info_response`, `map_cdek_waybill_info_response`,
dataclasses `CDEKOrderInfo`, `CDEKWaybillInfo`) реализованы; `CDEKProvider`
предоставляет обёртки.
- [ ] `app/domain/cdek_polling.py` содержит `TERMINAL_ORDER_STATUSES` и
`is_terminal_order_status`.
- [ ] `OrderRepository.list_orders_pending_waybill`, `record_order_poll`,
`record_waybill_poll` реализованы.
- [ ] `WaybillPollerService.poll_once` и `run_forever` реализованы.
- [ ] `app/workers/waybill_poller.py` поднимает зависимости, поддерживает
graceful shutdown по сигналу.
- [ ] `config.example.yaml` содержит секцию `waybill_poller`, `app/config.py`
`WaybillPollerConfig`.
- [ ] `docker-compose.yml` содержит сервис `waybill-poller`.
## Tests
- `tests/domain/test_cdek_polling.py` — терминальные/нетерминальные статусы.
- `tests/adapters/delivery_providers/cdek/test_order_mapper.py` — мапперы
ответов GET /v2/orders/{uuid} и GET /v2/print/orders/{uuid}.
- `tests/adapters/delivery_providers/cdek/test_order_info_client.py`
`CDEKClient.get_order` и `get_waybill`: 200/4xx/5xx, ретраи, парсинг.
- `tests/repositories/order/test_repository.py``list_orders_pending_waybill`,
`record_order_poll`, `record_waybill_poll` (фильтр, упорядочивание, защита
waybill_uuid/waybill_url от перезаписи).
- `tests/services/test_waybill_poller.py``poll_once` на стабах (два пути,
терминальный статус, изоляция ошибок), `run_forever` завершается по
stop_event.
- `tests/services/test_tbank_notifications.py` — регистрация заказа больше не
сохраняет waybill-поля.
- `tests/workers/test_waybill_poller_main.py``_run` собирает зависимости и
завершается по stop_event.
## Commands
- `poetry run pytest -q`
- `poetry run alembic upgrade head`
- `poetry run python -m app.workers.waybill_poller`
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,87 @@
---
id: 034
title: Add CDEK waybill e-mail sender worker
status: DONE
created: 2026-05-23
---
## Context
После задачи 033 фоновый `WaybillPollerService` дотягивает у CDEK
`cdek_waybill_url` для зарегистрированных заказов и на этом цепочка
обрывается: PDF-накладная остаётся доступной только по URL, клиенту на почту
не отправляется. Адрес получателя уже сохраняется в `Order.account_email`
(приходит из `InitPaymentRequest.account_email`).
## Goal
Добавить отдельный фоновый сервис и worker, который для заказов с
`cdek_waybill_url IS NOT NULL AND waybill_email_sent_at IS NULL` скачивает PDF
накладной у CDEK и отправляет его вложением на `Order.account_email`.
## Constraints
- Запуск — отдельный процесс `python -m app.workers.waybill_email_sender`,
отдельный сервис в `docker-compose.yml`, одна реплика, тот же
`config.yaml`, что и у `waybill-poller`.
- Поллер waybill-данных (033) не меняем; новый сервис — единственный writer
колонки `waybill_email_sent_at`.
- Соблюдение слоёв AGENTS.md: SMTP — только в адаптере, скачивание PDF —
в CDEK-адаптере, работа с БД — в репозитории, оркестрация — в сервисе.
- Текст письма (тема, тело, имя вложения) — приватные константы сервиса,
без отдельного domain-модуля и без шаблонов в config.
- На вход (POST /api/v1/delivery/order) `accountEmail` валидируется как
`pydantic.EmailStr` — заведомо некорректный адрес отбрасывается на
контроллере и не доходит до воркера.
## Acceptance criteria
- При появлении у заказа `cdek_waybill_url` worker в течение
`waybill_email_sender.interval_seconds` скачивает PDF и отправляет письмо
на `Order.account_email`, после чего проставляет `waybill_email_sent_at`.
- Письмо содержит тему `Накладная по заказу {order_uuid}`, тело — краткое
уведомление со ссылкой на `cdek_waybill_url`, и вложение
`waybill_{order_uuid}.pdf` (`application/pdf`).
- Повторный запуск worker-а не приводит к повторной отправке (фильтр
`waybill_email_sent_at IS NULL`).
- Ошибка скачивания PDF или SMTP по одному заказу логируется и не валит
батч; заказ остаётся pending и будет переотправлен в следующем тике.
- `POST /api/v1/delivery/order` с заведомо невалидным `accountEmail`
возвращает 422.
- Worker корректно завершает работу по SIGTERM/SIGINT: закрывает HTTP-
клиент и async engine.
## Definition of Done
- [ ] Миграция `20260524_035_add_waybill_email_sent_at.py` добавляет
`waybill_email_sent_at`; модель синхронизирована.
- [ ] `OrderRepository.list_orders_pending_waybill_email` и
`record_waybill_email_sent` реализованы.
- [ ] `CDEKClient.download_waybill_pdf(url)` реализован.
- [ ] Адаптер `app/adapters/email/smtp_client.py` (`aiosmtplib`) реализован.
- [ ] `WaybillEmailSenderService.poll_once` и `run_forever` реализованы.
- [ ] `app/workers/waybill_email_sender.py` поднимает зависимости и
поддерживает graceful shutdown.
- [ ] `config.example.yaml` содержит секции `email` и
`waybill_email_sender`; `app/config.py``EmailAdapterConfig` и
`WaybillEmailSenderConfig`.
- [ ] `docker-compose.yml` содержит сервис `waybill-email-sender`.
- [ ] `pyproject.toml` содержит `aiosmtplib` и `email-validator`.
- [ ] `InitPaymentRequest.account_email` имеет тип `EmailStr`.
## Tests
- `tests/adapters/email/test_smtp_client.py` — корректное MIME-сообщение,
вложение PDF, проброс SMTP-ошибок.
- `tests/adapters/delivery_providers/cdek/test_waybill_download_client.py`
`download_waybill_pdf`: 200 → bytes, 4xx/5xx, ретраи, auth header.
- `tests/repositories/order/test_repository.py` — расширить:
`list_orders_pending_waybill_email` (фильтр и порядок по `created_at`),
`record_waybill_email_sent` (защита от перезаписи).
- `tests/services/test_waybill_email_sender.py``poll_once` на стабах:
успешная отправка, ошибка скачивания PDF, ошибка SMTP, изоляция ошибок
между заказами, `run_forever` завершается по `stop_event`.
- `tests/workers/test_waybill_email_sender_main.py``_run` собирает
зависимости и завершается по `stop_event`.
- `tests/controllers/v1/test_init_payment.py` — 422 на невалидный
`accountEmail`.
## Commands
- `poetry run pytest -q`
- `poetry run alembic upgrade head`
- `poetry run python -m app.workers.waybill_email_sender`
- `python3 spec/gen_spec_index.py --check`
View File
@@ -296,11 +296,27 @@ adapter:
cdek_timeout_seconds: 7.5 cdek_timeout_seconds: 7.5
cdek_cache_ttl_seconds: 777 cdek_cache_ttl_seconds: 777
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
success_url: "https://merchant.test/payment/success"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
postgres:
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/cdek_adapter_test"
observability: observability:
enabled: false enabled: false
service_name: "cdek-adapter-test-service" service_name: "cdek-adapter-test-service"
otlp_endpoint: "http://collector:4317" otlp_endpoint: "http://collector:4317"
otlp_insecure: true otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
""".strip(), """.strip(),
encoding="utf-8", encoding="utf-8",
) )
@@ -10,6 +10,7 @@ def test_map_cdek_response_maps_all_tariffs_to_unified_model() -> None:
"currency": "usd", "currency": "usd",
"tariff_codes": [ "tariff_codes": [
{ {
"tariff_code": 7,
"tariff_name": "Express", "tariff_name": "Express",
"delivery_sum": "1234.50", "delivery_sum": "1234.50",
"currency": "rub", "currency": "rub",
@@ -33,6 +34,25 @@ def test_map_cdek_response_maps_all_tariffs_to_unified_model() -> None:
assert [price.currency for price in result] == ["RUB", "USD"] assert [price.currency for price in result] == ["RUB", "USD"]
assert [price.delivery_days_min for price in result] == [2, 5] assert [price.delivery_days_min for price in result] == [2, 5]
assert [price.delivery_days_max for price in result] == [4, 7] assert [price.delivery_days_max for price in result] == [4, 7]
assert [price.tariff_code for price in result] == [7, 136]
def test_map_cdek_response_returns_none_tariff_code_when_missing() -> None:
payload = {
"tariff_codes": [
{
"tariff_name": "Express",
"delivery_sum": "100.00",
"currency": "RUB",
"period_min": 1,
"period_max": 2,
}
]
}
result = map_cdek_response(payload)
assert result[0].tariff_code is None
def test_map_cdek_response_raises_for_missing_tariff_codes() -> None: def test_map_cdek_response_raises_for_missing_tariff_codes() -> None:
@@ -10,7 +10,10 @@ from app.adapters.delivery_providers.cdek.client import (
CDEKProvider, CDEKProvider,
CDEKRequestError, CDEKRequestError,
) )
from app.schemas.order import OrderCreateRequest from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderRegistrationResult,
)
from tests.payment_fixtures import make_init_payment_request
class StubAuthClient: class StubAuthClient:
@@ -51,113 +54,53 @@ class SequenceHTTPClient:
return result return result
def _make_order_request(**overrides: object) -> OrderCreateRequest: def _make_provider(http_client: SequenceHTTPClient, **kwargs: Any) -> CDEKProvider:
payload: dict[str, object] = { return CDEKProvider(
"type": 2,
"tariff_code": 535,
"comment": "Test order",
"sender": {
"name": "Petr Petrov",
"email": "sender@example.com",
"phones": [{"number": "+79009876543"}],
},
"recipient": {
"name": "Ivan Ivanov",
"email": "ivan@example.com",
"phones": [{"number": "+79001234567"}],
},
"from_location": {
"address": "Lenina 1",
"city": "Moscow",
"country_code": "RU",
},
"to_location": {
"address": "Pushkina 10",
"city": "Novosibirsk",
"country_code": "RU",
},
"services": [{"code": "INSURANCE", "parameter": "1000"}],
"packages": [
{
"number": "1",
"weight": 1000,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
}
],
}
payload.update(overrides)
return OrderCreateRequest(**payload)
def test_provider_register_order_posts_cdek_contract_payload_and_maps_response() -> None:
response = httpx.Response(
200,
json={"entity": {"uuid": "cdek-order-uuid"}},
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
)
http_client = SequenceHTTPClient([response])
provider = CDEKProvider(
CDEKClient( CDEKClient(
http_client=http_client, # type: ignore[arg-type] http_client=http_client, # type: ignore[arg-type]
auth_client=StubAuthClient(), # type: ignore[arg-type] auth_client=StubAuthClient(), # type: ignore[arg-type]
base_url="https://api.cdek.test/v2", base_url="https://api.cdek.test/v2",
timeout_seconds=7.5, timeout_seconds=7.5,
retry_attempts=0, retry_attempts=0,
**kwargs,
) )
) )
result = asyncio.run(provider.register_order(_make_order_request()))
assert result.provider == "cdek" def test_provider_register_order_posts_payload_and_maps_response() -> None:
assert result.order_uuid == "cdek-order-uuid" response = httpx.Response(
assert http_client.calls == [ 200,
json={
"entity": {"uuid": "cdek-order-uuid"},
"related_entities": [
{ {
"method": "POST", "type": "waybill",
"url": "https://api.cdek.test/v2/orders", "uuid": "waybill-uuid-1",
"json": { "url": "https://cdek.test/waybill/1.pdf",
"type": 2,
"tariff_code": 535,
"comment": "Test order",
"sender": {
"name": "Petr Petrov",
"email": "sender@example.com",
"phones": [{"number": "+79009876543"}],
},
"recipient": {
"name": "Ivan Ivanov",
"email": "ivan@example.com",
"phones": [{"number": "+79001234567"}],
},
"from_location": {
"address": "Lenina 1",
"city": "Moscow",
"country_code": "RU",
},
"to_location": {
"address": "Pushkina 10",
"city": "Novosibirsk",
"country_code": "RU",
},
"services": [{"code": "INSURANCE", "parameter": "1000"}],
"packages": [
{
"number": "1",
"weight": 1000,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
} }
], ],
}, },
"data": None, request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
"headers": {"Authorization": "Bearer test-token"}, )
"timeout": 7.5, http_client = SequenceHTTPClient([response])
} provider = _make_provider(http_client)
]
result = asyncio.run(provider.register_order(make_init_payment_request(), "order-uuid-1"))
assert result == CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid",
waybill_uuid="waybill-uuid-1",
waybill_url="https://cdek.test/waybill/1.pdf",
)
assert http_client.calls[0]["url"] == "https://api.cdek.test/v2/orders"
payload = http_client.calls[0]["json"]
assert payload["number"] == "order-uuid-1"
assert payload["type"] == 2
assert payload["tariff_code"] == 535
assert payload["print"] == "WAYBILL"
assert payload["from_location"]["code"] == 7017
assert payload["to_location"]["code"] == 16454
assert payload["packages"][0]["weight"] == 1000
def test_cdek_client_register_order_maps_4xx_to_request_error() -> None: def test_cdek_client_register_order_maps_4xx_to_request_error() -> None:
@@ -175,11 +118,47 @@ def test_cdek_client_register_order_maps_4xx_to_request_error() -> None:
) )
with pytest.raises(CDEKRequestError, match="status 422"): with pytest.raises(CDEKRequestError, match="status 422"):
asyncio.run(client.register_order(_make_order_request())) asyncio.run(client.register_order(make_init_payment_request(), "order-uuid-1"))
assert len(http_client.calls) == 1 assert len(http_client.calls) == 1
def test_cdek_client_register_order_maps_duplicate_external_id_to_success() -> None:
duplicate_response = httpx.Response(
409,
json={
"entity": {"uuid": "existing-cdek-order-uuid"},
"requests": [
{
"errors": [
{
"code": "v2_entity_already_exists",
"message": "Order with this number already exists",
}
]
}
],
},
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
)
http_client = SequenceHTTPClient([duplicate_response])
client = CDEKClient(
http_client=http_client, # type: ignore[arg-type]
auth_client=StubAuthClient(), # type: ignore[arg-type]
base_url="https://api.cdek.test/v2",
retry_attempts=2,
)
result = asyncio.run(client.register_order(make_init_payment_request(), "order-uuid-1"))
assert result == CDEKOrderRegistrationResult(
order_uuid="existing-cdek-order-uuid",
waybill_uuid=None,
waybill_url=None,
)
assert len(http_client.calls) == 1
def test_cdek_client_register_order_retries_5xx_and_raises_client_error() -> None: def test_cdek_client_register_order_retries_5xx_and_raises_client_error() -> None:
first_response = httpx.Response( first_response = httpx.Response(
503, 503,
@@ -207,7 +186,7 @@ def test_cdek_client_register_order_retries_5xx_and_raises_client_error() -> Non
) )
with pytest.raises(CDEKClientError, match="retriable status 503"): with pytest.raises(CDEKClientError, match="retriable status 503"):
asyncio.run(client.register_order(_make_order_request())) asyncio.run(client.register_order(make_init_payment_request(), "order-uuid-1"))
assert len(http_client.calls) == 2 assert len(http_client.calls) == 2
assert sleep_calls == [0.25] assert sleep_calls == [0.25]
@@ -228,4 +207,4 @@ def test_cdek_client_register_order_raises_client_error_for_invalid_success_payl
) )
with pytest.raises(CDEKClientError, match="response payload is invalid"): with pytest.raises(CDEKClientError, match="response payload is invalid"):
asyncio.run(client.register_order(_make_order_request())) asyncio.run(client.register_order(make_init_payment_request(), "order-uuid-1"))
@@ -0,0 +1,173 @@
import asyncio
from typing import Any
import httpx
import pytest
from app.adapters.delivery_providers.cdek.client import (
CDEKClient,
CDEKClientError,
CDEKRequestError,
)
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderInfo,
CDEKWaybillInfo,
)
class StubAuthClient:
async def get_access_token(self) -> str:
return "test-token"
class SequenceHTTPClient:
def __init__(self, results: list[Any]) -> None:
self._results = results
self.calls: list[dict[str, Any]] = []
def _next_result(self) -> Any:
return self._results[len(self.calls) - 1]
async def get(
self,
url: str,
*,
headers: dict[str, str] | None = None,
timeout: float | None = None,
) -> httpx.Response:
self.calls.append(
{"method": "GET", "url": url, "headers": headers, "timeout": timeout}
)
result = self._next_result()
if isinstance(result, Exception):
raise result
return result
def _make_client(http_client: SequenceHTTPClient, **kwargs: Any) -> CDEKClient:
kwargs.setdefault("retry_attempts", 0)
return CDEKClient(
http_client=http_client, # type: ignore[arg-type]
auth_client=StubAuthClient(), # type: ignore[arg-type]
base_url="https://api.cdek.test/v2",
timeout_seconds=7.5,
**kwargs,
)
def test_get_order_parses_status_and_waybill_uuid() -> None:
response = httpx.Response(
200,
json={
"entity": {
"uuid": "cdek-order-uuid",
"statuses": [
{"code": "ACCEPTED", "date_time": "2026-05-24T10:00:00+0000"}
],
},
"related_entities": [
{"type": "waybill", "uuid": "waybill-uuid-1"}
],
},
request=httpx.Request("GET", "https://api.cdek.test/v2/orders/cdek-order-uuid"),
)
http_client = SequenceHTTPClient([response])
client = _make_client(http_client)
result = asyncio.run(client.get_order("cdek-order-uuid"))
assert result == CDEKOrderInfo(
order_uuid="cdek-order-uuid",
status_code="ACCEPTED",
waybill_uuid="waybill-uuid-1",
)
assert http_client.calls[0]["url"] == (
"https://api.cdek.test/v2/orders/cdek-order-uuid"
)
assert http_client.calls[0]["headers"] == {"Authorization": "Bearer test-token"}
def test_get_order_retries_on_5xx_and_succeeds() -> None:
flaky = httpx.Response(
503,
json={"message": "temporary"},
request=httpx.Request("GET", "https://api.cdek.test/v2/orders/x"),
)
success = httpx.Response(
200,
json={"entity": {"uuid": "x", "statuses": []}},
request=httpx.Request("GET", "https://api.cdek.test/v2/orders/x"),
)
http_client = SequenceHTTPClient([flaky, success])
sleep_calls: list[float] = []
async def fake_sleep(seconds: float) -> None:
sleep_calls.append(seconds)
client = _make_client(
http_client,
retry_attempts=1,
retry_backoff_seconds=0.1,
sleep=fake_sleep,
)
result = asyncio.run(client.get_order("x"))
assert result.order_uuid == "x"
assert len(http_client.calls) == 2
assert sleep_calls == [0.1]
def test_get_order_raises_request_error_on_4xx() -> None:
response = httpx.Response(
404,
json={"requests": [{"errors": [{"code": "v2_not_found"}]}]},
request=httpx.Request("GET", "https://api.cdek.test/v2/orders/missing"),
)
http_client = SequenceHTTPClient([response])
client = _make_client(http_client)
with pytest.raises(CDEKRequestError, match="status 404"):
asyncio.run(client.get_order("missing"))
def test_get_waybill_returns_url_when_present() -> None:
response = httpx.Response(
200,
json={
"entity": {
"uuid": "waybill-uuid-1",
"url": "https://cdek.test/waybill/1.pdf",
}
},
request=httpx.Request(
"GET", "https://api.cdek.test/v2/print/orders/waybill-uuid-1"
),
)
http_client = SequenceHTTPClient([response])
client = _make_client(http_client)
result = asyncio.run(client.get_waybill("waybill-uuid-1"))
assert result == CDEKWaybillInfo(
waybill_uuid="waybill-uuid-1",
url="https://cdek.test/waybill/1.pdf",
)
assert http_client.calls[0]["url"] == (
"https://api.cdek.test/v2/print/orders/waybill-uuid-1"
)
def test_get_waybill_raises_client_error_for_invalid_payload() -> None:
response = httpx.Response(
200,
json={"entity": {}},
request=httpx.Request(
"GET", "https://api.cdek.test/v2/print/orders/waybill-uuid-1"
),
)
http_client = SequenceHTTPClient([response])
client = _make_client(http_client)
with pytest.raises(CDEKClientError, match="response payload is invalid"):
asyncio.run(client.get_waybill("waybill-uuid-1"))
@@ -0,0 +1,284 @@
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderInfo,
CDEKOrderMappingError,
CDEKOrderRegistrationResult,
CDEKWaybillInfo,
compose_address_line,
map_cdek_existing_order_response,
map_cdek_order_info_response,
map_cdek_order_request,
map_cdek_order_response,
map_cdek_waybill_info_response,
)
import pytest
from app.schemas.payment import Address
from tests.payment_fixtures import make_init_payment_request
def test_order_payload_uses_order_uuid_and_tariff_code_from_system_data() -> None:
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
assert payload["number"] == "order-uuid-1"
assert payload["type"] == 2
assert payload["tariff_code"] == 535
def test_order_payload_requests_waybill_print() -> None:
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
assert payload["print"] == "WAYBILL"
def test_map_cdek_order_response_extracts_order_uuid_without_waybill() -> None:
result = map_cdek_order_response({"entity": {"uuid": "cdek-order-uuid"}})
assert result == CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid",
waybill_uuid=None,
waybill_url=None,
)
def test_map_cdek_order_response_extracts_waybill_from_related_entities() -> None:
result = map_cdek_order_response(
{
"entity": {"uuid": "cdek-order-uuid"},
"related_entities": [
{"type": "delivery", "uuid": "ignored"},
{
"type": "waybill",
"uuid": "waybill-uuid-1",
"url": "https://cdek.test/waybill/1.pdf",
},
],
}
)
assert result == CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid",
waybill_uuid="waybill-uuid-1",
waybill_url="https://cdek.test/waybill/1.pdf",
)
def test_map_cdek_existing_order_response_returns_waybill_for_duplicate() -> None:
result = map_cdek_existing_order_response(
{
"entity": {"uuid": "existing-uuid"},
"requests": [
{"errors": [{"code": "v2_entity_already_exists", "message": "dup"}]}
],
"related_entities": [
{
"type": "waybill",
"uuid": "waybill-existing",
"url": "https://cdek.test/waybill/existing.pdf",
}
],
}
)
assert result == CDEKOrderRegistrationResult(
order_uuid="existing-uuid",
waybill_uuid="waybill-existing",
waybill_url="https://cdek.test/waybill/existing.pdf",
)
def test_order_payload_maps_phones_and_phone_ext_to_additional() -> None:
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
assert payload["sender"]["phones"] == [{"number": "+79009876543"}]
assert payload["recipient"]["phones"] == [
{"number": "+79001234567", "additional": "101"}
]
def test_order_payload_maps_locations_with_address_and_postal_code() -> None:
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
assert payload["from_location"] == {
"code": 7017,
"address": "Дубай, Sheikh Zayed Road, 10, кв. 201",
"postal_code": "12345",
}
assert payload["to_location"] == {
"code": 16454,
"address": "Шарджа, Al Wahda, 5",
"postal_code": "54321",
}
def test_order_payload_for_parcel_includes_dimensions() -> None:
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
package = payload["packages"][0]
assert package["weight"] == 1000
assert package["length"] == 20
assert package["width"] == 15
assert package["height"] == 10
def test_order_payload_for_doc_omits_dimensions() -> None:
request = make_init_payment_request(
content={"description": "Docs"},
systemData={
"tariff": {
"provider": "СДЭК",
"serviceName": "Документы",
"price": 50000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": 535,
},
"parcelType": "doc",
"docPackaging": "envelope",
"weight": "0.5",
"dimensions": None,
},
)
payload = map_cdek_order_request(request, "order-uuid-1")
package = payload["packages"][0]
assert package["weight"] == 500
assert "length" not in package
assert "width" not in package
assert "height" not in package
def test_order_payload_omits_shipment_and_delivery_point() -> None:
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
assert "shipment_point" not in payload
assert "delivery_point" not in payload
def test_order_payload_includes_company_requisites_for_legal_entity() -> None:
request = make_init_payment_request(
senderContact={
"fullName": "Romashka LLC",
"email": "buh@romashka.test",
"phone": "+74950000000",
"phoneExt": None,
"isCompany": True,
"companyName": "Romashka LLC",
"inn": "7707083893",
"kpp": "770701001",
}
)
sender = map_cdek_order_request(request, "order-uuid-1")["sender"]
assert sender["contragent_type"] == "LEGAL_ENTITY"
assert sender["company"] == "Romashka LLC"
assert sender["inn"] == "7707083893"
assert sender["kpp"] == "770701001"
def test_order_payload_propagates_description_to_comments_without_items() -> None:
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
assert payload["comment"] == "Headphones"
assert payload["packages"][0]["comment"] == "Headphones"
assert "items" not in payload["packages"][0]
def test_order_payload_falls_back_package_comment_to_order_uuid() -> None:
payload = map_cdek_order_request(
make_init_payment_request(content={"description": None}), "order-uuid-1"
)
assert payload["packages"][0]["comment"] == "order-uuid-1"
def test_order_payload_includes_company_for_individual_sender_as_full_name() -> None:
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
assert payload["sender"]["company"] == "Petr Petrov"
assert payload["recipient"]["company"] == "Ivan Ivanov"
assert "contragent_type" not in payload["sender"]
assert "contragent_type" not in payload["recipient"]
def test_compose_address_line_handles_empty_apartment() -> None:
address = Address(
cityId=1,
city="Москва",
street="Lenina",
house="1",
apartment=None,
zip="12345",
comment=None,
)
assert compose_address_line(address) == "Москва, Lenina, 1"
def test_map_cdek_order_info_response_picks_latest_status_by_date_time() -> None:
result = map_cdek_order_info_response(
{
"entity": {
"uuid": "cdek-order-uuid",
"statuses": [
{"code": "ACCEPTED", "date_time": "2026-05-24T10:00:00+0000"},
{"code": "INVALID", "date_time": "2026-05-24T10:00:05+0000"},
],
},
"related_entities": [
{"type": "waybill", "uuid": "waybill-uuid-1"}
],
}
)
assert result == CDEKOrderInfo(
order_uuid="cdek-order-uuid",
status_code="INVALID",
waybill_uuid="waybill-uuid-1",
)
def test_map_cdek_order_info_response_returns_none_status_when_statuses_empty() -> None:
result = map_cdek_order_info_response(
{"entity": {"uuid": "cdek-order-uuid", "statuses": []}}
)
assert result == CDEKOrderInfo(
order_uuid="cdek-order-uuid",
status_code=None,
waybill_uuid=None,
)
def test_map_cdek_order_info_response_raises_for_missing_entity() -> None:
with pytest.raises(CDEKOrderMappingError):
map_cdek_order_info_response({"requests": []})
def test_map_cdek_waybill_info_response_returns_url_when_present() -> None:
result = map_cdek_waybill_info_response(
{
"entity": {
"uuid": "waybill-uuid-1",
"url": "https://cdek.test/waybill/1.pdf",
}
}
)
assert result == CDEKWaybillInfo(
waybill_uuid="waybill-uuid-1",
url="https://cdek.test/waybill/1.pdf",
)
def test_map_cdek_waybill_info_response_returns_none_url_when_missing() -> None:
result = map_cdek_waybill_info_response(
{"entity": {"uuid": "waybill-uuid-1"}}
)
assert result == CDEKWaybillInfo(waybill_uuid="waybill-uuid-1", url=None)
def test_map_cdek_waybill_info_response_raises_for_missing_uuid() -> None:
with pytest.raises(CDEKOrderMappingError):
map_cdek_waybill_info_response({"entity": {}})
@@ -0,0 +1,220 @@
import asyncio
from decimal import Decimal
from typing import Any
import httpx
import pytest
from app.adapters.delivery_providers.cdek.client import (
CDEKClient,
CDEKClientError,
CDEKProvider,
CDEKRequestError,
)
from tests.payment_fixtures import make_init_payment_request
class StubAuthClient:
async def get_access_token(self) -> str:
return "test-token"
class SequenceHTTPClient:
def __init__(self, results: list[Any]) -> None:
self._results = results
self.calls: list[dict[str, Any]] = []
def _next_result(self) -> Any:
return self._results[len(self.calls) - 1]
async def post(
self,
url: str,
*,
json: dict[str, Any] | None = None,
data: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
timeout: float | None = None,
) -> httpx.Response:
self.calls.append(
{
"method": "POST",
"url": url,
"json": json,
"data": data,
"headers": headers,
"timeout": timeout,
}
)
result = self._next_result()
if isinstance(result, Exception):
raise result
return result
def test_provider_get_payment_price_posts_payload_from_camelcase_request() -> None:
response = httpx.Response(
200,
json={
"tariff_codes": [
{
"tariff_code": 535,
"tariff_name": "CDEK tariff",
"delivery_sum": "1250.00",
"currency": "RUB",
"period_min": 1,
"period_max": 2,
},
]
},
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
)
http_client = SequenceHTTPClient([response])
provider = CDEKProvider(
CDEKClient(
http_client=http_client, # type: ignore[arg-type]
auth_client=StubAuthClient(), # type: ignore[arg-type]
base_url="https://api.cdek.test/v2",
timeout_seconds=7.5,
retry_attempts=0,
)
)
result = asyncio.run(provider.get_payment_price(make_init_payment_request()))
assert result is not None
assert result.price == Decimal("1250.00")
assert http_client.calls == [
{
"method": "POST",
"url": "https://api.cdek.test/v2/calculator/tarifflist",
"json": {
"type": 2,
"from_location": {"code": 7017},
"to_location": {"code": 16454},
"packages": [
{
"weight": 1000,
"length": 20,
"width": 15,
"height": 10,
}
],
},
"data": None,
"headers": {"Authorization": "Bearer test-token"},
"timeout": 7.5,
}
]
def test_provider_get_payment_price_omits_dimensions_for_doc() -> None:
response = httpx.Response(
200,
json={"tariff_codes": []},
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
)
http_client = SequenceHTTPClient([response])
provider = CDEKProvider(
CDEKClient(
http_client=http_client, # type: ignore[arg-type]
auth_client=StubAuthClient(), # type: ignore[arg-type]
base_url="https://api.cdek.test/v2",
retry_attempts=0,
)
)
request = make_init_payment_request(
content={"description": "Docs"},
systemData={
"tariff": {
"provider": "СДЭК",
"serviceName": "Документы",
"price": 50000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": 535,
},
"parcelType": "doc",
"docPackaging": "envelope",
"weight": "0.5",
"dimensions": None,
},
)
asyncio.run(provider.get_payment_price(request))
package = http_client.calls[0]["json"]["packages"][0]
assert package == {"weight": 500}
def test_client_get_raw_payment_price_maps_4xx_to_request_error() -> None:
rejected_response = httpx.Response(
422,
json={"errors": [{"message": "bad request"}]},
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
)
http_client = SequenceHTTPClient([rejected_response])
client = CDEKClient(
http_client=http_client, # type: ignore[arg-type]
auth_client=StubAuthClient(), # type: ignore[arg-type]
base_url="https://api.cdek.test/v2",
retry_attempts=2,
)
with pytest.raises(CDEKRequestError, match="status 422"):
asyncio.run(client.get_raw_payment_price(make_init_payment_request()))
assert len(http_client.calls) == 1
def test_client_get_raw_payment_price_retries_5xx_and_raises_client_error() -> None:
first_response = httpx.Response(
503,
json={"message": "temporary failure"},
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
)
second_response = httpx.Response(
503,
json={"message": "temporary failure"},
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
)
http_client = SequenceHTTPClient([first_response, second_response])
sleep_calls: list[float] = []
async def fake_sleep(seconds: float) -> None:
sleep_calls.append(seconds)
client = CDEKClient(
http_client=http_client, # type: ignore[arg-type]
auth_client=StubAuthClient(), # type: ignore[arg-type]
base_url="https://api.cdek.test/v2",
retry_attempts=1,
retry_backoff_seconds=0.25,
sleep=fake_sleep,
)
with pytest.raises(CDEKClientError, match="status 503"):
asyncio.run(client.get_raw_payment_price(make_init_payment_request()))
assert len(http_client.calls) == 2
assert sleep_calls == [0.25]
def test_provider_get_payment_price_maps_invalid_success_payload_to_client_error() -> None:
invalid_response = httpx.Response(
200,
json={"tariff_codes": [{"tariff_code": 535}]},
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
)
http_client = SequenceHTTPClient([invalid_response])
provider = CDEKProvider(
CDEKClient(
http_client=http_client, # type: ignore[arg-type]
auth_client=StubAuthClient(), # type: ignore[arg-type]
base_url="https://api.cdek.test/v2",
retry_attempts=0,
)
)
with pytest.raises(CDEKClientError, match="response payload is invalid"):
asyncio.run(provider.get_payment_price(make_init_payment_request()))
@@ -0,0 +1,115 @@
import asyncio
from typing import Any
import httpx
import pytest
from app.adapters.delivery_providers.cdek.client import (
CDEKClient,
CDEKClientError,
CDEKRequestError,
)
class StubAuthClient:
async def get_access_token(self) -> str:
return "test-token"
class SequenceHTTPClient:
def __init__(self, results: list[Any]) -> None:
self._results = results
self.calls: list[dict[str, Any]] = []
async def get(
self,
url: str,
*,
headers: dict[str, str] | None = None,
timeout: float | None = None,
) -> httpx.Response:
self.calls.append(
{"method": "GET", "url": url, "headers": headers, "timeout": timeout}
)
result = self._results[len(self.calls) - 1]
if isinstance(result, Exception):
raise result
return result
def _make_client(http_client: SequenceHTTPClient, **kwargs: Any) -> CDEKClient:
kwargs.setdefault("retry_attempts", 0)
return CDEKClient(
http_client=http_client, # type: ignore[arg-type]
auth_client=StubAuthClient(), # type: ignore[arg-type]
base_url="https://api.cdek.test/v2",
timeout_seconds=7.5,
**kwargs,
)
def test_download_waybill_pdf_returns_response_bytes() -> None:
pdf_bytes = b"%PDF-1.4 mock content"
response = httpx.Response(
200,
content=pdf_bytes,
request=httpx.Request("GET", "https://cdek.test/waybill/1.pdf"),
)
http_client = SequenceHTTPClient([response])
client = _make_client(http_client)
result = asyncio.run(
client.download_waybill_pdf("https://cdek.test/waybill/1.pdf")
)
assert result == pdf_bytes
assert http_client.calls[0]["url"] == "https://cdek.test/waybill/1.pdf"
assert http_client.calls[0]["headers"] == {"Authorization": "Bearer test-token"}
assert http_client.calls[0]["timeout"] == 7.5
def test_download_waybill_pdf_retries_on_5xx_and_succeeds() -> None:
request = httpx.Request("GET", "https://cdek.test/x.pdf")
flaky = httpx.Response(503, content=b"oops", request=request)
success = httpx.Response(200, content=b"%PDF", request=request)
http_client = SequenceHTTPClient([flaky, success])
sleep_calls: list[float] = []
async def fake_sleep(seconds: float) -> None:
sleep_calls.append(seconds)
client = _make_client(
http_client,
retry_attempts=1,
retry_backoff_seconds=0.1,
sleep=fake_sleep,
)
result = asyncio.run(client.download_waybill_pdf("https://cdek.test/x.pdf"))
assert result == b"%PDF"
assert len(http_client.calls) == 2
assert sleep_calls == [0.1]
def test_download_waybill_pdf_raises_request_error_on_4xx() -> None:
response = httpx.Response(
404,
content=b"not found",
request=httpx.Request("GET", "https://cdek.test/missing.pdf"),
)
http_client = SequenceHTTPClient([response])
client = _make_client(http_client)
with pytest.raises(CDEKRequestError, match="status 404"):
asyncio.run(client.download_waybill_pdf("https://cdek.test/missing.pdf"))
def test_download_waybill_pdf_raises_client_error_after_retries_exhausted() -> None:
request = httpx.Request("GET", "https://cdek.test/x.pdf")
response = httpx.Response(500, content=b"oops", request=request)
http_client = SequenceHTTPClient([response])
client = _make_client(http_client, retry_attempts=0)
with pytest.raises(CDEKClientError, match="retriable status 500"):
asyncio.run(client.download_waybill_pdf("https://cdek.test/x.pdf"))
View File
+187
View File
@@ -0,0 +1,187 @@
import asyncio
from email.message import EmailMessage
from typing import Any
import pytest
from app.adapters.email import SMTPEmailSender, SMTPEmailSenderError
class StubSend:
def __init__(self, raise_exc: Exception | None = None) -> None:
self.raise_exc = raise_exc
self.calls: list[dict[str, Any]] = []
async def __call__(
self,
message: EmailMessage,
*,
hostname: str,
port: int,
username: str | None,
password: str | None,
use_tls: bool,
start_tls: bool,
timeout: float,
) -> object:
self.calls.append(
{
"message": message,
"hostname": hostname,
"port": port,
"username": username,
"password": password,
"use_tls": use_tls,
"start_tls": start_tls,
"timeout": timeout,
}
)
if self.raise_exc is not None:
raise self.raise_exc
return None
def _make_sender(send: StubSend, **overrides: Any) -> SMTPEmailSender:
kwargs: dict[str, Any] = {
"smtp_host": "smtp.test",
"smtp_port": 587,
"username": "user",
"password": "pass",
"from_address": "no-reply@test",
"use_tls": True,
"timeout_seconds": 5.0,
"send": send,
}
kwargs.update(overrides)
return SMTPEmailSender(**kwargs)
def test_send_email_builds_multipart_message_with_pdf_attachment() -> None:
send = StubSend()
sender = _make_sender(send)
asyncio.run(
sender.send_email(
to="client@example.com",
subject="Накладная по заказу o-1",
body="hello",
attachment_bytes=b"%PDF",
attachment_filename="waybill_o-1.pdf",
)
)
assert len(send.calls) == 1
call = send.calls[0]
assert call["hostname"] == "smtp.test"
assert call["port"] == 587
assert call["username"] == "user"
assert call["password"] == "pass"
assert call["use_tls"] is False
assert call["start_tls"] is True
assert call["timeout"] == 5.0
message: EmailMessage = call["message"]
assert message["From"] == "no-reply@test"
assert message["To"] == "client@example.com"
assert message["Subject"] == "Накладная по заказу o-1"
parts = list(message.iter_attachments())
assert len(parts) == 1
attachment = parts[0]
assert attachment.get_content_type() == "application/pdf"
assert attachment.get_filename() == "waybill_o-1.pdf"
assert attachment.get_payload(decode=True) == b"%PDF"
def test_send_email_uses_tls_for_port_465() -> None:
send = StubSend()
sender = _make_sender(send, smtp_port=465)
asyncio.run(
sender.send_email(
to="x@example.com",
subject="s",
body="b",
attachment_bytes=b"",
attachment_filename="f.pdf",
)
)
call = send.calls[0]
assert call["use_tls"] is True
assert call["start_tls"] is False
def test_send_email_disables_tls_when_use_tls_false() -> None:
send = StubSend()
sender = _make_sender(send, use_tls=False)
asyncio.run(
sender.send_email(
to="x@example.com",
subject="s",
body="b",
attachment_bytes=b"",
attachment_filename="f.pdf",
)
)
call = send.calls[0]
assert call["use_tls"] is False
assert call["start_tls"] is False
def test_send_email_wraps_send_errors_in_sender_error() -> None:
send = StubSend(raise_exc=RuntimeError("connection refused"))
sender = _make_sender(send)
with pytest.raises(SMTPEmailSenderError, match="connection refused"):
asyncio.run(
sender.send_email(
to="x@example.com",
subject="s",
body="b",
attachment_bytes=b"",
attachment_filename="f.pdf",
)
)
def test_send_email_passes_none_when_credentials_blank() -> None:
send = StubSend()
sender = _make_sender(send, username="", password="")
asyncio.run(
sender.send_email(
to="x@example.com",
subject="s",
body="b",
attachment_bytes=b"",
attachment_filename="f.pdf",
)
)
call = send.calls[0]
assert call["username"] is None
assert call["password"] is None
def test_send_email_without_attachment_builds_plain_text_message() -> None:
send = StubSend()
sender = _make_sender(send)
asyncio.run(
sender.send_email(
to="client@example.com",
subject="Оплата принята",
body="Ваша оплата принята.",
)
)
assert len(send.calls) == 1
message: EmailMessage = send.calls[0]["message"]
assert message["From"] == "no-reply@test"
assert message["To"] == "client@example.com"
assert message["Subject"] == "Оплата принята"
assert "Ваша оплата принята." in message.get_content()
assert list(message.iter_attachments()) == []
+265
View File
@@ -0,0 +1,265 @@
import asyncio
import hashlib
from typing import Any
import httpx
import pytest
from app.adapters.tbank.client import TBankAdapter
from app.adapters.tbank.base import (
TBankPaymentAdapterError,
TBankPaymentRequestError,
)
from app.config import TBankPaymentAuthConfig, TBankPaymentConfig
class SequenceHTTPClient:
def __init__(self, results: list[Any]) -> None:
self._results = results
self.calls: list[dict[str, Any]] = []
def _next_result(self) -> Any:
return self._results[len(self.calls) - 1]
async def post(
self,
url: str,
*,
json: dict[str, Any] | None = None,
data: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
timeout: float | None = None,
) -> httpx.Response:
self.calls.append(
{
"method": "POST",
"url": url,
"json": json,
"data": data,
"headers": headers,
"timeout": timeout,
}
)
result = self._next_result()
if isinstance(result, Exception):
raise result
return result
def _make_adapter(
http_client: SequenceHTTPClient,
*,
notification_url: str = "https://example.test/api/v1/delivery/tbank/notifications",
success_url: str = "https://example.test/payment/success",
retry_attempts: int = 0,
retry_backoff_seconds: float = 0.2,
sleep_calls: list[float] | None = None,
) -> TBankAdapter:
async def fake_sleep(seconds: float) -> None:
if sleep_calls is not None:
sleep_calls.append(seconds)
return TBankAdapter(
http_client=http_client, # type: ignore[arg-type]
init_url="https://securepay.tinkoff.ru/v2/Init",
notification_url=notification_url,
success_url=success_url,
terminal_key="TBankTest",
password="test-password",
timeout_seconds=7.5,
retry_attempts=retry_attempts,
retry_backoff_seconds=retry_backoff_seconds,
sleep=fake_sleep,
)
def test_create_payment_link_posts_signed_payload_and_maps_payment_url() -> None:
response = httpx.Response(
200,
json={"Success": True, "PaymentURL": "https://securepay.test/pay/1"},
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
)
http_client = SequenceHTTPClient([response])
adapter = _make_adapter(http_client)
result = asyncio.run(
adapter.create_payment_link(
order_uuid="order-uuid-1",
amount_kopecks=125000,
)
)
expected_token = hashlib.sha256(
(
"125000"
"https://example.test/api/v1/delivery/tbank/notifications"
"order-uuid-1"
"test-password"
"https://example.test/payment/success/order-uuid-1"
"TBankTest"
).encode("utf-8")
).hexdigest()
assert result == "https://securepay.test/pay/1"
assert http_client.calls == [
{
"method": "POST",
"url": "https://securepay.tinkoff.ru/v2/Init",
"json": {
"TerminalKey": "TBankTest",
"Amount": 125000,
"OrderId": "order-uuid-1",
"NotificationURL": "https://example.test/api/v1/delivery/tbank/notifications",
"SuccessURL": "https://example.test/payment/success/order-uuid-1",
"Token": expected_token,
},
"data": None,
"headers": {
"Accept": "application/json",
"Content-Type": "application/json",
},
"timeout": 7.5,
}
]
def test_from_config_posts_configured_urls_and_deterministic_token() -> None:
response = httpx.Response(
200,
json={"Success": True, "PaymentURL": "https://securepay.test/pay/2"},
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
)
http_client = SequenceHTTPClient([response])
config = TBankPaymentConfig(
init_url="https://securepay.tinkoff.ru/v2/Init",
notification_url="https://merchant.test/api/v1/delivery/tbank/notifications",
success_url="https://merchant.test/payment/success",
auth=TBankPaymentAuthConfig(
terminal_key="ConfigTerminal",
password="config-password",
),
timeout_seconds=6.25,
retry_attempts=0,
retry_backoff_seconds=0.1,
)
adapter = TBankAdapter.from_config(
http_client=http_client, # type: ignore[arg-type]
config=config,
)
result = asyncio.run(
adapter.create_payment_link(
order_uuid="order-uuid-2",
amount_kopecks=9900,
)
)
expected_token = hashlib.sha256(
(
"9900"
"https://merchant.test/api/v1/delivery/tbank/notifications"
"order-uuid-2"
"config-password"
"https://merchant.test/payment/success/order-uuid-2"
"ConfigTerminal"
).encode("utf-8")
).hexdigest()
assert result == "https://securepay.test/pay/2"
assert http_client.calls[0]["json"] == {
"TerminalKey": "ConfigTerminal",
"Amount": 9900,
"OrderId": "order-uuid-2",
"NotificationURL": "https://merchant.test/api/v1/delivery/tbank/notifications",
"SuccessURL": "https://merchant.test/payment/success/order-uuid-2",
"Token": expected_token,
}
assert http_client.calls[0]["timeout"] == 6.25
def test_create_payment_link_maps_4xx_to_request_error() -> None:
response = httpx.Response(
400,
json={
"Success": False,
"ErrorCode": "101",
"Message": "bad request",
"Details": "invalid token",
},
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
)
http_client = SequenceHTTPClient([response])
adapter = _make_adapter(http_client)
with pytest.raises(TBankPaymentRequestError) as exc_info:
asyncio.run(adapter.create_payment_link("order-uuid-1", 125000))
assert exc_info.value.status_code == 400
assert exc_info.value.error_code == "101"
assert exc_info.value.provider_message == "bad request"
assert exc_info.value.details == "invalid token"
assert str(exc_info.value) == (
"TBank payment init request was rejected. "
"status_code=400 error_code=101 message=bad request details=invalid token"
)
def test_create_payment_link_maps_unsuccessful_payload_to_request_error() -> None:
response = httpx.Response(
200,
json={
"Success": False,
"ErrorCode": "102",
"Message": "duplicate order id",
"Details": "OrderId must be unique",
},
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
)
http_client = SequenceHTTPClient([response])
adapter = _make_adapter(http_client)
with pytest.raises(TBankPaymentRequestError) as exc_info:
asyncio.run(adapter.create_payment_link("order-uuid-1", 125000))
assert exc_info.value.status_code is None
assert exc_info.value.error_code == "102"
assert exc_info.value.provider_message == "duplicate order id"
assert exc_info.value.details == "OrderId must be unique"
assert str(exc_info.value) == (
"TBank payment init request was rejected. "
"error_code=102 message=duplicate order id details=OrderId must be unique"
)
def test_create_payment_link_maps_transport_errors_to_client_error() -> None:
request = httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init")
http_client = SequenceHTTPClient([httpx.ConnectError("down", request=request)])
adapter = _make_adapter(http_client)
with pytest.raises(TBankPaymentAdapterError, match="retry attempts"):
asyncio.run(adapter.create_payment_link("order-uuid-1", 125000))
def test_create_payment_link_retries_5xx_and_raises_client_error() -> None:
first_response = httpx.Response(
503,
json={"Message": "temporary failure"},
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
)
second_response = httpx.Response(
503,
json={"Message": "temporary failure"},
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
)
http_client = SequenceHTTPClient([first_response, second_response])
sleep_calls: list[float] = []
adapter = _make_adapter(
http_client,
retry_attempts=1,
retry_backoff_seconds=0.25,
sleep_calls=sleep_calls,
)
with pytest.raises(TBankPaymentAdapterError, match="retriable status 503"):
asyncio.run(adapter.create_payment_link("order-uuid-1", 125000))
assert len(http_client.calls) == 2
assert sleep_calls == [0.25]
+135
View File
@@ -0,0 +1,135 @@
import hashlib
import pytest
from app.adapters.tbank.base import TBankPaymentNotificationTokenError
from app.adapters.tbank.client import TBankAdapter
from app.schemas.payment import TBankPaymentNotification
def _make_adapter() -> TBankAdapter:
return TBankAdapter(
http_client=object(), # type: ignore[arg-type]
init_url="https://securepay.tinkoff.ru/v2/Init",
notification_url="https://merchant.test/api/v1/delivery/tbank/notifications",
success_url="https://merchant.test/payment/success",
terminal_key="TestTerminal",
password="test-password",
)
def _base_payload(**overrides: object) -> dict[str, object]:
payload: dict[str, object] = {
"TerminalKey": "TestTerminal",
"OrderId": "order-uuid-1",
"Success": True,
"Status": "CONFIRMED",
"PaymentId": 8347568144,
"ErrorCode": "0",
"Amount": 125000,
}
payload.update(overrides)
return payload
def _build_notification_token(payload: dict[str, object]) -> str:
token_payload = {
key: value
for key, value in payload.items()
if key != "Token" and not isinstance(value, (dict, list))
}
token_payload["Password"] = "test-password"
token_source = "".join(
_stringify_token_value(token_payload[key]) for key in sorted(token_payload)
)
return hashlib.sha256(token_source.encode("utf-8")).hexdigest()
def _stringify_token_value(value: object) -> str:
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def _signed_notification(**overrides: object) -> TBankPaymentNotification:
payload = _base_payload(**overrides)
if "Token" not in payload:
payload["Token"] = _build_notification_token(payload)
return TBankPaymentNotification(**payload)
def test_verify_payment_notification_accepts_valid_token() -> None:
adapter = _make_adapter()
notification = _signed_notification()
adapter.verify_payment_notification(notification)
def test_verify_payment_notification_rejects_invalid_token() -> None:
adapter = _make_adapter()
notification = _signed_notification(Token="invalid-token")
with pytest.raises(TBankPaymentNotificationTokenError):
adapter.verify_payment_notification(notification)
def test_verify_payment_notification_excludes_token_from_hash_source() -> None:
adapter = _make_adapter()
payload = _base_payload()
payload["Token"] = _build_notification_token(payload)
notification = TBankPaymentNotification(**payload)
adapter.verify_payment_notification(notification)
def test_verify_payment_notification_excludes_nested_data_and_receipt() -> None:
adapter = _make_adapter()
notification = _signed_notification(
Data={"nested": "ignored"},
Receipt={"Items": [{"Name": "Box", "Price": 125000}]},
)
adapter.verify_payment_notification(notification)
def test_verify_payment_notification_includes_extra_scalar_fields() -> None:
adapter = _make_adapter()
notification = _signed_notification(CardId="card-id-1")
adapter.verify_payment_notification(notification)
payload_without_extra = notification.model_dump(mode="python")
del payload_without_extra["CardId"]
payload_without_extra["Token"] = _build_notification_token(payload_without_extra)
invalid_notification = TBankPaymentNotification(
**payload_without_extra,
CardId="card-id-1",
)
with pytest.raises(TBankPaymentNotificationTokenError):
adapter.verify_payment_notification(invalid_notification)
def test_verify_payment_notification_serializes_booleans_as_lowercase() -> None:
adapter = _make_adapter()
notification = _signed_notification(Success=False)
adapter.verify_payment_notification(notification)
def test_verify_payment_notification_uses_deterministic_sha256_comparison() -> None:
payload = _base_payload()
expected_token = hashlib.sha256(
(
"125000"
"0"
"order-uuid-1"
"test-password"
"8347568144"
"CONFIRMED"
"true"
"TestTerminal"
).encode("utf-8")
).hexdigest()
assert _build_notification_token(payload) == expected_token
+16
View File
@@ -15,6 +15,17 @@ adapter:
cdek_client_id: "yaml-id" cdek_client_id: "yaml-id"
cdek_client_secret: "yaml-secret" cdek_client_secret: "yaml-secret"
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
notification_url: "https://default.test/api/v1/delivery/tbank/notifications"
success_url: "https://default.test/payment/success"
auth:
terminal_key: "yaml-terminal-key"
password: "yaml-password"
postgres:
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/default"
address_suggestions: address_suggestions:
country_to_provider: country_to_provider:
RU: "dadata" RU: "dadata"
@@ -37,3 +48,8 @@ observability:
service_name: "default-config-service" service_name: "default-config-service"
otlp_endpoint: "http://default-collector:4317" otlp_endpoint: "http://default-collector:4317"
otlp_insecure: true otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
@@ -23,6 +23,17 @@ adapter:
cdek_timeout_seconds: 10.0 cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900 cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
success_url: "https://merchant.test/payment/success"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
postgres:
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/invalid_price"
address_suggestions: address_suggestions:
country_to_provider: country_to_provider:
RU: "dadata" RU: "dadata"
@@ -35,3 +46,8 @@ observability:
service_name: "invalid-price-multiplier-service" service_name: "invalid-price-multiplier-service"
otlp_endpoint: "http://collector:4317" otlp_endpoint: "http://collector:4317"
otlp_insecure: true otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
@@ -12,6 +12,17 @@ repository:
redis_dsn: "redis://localhost:6379/0" redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900 price_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
success_url: "https://merchant.test/payment/success"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
postgres:
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/missing_adapter"
address_suggestions: address_suggestions:
country_to_provider: country_to_provider:
RU: "dadata" RU: "dadata"
@@ -24,3 +35,8 @@ observability:
service_name: "missing-adapter-service" service_name: "missing-adapter-service"
otlp_endpoint: "http://collector:4317" otlp_endpoint: "http://collector:4317"
otlp_insecure: true otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
@@ -23,8 +23,24 @@ adapter:
cdek_timeout_seconds: 10.0 cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900 cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
success_url: "https://merchant.test/payment/success"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
postgres:
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/missing_address"
observability: observability:
enabled: false enabled: false
service_name: "missing-address-suggestions-service" service_name: "missing-address-suggestions-service"
otlp_endpoint: "http://collector:4317" otlp_endpoint: "http://collector:4317"
otlp_insecure: true otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
@@ -23,9 +23,25 @@ adapter:
cdek_timeout_seconds: 10.0 cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900 cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
success_url: "https://merchant.test/payment/success"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
postgres:
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/missing_observability"
address_suggestions: address_suggestions:
country_to_provider: country_to_provider:
RU: "dadata" RU: "dadata"
dadata: dadata:
url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
api_key: "missing-observability-dadata-key" api_key: "missing-observability-dadata-key"
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
@@ -23,6 +23,17 @@ adapter:
cdek_timeout_seconds: 10.0 cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900 cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
success_url: "https://merchant.test/payment/success"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
postgres:
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/missing_observability_endpoint"
address_suggestions: address_suggestions:
country_to_provider: country_to_provider:
RU: "dadata" RU: "dadata"
@@ -34,3 +45,8 @@ observability:
enabled: true enabled: true
service_name: "g2s-aggregator" service_name: "g2s-aggregator"
otlp_insecure: true otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
@@ -22,6 +22,17 @@ adapter:
cdek_timeout_seconds: 10.0 cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900 cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
success_url: "https://merchant.test/payment/success"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
postgres:
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/missing_price"
address_suggestions: address_suggestions:
country_to_provider: country_to_provider:
RU: "dadata" RU: "dadata"
@@ -34,3 +45,8 @@ observability:
service_name: "missing-price-multiplier-service" service_name: "missing-price-multiplier-service"
otlp_endpoint: "http://collector:4317" otlp_endpoint: "http://collector:4317"
otlp_insecure: true otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
@@ -15,6 +15,20 @@ adapter:
cdek_client_id: "test-id" cdek_client_id: "test-id"
cdek_client_secret: "test-secret" cdek_client_secret: "test-secret"
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
notification_url: "https://override.test/api/v1/delivery/tbank/notifications"
success_url: "https://override.test/payment/success"
auth:
terminal_key: "override-terminal-key"
password: "override-password"
timeout_seconds: 4.25
retry_attempts: 1
retry_backoff_seconds: 0.05
postgres:
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/override"
address_suggestions: address_suggestions:
country_to_provider: country_to_provider:
RU: "dadata" RU: "dadata"
@@ -38,3 +52,8 @@ observability:
service_name: "override-config-service" service_name: "override-config-service"
otlp_endpoint: "http://override-collector:4317" otlp_endpoint: "http://override-collector:4317"
otlp_insecure: false otlp_insecure: false
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
+119
View File
@@ -104,6 +104,61 @@ def _use_runtime_config_files(
get_settings.cache_clear() get_settings.cache_clear()
def _write_tbank_url_validation_config(
tmp_path: Path,
*,
include_notification_url: bool = True,
include_success_url: bool = True,
) -> Path:
notification_url = (
' notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"\n'
if include_notification_url
else ""
)
success_url = (
' success_url: "https://merchant.test/payment/success"\n'
if include_success_url
else ""
)
config_file = tmp_path / "config.yaml"
config_file.write_text(
f"""
controller: {{}}
service: {{}}
business_logic:
provider_price_multiplier: 1.0
repository: {{}}
adapter: {{}}
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
{notification_url}{success_url} auth:
terminal_key: "test-terminal-key"
password: "test-password"
postgres:
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/config_test"
address_suggestions: {{}}
observability:
service_name: "config-test"
otlp_endpoint: "http://collector:4317"
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
""".strip(),
encoding="utf-8",
)
return config_file
def test_configuration_sections_are_loaded_from_yaml_file( def test_configuration_sections_are_loaded_from_yaml_file(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@@ -126,6 +181,21 @@ def test_configuration_sections_are_loaded_from_yaml_file(
assert settings.adapter.cdek_retry_backoff_seconds == 0.2 assert settings.adapter.cdek_retry_backoff_seconds == 0.2
assert settings.adapter.cdek_timeout_seconds == 10.0 assert settings.adapter.cdek_timeout_seconds == 10.0
assert settings.adapter.cdek_cache_ttl_seconds == 900 assert settings.adapter.cdek_cache_ttl_seconds == 900
assert settings.tbank_payment.init_url == "https://securepay.tinkoff.ru/v2/Init"
assert (
settings.tbank_payment.notification_url
== "https://merchant.test/api/v1/delivery/tbank/notifications"
)
assert settings.tbank_payment.success_url == "https://merchant.test/payment/success"
assert settings.tbank_payment.auth.terminal_key == "test-terminal-key"
assert settings.tbank_payment.auth.password == "test-password"
assert settings.tbank_payment.timeout_seconds == 10.0
assert settings.tbank_payment.retry_attempts == 2
assert settings.tbank_payment.retry_backoff_seconds == 0.2
assert (
settings.postgres.dsn
== "postgresql+asyncpg://postgres:postgres@localhost:5432/g2s_aggregator_test"
)
assert settings.address_suggestions.country_to_provider == _expected_country_mapping() assert settings.address_suggestions.country_to_provider == _expected_country_mapping()
assert ( assert (
settings.address_suggestions.dadata.url settings.address_suggestions.dadata.url
@@ -192,6 +262,12 @@ def test_get_settings_returns_cached_instance(monkeypatch: pytest.MonkeyPatch) -
assert first is second assert first is second
assert first.service.provider_timeout_seconds == 10.0 assert first.service.provider_timeout_seconds == 10.0
assert first.business_logic.provider_price_multiplier == Decimal("1.0") assert first.business_logic.provider_price_multiplier == Decimal("1.0")
assert first.tbank_payment.auth.terminal_key == "test-terminal-key"
assert (
first.tbank_payment.notification_url
== "https://merchant.test/api/v1/delivery/tbank/notifications"
)
assert first.postgres.dsn.endswith("/g2s_aggregator_test")
assert first.address_suggestions.country_to_provider["RU"] == "dadata" assert first.address_suggestions.country_to_provider["RU"] == "dadata"
assert first.observability.service_name == "g2s-aggregator-test" assert first.observability.service_name == "g2s-aggregator-test"
get_settings.cache_clear() get_settings.cache_clear()
@@ -212,6 +288,20 @@ def test_get_settings_uses_config_test_yaml_in_pytest_environment(
assert settings.controller.api_prefix == "/from-config-test-yaml" assert settings.controller.api_prefix == "/from-config-test-yaml"
assert settings.business_logic.provider_price_multiplier == Decimal("1.25") assert settings.business_logic.provider_price_multiplier == Decimal("1.25")
assert settings.adapter.cdek_client_id == "test-id" assert settings.adapter.cdek_client_id == "test-id"
assert settings.tbank_payment.auth.terminal_key == "override-terminal-key"
assert settings.tbank_payment.auth.password == "override-password"
assert (
settings.tbank_payment.notification_url
== "https://override.test/api/v1/delivery/tbank/notifications"
)
assert settings.tbank_payment.success_url == "https://override.test/payment/success"
assert settings.tbank_payment.timeout_seconds == 4.25
assert settings.tbank_payment.retry_attempts == 1
assert settings.tbank_payment.retry_backoff_seconds == 0.05
assert (
settings.postgres.dsn
== "postgresql+asyncpg://postgres:postgres@localhost:5432/override"
)
assert settings.address_suggestions.country_to_provider == { assert settings.address_suggestions.country_to_provider == {
"RU": "dadata", "RU": "dadata",
"AM": "yandex_geosuggest", "AM": "yandex_geosuggest",
@@ -262,6 +352,35 @@ def test_get_settings_fails_when_provider_price_multiplier_is_missing(
get_settings.cache_clear() get_settings.cache_clear()
@pytest.mark.parametrize(
("include_notification_url", "include_success_url", "expected_location"),
(
(False, True, ("tbank_payment", "notification_url")),
(True, False, ("tbank_payment", "success_url")),
),
)
def test_get_settings_fails_when_tbank_payment_url_is_missing(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
include_notification_url: bool,
include_success_url: bool,
expected_location: tuple[str, str],
) -> None:
config_file = _write_tbank_url_validation_config(
tmp_path,
include_notification_url=include_notification_url,
include_success_url=include_success_url,
)
_use_runtime_config_files(monkeypatch, test_config_file=config_file)
with pytest.raises(ValidationError) as error:
get_settings()
locations = {tuple(item["loc"]) for item in error.value.errors()}
assert expected_location in locations
get_settings.cache_clear()
def test_get_settings_fails_when_observability_section_is_missing( def test_get_settings_fails_when_observability_section_is_missing(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
+5 -3
View File
@@ -183,6 +183,7 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
"currency": "RUB", "currency": "RUB",
"delivery_days_min": 2, "delivery_days_min": 2,
"delivery_days_max": 3, "delivery_days_max": 3,
"tariff_code": None,
}, },
{ {
"provider": "stub-provider", "provider": "stub-provider",
@@ -191,6 +192,7 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
"currency": "RUB", "currency": "RUB",
"delivery_days_min": 4, "delivery_days_min": 4,
"delivery_days_max": 5, "delivery_days_max": 5,
"tariff_code": None,
} }
] ]
assert second_response.json() == first_response.json() assert second_response.json() == first_response.json()
@@ -308,9 +310,9 @@ def test_post_delivery_price_filters_response_by_optional_parcel_type(
) -> None: ) -> None:
provider = StubPriceProvider( provider = StubPriceProvider(
response=[ response=[
_make_price(service_name="Parcel locker", price="90.00"), _make_price(service_name="Parcel locker", price="90.00", provider="other"),
_make_price(service_name="Срочный документ", price="150.00"), _make_price(service_name="Срочный документ", price="150.00", provider="other"),
_make_price(service_name="DOCUMENT EXPRESS", price="200.00"), _make_price(service_name="DOCUMENT EXPRESS", price="200.00", provider="other"),
] ]
) )
service = AggregatorService(providers=[provider]) service = AggregatorService(providers=[provider])
+291
View File
@@ -0,0 +1,291 @@
import asyncio
import httpx
from app.controllers.v1.delivery import get_aggregator_service
from app.main import create_app
from app.schemas.payment import InitPaymentRequest, InitPaymentResponse
from app.services.aggregator import (
InitPaymentUnavailableError,
InvalidInitPaymentRequestError,
)
from tests.payment_fixtures import make_init_payment_payload
class StubAggregatorService:
def __init__(self, *, response: object, error: Exception | None = None) -> None:
self._response = response
self._error = error
self.calls: list[InitPaymentRequest] = []
async def init_payment(self, request: InitPaymentRequest) -> object:
self.calls.append(request)
if self._error is not None:
raise self._error
return self._response
def _install_service_override(app, service: StubAggregatorService) -> None:
async def override_service() -> StubAggregatorService:
return service
app.dependency_overrides[get_aggregator_service] = override_service
def _post(app, payload: dict[str, object]) -> httpx.Response:
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/order", json=payload)
return asyncio.run(run_request())
def test_post_init_payment_returns_response_and_delegates_to_service() -> None:
expected_response = InitPaymentResponse(payment_url="https://pay.test/payment/1")
service = StubAggregatorService(response=expected_response)
app = create_app()
_install_service_override(app, service)
payload = make_init_payment_payload()
response = _post(app, payload)
assert response.status_code == 200
assert response.json() == expected_response.model_dump(mode="json")
assert service.calls == [InitPaymentRequest.model_validate(payload)]
def test_post_init_payment_rejects_invalid_tariff_code() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["systemData"]["tariff"]["tariffCode"] = "not-a-number"
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_snake_case_top_level_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["account_email"] = invalid_payload.pop("accountEmail")
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_invalid_account_email() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["accountEmail"] = "not-an-email"
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_snake_case_nested_address_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
sender_address = invalid_payload["senderAddress"]
sender_address["city_id"] = sender_address.pop("cityId")
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_snake_case_nested_tariff_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
tariff = invalid_payload["systemData"]["tariff"]
tariff["tariff_code"] = tariff.pop("tariffCode")
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_missing_price() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
del invalid_payload["systemData"]["tariff"]["price"]
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_non_positive_price() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["systemData"]["tariff"]["price"] = 0
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_non_integer_price() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["systemData"]["tariff"]["price"] = 125000.5
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_requires_company_fields_when_is_company_true() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["senderContact"]["isCompany"] = True
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_accepts_company_with_full_requisites() -> None:
expected_response = InitPaymentResponse(payment_url="https://pay.test/payment/1")
service = StubAggregatorService(response=expected_response)
app = create_app()
_install_service_override(app, service)
payload = make_init_payment_payload()
payload["senderContact"].update(
{
"isCompany": True,
"companyName": "Romashka LLC",
"inn": "7707083893",
"kpp": "770701001",
}
)
response = _post(app, payload)
assert response.status_code == 200
assert service.calls == [InitPaymentRequest.model_validate(payload)]
def test_post_init_payment_rejects_non_numeric_weight() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["systemData"]["weight"] = "string"
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_non_numeric_dimension() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["systemData"]["dimensions"]["length"] = "string"
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_parcel_without_dimensions() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["systemData"]["dimensions"] = None
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_accepts_doc_without_dimensions() -> None:
expected_response = InitPaymentResponse(payment_url="https://pay.test/payment/1")
service = StubAggregatorService(response=expected_response)
app = create_app()
_install_service_override(app, service)
payload = make_init_payment_payload()
payload["systemData"]["parcelType"] = "doc"
payload["systemData"]["docPackaging"] = "envelope"
payload["systemData"]["dimensions"] = None
response = _post(app, payload)
assert response.status_code == 200
assert service.calls == [InitPaymentRequest.model_validate(payload)]
def test_post_init_payment_maps_invalid_request_to_400() -> None:
service = StubAggregatorService(
response=None,
error=InvalidInitPaymentRequestError("invalid payload"),
)
app = create_app()
_install_service_override(app, service)
response = _post(app, make_init_payment_payload())
assert response.status_code == 400
assert response.json() == {
"detail": {
"code": "invalid_init_payment_request",
"message": "Payment request contains invalid or unsupported TBank data.",
}
}
def test_post_init_payment_maps_service_exception_to_503() -> None:
service = StubAggregatorService(
response=None,
error=InitPaymentUnavailableError("service unavailable"),
)
app = create_app()
_install_service_override(app, service)
response = _post(app, make_init_payment_payload())
assert response.status_code == 503
assert response.json() == {
"detail": {
"code": "init_payment_unavailable",
"message": "Payment initialization is temporarily unavailable.",
}
}
-214
View File
@@ -1,214 +0,0 @@
import asyncio
import httpx
from app.controllers.v1.delivery import get_aggregator_service
from app.main import create_app
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
from app.services.aggregator import (
AggregatorServiceError,
InvalidOrderCreateRequestError,
)
class StubAggregatorService:
def __init__(self, *, response: object, error: Exception | None = None) -> None:
self._response = response
self._error = error
self.calls: list[OrderCreateRequest] = []
async def create_order(self, request: OrderCreateRequest) -> object:
self.calls.append(request)
if self._error is not None:
raise self._error
return self._response
def _install_service_override(app, service: StubAggregatorService) -> None:
async def override_service() -> StubAggregatorService:
return service
app.dependency_overrides[get_aggregator_service] = override_service
def _valid_payload() -> dict[str, object]:
return {
"type": 2,
"tariff_code": 535,
"comment": "Test order",
"sender": {
"name": "Petr Petrov",
"email": "sender@example.com",
"phones": [{"number": "+79009876543"}],
},
"recipient": {
"name": "Ivan Ivanov",
"email": "ivan@example.com",
"phones": [{"number": "+79001234567"}],
},
"from_location": {
"address": "Lenina 1",
"city": "Moscow",
"country_code": "RU",
},
"to_location": {
"address": "Pushkina 10",
"city": "Novosibirsk",
"country_code": "RU",
},
"services": [{"code": "INSURANCE", "parameter": "1000"}],
"packages": [
{
"number": "1",
"weight": 1000,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
}
],
}
def test_post_delivery_order_returns_response_and_delegates_to_service() -> None:
expected_response = OrderCreateResponse(provider="cdek", order_uuid="order-uuid-1")
service = StubAggregatorService(response=expected_response)
app = create_app()
_install_service_override(app, service)
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/order", json=_valid_payload())
response = asyncio.run(run_request())
assert response.status_code == 200
assert response.json() == expected_response.model_dump(mode="json")
assert service.calls == [OrderCreateRequest(**_valid_payload())]
def test_post_delivery_order_rejects_invalid_payload() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["tariff_code"] = 136
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/order", json=invalid_payload)
response = asyncio.run(run_request())
assert response.status_code == 422
assert service.calls == []
def test_post_delivery_order_rejects_sender_company_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["sender"] = {
**invalid_payload["sender"], # type: ignore[arg-type]
"company": "Romashka LLC",
}
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/order", json=invalid_payload)
response = asyncio.run(run_request())
assert response.status_code == 422
assert service.calls == []
def test_post_delivery_order_rejects_recipient_company_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["recipient"] = {
**invalid_payload["recipient"], # type: ignore[arg-type]
"company": "Romashka LLC",
}
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/order", json=invalid_payload)
response = asyncio.run(run_request())
assert response.status_code == 422
assert service.calls == []
def test_post_delivery_order_maps_invalid_request_to_400() -> None:
service = StubAggregatorService(
response=None,
error=InvalidOrderCreateRequestError("invalid payload"),
)
app = create_app()
_install_service_override(app, service)
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/order", json=_valid_payload())
response = asyncio.run(run_request())
assert response.status_code == 400
assert response.json() == {
"detail": {
"code": "invalid_order_create_request",
"message": "Order request contains invalid or unsupported CDEK data.",
}
}
def test_post_delivery_order_maps_service_exception_to_503() -> None:
service = StubAggregatorService(
response=None,
error=AggregatorServiceError("service unavailable"),
)
app = create_app()
_install_service_override(app, service)
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/order", json=_valid_payload())
response = asyncio.run(run_request())
assert response.status_code == 503
assert response.json() == {
"detail": {
"code": "order_creation_unavailable",
"message": "CDEK order creation is temporarily unavailable.",
}
}
@@ -0,0 +1,120 @@
import asyncio
import httpx
from app.controllers.v1.delivery import get_aggregator_service
from app.main import create_app
from app.schemas.payment import TBankPaymentNotification
from app.services.aggregator import (
InvalidTBankPaymentNotificationError,
TBankPaymentNotificationProcessingError,
)
class StubAggregatorService:
def __init__(self, *, response: str = "OK", error: Exception | None = None) -> None:
self._response = response
self._error = error
self.calls: list[TBankPaymentNotification] = []
async def handle_tbank_payment_notification(
self,
notification: TBankPaymentNotification,
) -> str:
self.calls.append(notification)
if self._error is not None:
raise self._error
return self._response
def _install_service_override(app, service: StubAggregatorService) -> None:
async def override_service() -> StubAggregatorService:
return service
app.dependency_overrides[get_aggregator_service] = override_service
def _valid_payload() -> dict[str, object]:
return {
"TerminalKey": "TestTerminal",
"OrderId": "order-uuid-1",
"Success": True,
"Status": "CONFIRMED",
"PaymentId": 8347568144,
"ErrorCode": "0",
"Amount": 125000,
"Token": "signed-token",
"Data": {"nested": "accepted by schema"},
}
def _post_notification(app, payload: dict[str, object]) -> httpx.Response:
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/tbank/notifications",
json=payload,
)
return asyncio.run(run_request())
def test_post_tbank_notification_returns_plain_text_ok_and_delegates_once() -> None:
service = StubAggregatorService(response="OK")
app = create_app()
_install_service_override(app, service)
response = _post_notification(app, _valid_payload())
assert response.status_code == 200
assert response.text == "OK"
assert response.headers["content-type"].startswith("text/plain")
assert service.calls == [TBankPaymentNotification(**_valid_payload())]
def test_post_tbank_notification_maps_invalid_token_to_400() -> None:
service = StubAggregatorService(
error=InvalidTBankPaymentNotificationError("invalid token")
)
app = create_app()
_install_service_override(app, service)
response = _post_notification(app, _valid_payload())
assert response.status_code == 400
assert response.json()["detail"]["code"] == "invalid_tbank_payment_notification"
assert service.calls == [TBankPaymentNotification(**_valid_payload())]
def test_post_tbank_notification_maps_processing_failure_to_503() -> None:
service = StubAggregatorService(
error=TBankPaymentNotificationProcessingError("temporary failure")
)
app = create_app()
_install_service_override(app, service)
response = _post_notification(app, _valid_payload())
assert response.status_code == 503
assert (
response.json()["detail"]["code"]
== "tbank_payment_notification_processing_unavailable"
)
assert service.calls == [TBankPaymentNotification(**_valid_payload())]
def test_post_tbank_notification_rejects_invalid_payload_without_service_call() -> None:
service = StubAggregatorService()
app = create_app()
_install_service_override(app, service)
payload = _valid_payload()
del payload["Token"]
response = _post_notification(app, payload)
assert response.status_code == 422
assert service.calls == []
+19
View File
@@ -0,0 +1,19 @@
import pytest
from app.domain.cdek_polling import TERMINAL_ORDER_STATUSES, is_terminal_order_status
@pytest.mark.parametrize(
"code", sorted(TERMINAL_ORDER_STATUSES)
)
def test_terminal_statuses_are_terminal(code: str) -> None:
assert is_terminal_order_status(code) is True
@pytest.mark.parametrize("code", ["ACCEPTED", "CREATED", "RECEIVED_AT_SHIPMENT_WAREHOUSE", ""])
def test_non_terminal_statuses_are_not_terminal(code: str) -> None:
assert is_terminal_order_status(code) is False
def test_none_status_is_not_terminal() -> None:
assert is_terminal_order_status(None) is False
@@ -0,0 +1,55 @@
from app.domain.payment_notifications import (
TBankPaymentNotificationAction,
resolve_tbank_payment_notification_action,
)
def test_confirmed_success_zero_error_code_registers_cdek_order() -> None:
result = resolve_tbank_payment_notification_action(
status="CONFIRMED",
success=True,
error_code="0",
)
assert result is TBankPaymentNotificationAction.REGISTER_CDEK_ORDER
def test_confirmed_without_success_acknowledges_only() -> None:
result = resolve_tbank_payment_notification_action(
status="CONFIRMED",
success=False,
error_code="0",
)
assert result is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
def test_confirmed_with_non_zero_error_code_acknowledges_only() -> None:
result = resolve_tbank_payment_notification_action(
status="CONFIRMED",
success=True,
error_code="101",
)
assert result is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
def test_known_non_confirmed_statuses_acknowledge_only() -> None:
for status in ("AUTHORIZED", "REJECTED", "CANCELED", "DEADLINE_EXPIRED"):
result = resolve_tbank_payment_notification_action(
status=status,
success=True,
error_code="0",
)
assert result is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
def test_unknown_status_acknowledges_only() -> None:
result = resolve_tbank_payment_notification_action(
status="UNKNOWN_STATUS",
success=True,
error_code="0",
)
assert result is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
@@ -0,0 +1,62 @@
from decimal import Decimal
from app.domain.price import (
calculate_expected_payment_amount_kopecks,
is_init_payment_price_valid,
)
from app.schemas.response import DeliveryPrice
def _make_price(**overrides: object) -> DeliveryPrice:
payload = {
"provider": "cdek",
"service_name": "CDEK tariff",
"price": Decimal("1250.00"),
"currency": "RUB",
"delivery_days_min": 1,
"delivery_days_max": 2,
}
payload.update(overrides)
return DeliveryPrice.model_construct(**payload)
def test_init_payment_price_validation_accepts_exact_match() -> None:
provider_price = _make_price(price=Decimal("1250.00"))
assert is_init_payment_price_valid(125000, provider_price)
assert calculate_expected_payment_amount_kopecks(provider_price) == 125000
def test_init_payment_price_validation_rejects_mismatch() -> None:
provider_price = _make_price(price=Decimal("1250.00"))
assert not is_init_payment_price_valid(124999, provider_price)
def test_init_payment_price_validation_applies_multiplier_and_rounds_half_up() -> None:
provider_price = _make_price(price=Decimal("100.50"))
expected_amount = calculate_expected_payment_amount_kopecks(
provider_price,
price_multiplier=Decimal("1.1"),
)
assert expected_amount == 11100
assert is_init_payment_price_valid(
11100,
provider_price,
price_multiplier=Decimal("1.1"),
)
def test_init_payment_price_validation_converts_rub_to_kopecks() -> None:
provider_price = _make_price(price=Decimal("899.00"))
assert calculate_expected_payment_amount_kopecks(provider_price) == 89900
def test_init_payment_price_validation_rejects_non_rub_currency() -> None:
provider_price = _make_price(price=Decimal("899.00"), currency="USD")
assert calculate_expected_payment_amount_kopecks(provider_price) is None
assert not is_init_payment_price_valid(89900, provider_price)
+35
View File
@@ -106,6 +106,41 @@ def test_filter_prices_by_parcel_type_returns_non_document_tariffs_for_parcel()
assert [price.service_name for price in result] == ["Economy parcel", "Express"] assert [price.service_name for price in result] == ["Economy parcel", "Express"]
def test_filter_prices_by_parcel_type_keeps_prices_with_bypass_flag_regardless_of_type() -> None:
bypass_document = _make_price(
provider="cdek",
service_name="Документ курьером",
bypass_parcel_type_filter=True,
)
bypass_parcel = _make_price(
provider="cdek",
service_name="Посылка склад-склад",
bypass_parcel_type_filter=True,
)
other_document = _make_price(provider="other", service_name="Document")
other_parcel = _make_price(provider="other", service_name="Parcel")
doc_result = filter_prices_by_parcel_type(
[bypass_document, bypass_parcel, other_document, other_parcel],
parcel_type=ParcelType.DOC,
)
parcel_result = filter_prices_by_parcel_type(
[bypass_document, bypass_parcel, other_document, other_parcel],
parcel_type=ParcelType.PARCEL,
)
assert [price.service_name for price in doc_result] == [
"Документ курьером",
"Посылка склад-склад",
"Document",
]
assert [price.service_name for price in parcel_result] == [
"Документ курьером",
"Посылка склад-склад",
"Parcel",
]
def test_filter_prices_by_parcel_type_returns_all_prices_when_type_is_missing() -> None: def test_filter_prices_by_parcel_type_returns_all_prices_when_type_is_missing() -> None:
prices = [ prices = [
_make_price(service_name="Документ"), _make_price(service_name="Документ"),
+82
View File
@@ -0,0 +1,82 @@
"""Shared fixtures for the camelCase init-payment contract."""
from typing import Any
from app.schemas.payment import InitPaymentRequest
def make_init_payment_payload(**overrides: Any) -> dict[str, Any]:
"""Return a valid camelCase JSON payload for /api/v1/delivery/order."""
payload: dict[str, Any] = {
"senderAddress": {
"cityId": 1,
"city": "Дубай",
"street": "Sheikh Zayed Road",
"house": "10",
"apartment": "201",
"zip": "12345",
"comment": "Domofon 12",
},
"senderContact": {
"fullName": "Petr Petrov",
"email": "sender@example.com",
"phone": "+79009876543",
"phoneExt": None,
"isCompany": False,
"companyName": None,
"inn": None,
"kpp": None,
},
"receiverAddress": {
"cityId": 2,
"city": "Шарджа",
"street": "Al Wahda",
"house": "5",
"apartment": None,
"zip": "54321",
"comment": None,
},
"receiverContact": {
"fullName": "Ivan Ivanov",
"email": "ivan@example.com",
"phone": "+79001234567",
"phoneExt": "101",
"isCompany": False,
"companyName": None,
"inn": None,
"kpp": None,
},
"content": {
"description": "Headphones",
},
"pickupDate": "2026-05-15T10:00:00.000Z",
"deliveryDate": "2026-05-18T18:00:00.000Z",
"accountEmail": "client@example.com",
"agreePrivacy": True,
"agreeTerms": True,
"systemData": {
"tariff": {
"provider": "СДЭК",
"serviceName": "Экспресс лайт",
"price": 125000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": 535,
},
"parcelType": "parcel",
"docPackaging": None,
"weight": "1.0",
"dimensions": {
"length": "20",
"width": "15",
"height": "10",
},
},
}
payload.update(overrides)
return payload
def make_init_payment_request(**overrides: Any) -> InitPaymentRequest:
return InitPaymentRequest.model_validate(make_init_payment_payload(**overrides))
+16
View File
@@ -179,11 +179,27 @@ adapter:
cdek_timeout_seconds: 10.0 cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900 cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
success_url: "https://merchant.test/payment/success"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
postgres:
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/repository_test"
observability: observability:
enabled: false enabled: false
service_name: "repository-test-service" service_name: "repository-test-service"
otlp_endpoint: "http://collector:4317" otlp_endpoint: "http://collector:4317"
otlp_insecure: true otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
""".strip(), """.strip(),
encoding="utf-8", encoding="utf-8",
) )
+598
View File
@@ -0,0 +1,598 @@
import asyncio
from collections.abc import Awaitable, Callable
from datetime import datetime, timezone
from typing import Any
import pytest
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.ext.asyncio import create_async_engine
from app.repositories.order import OrderData, OrderRepository
from app.repositories.order.models import Base, Order
from tests.payment_fixtures import make_init_payment_payload
def _make_order_data(**overrides: object) -> OrderData:
payload: dict[str, Any] = {
"order_uuid": "order-uuid-1",
"payment_url": "https://pay.test/payment/1",
"price": 125000,
"tariff_code": 535,
"account_email": "client@example.com",
"payload": make_init_payment_payload(),
}
payload.update(overrides)
return OrderData(**payload)
async def _with_repository(
test_fn: Callable[
[OrderRepository, async_sessionmaker[AsyncSession]],
Awaitable[None],
],
) -> None:
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
try:
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
await test_fn(OrderRepository(session_factory=session_factory), session_factory)
finally:
await engine.dispose()
def test_create_order_persists_all_required_fields() -> None:
async def run(
repository: OrderRepository,
session_factory: async_sessionmaker[AsyncSession],
) -> None:
order_data = _make_order_data()
async with repository.session() as session:
order = await repository.create_order(session, order_data)
async with session_factory() as session:
result = await session.execute(
select(Order).where(Order.order_uuid == "order-uuid-1")
)
persisted_order = result.scalar_one()
assert order.id == persisted_order.id
assert persisted_order.order_uuid == "order-uuid-1"
assert persisted_order.payment_url == "https://pay.test/payment/1"
assert persisted_order.price == 125000
assert persisted_order.tariff_code == 535
assert persisted_order.account_email == "client@example.com"
assert persisted_order.payload == order_data.payload
assert persisted_order.payment_status is None
assert persisted_order.tbank_payment_id is None
assert persisted_order.cdek_order_uuid is None
assert persisted_order.cdek_waybill_uuid is None
assert persisted_order.cdek_waybill_url is None
assert persisted_order.created_at is not None
assert persisted_order.updated_at is not None
asyncio.run(_with_repository(run))
def test_create_order_rejects_duplicate_order_uuid() -> None:
async def run(
repository: OrderRepository,
_session_factory: async_sessionmaker[AsyncSession],
) -> None:
order_data = _make_order_data()
async with repository.session() as session:
await repository.create_order(session, order_data)
with pytest.raises(IntegrityError):
async with repository.session() as session:
await repository.create_order(session, order_data)
asyncio.run(_with_repository(run))
def test_get_order_by_order_uuid_returns_persisted_order() -> None:
async def run(
repository: OrderRepository,
_session_factory: async_sessionmaker[AsyncSession],
) -> None:
order_data = _make_order_data()
async with repository.session() as session:
await repository.create_order(session, order_data)
async with repository.session() as session:
order = await repository.get_order_by_order_uuid(session, "order-uuid-1")
assert order is not None
assert order.order_uuid == "order-uuid-1"
assert order.payment_url == "https://pay.test/payment/1"
asyncio.run(_with_repository(run))
def test_get_order_by_order_uuid_returns_none_for_missing_order() -> None:
async def run(
repository: OrderRepository,
_session_factory: async_sessionmaker[AsyncSession],
) -> None:
async with repository.session() as session:
order = await repository.get_order_by_order_uuid(session, "missing-order")
assert order is None
asyncio.run(_with_repository(run))
def test_mark_payment_status_persists_status_and_payment_id() -> None:
async def run(
repository: OrderRepository,
session_factory: async_sessionmaker[AsyncSession],
) -> None:
async with repository.session() as session:
await repository.create_order(session, _make_order_data())
async with repository.session() as session:
order = await repository.mark_payment_status(
session,
"order-uuid-1",
"CONFIRMED",
8347568144,
)
async with session_factory() as session:
result = await session.execute(
select(Order).where(Order.order_uuid == "order-uuid-1")
)
persisted_order = result.scalar_one()
assert order is not None
assert persisted_order.payment_status == "CONFIRMED"
assert persisted_order.tbank_payment_id == 8347568144
asyncio.run(_with_repository(run))
def test_mark_payment_status_returns_none_for_missing_order() -> None:
async def run(
repository: OrderRepository,
_session_factory: async_sessionmaker[AsyncSession],
) -> None:
async with repository.session() as session:
order = await repository.mark_payment_status(
session,
"missing-order",
"CONFIRMED",
8347568144,
)
assert order is None
asyncio.run(_with_repository(run))
def test_mark_cdek_order_registered_persists_cdek_order_uuid_only() -> None:
async def run(
repository: OrderRepository,
session_factory: async_sessionmaker[AsyncSession],
) -> None:
async with repository.session() as session:
await repository.create_order(session, _make_order_data())
async with repository.session() as session:
order = await repository.mark_cdek_order_registered(
session,
"order-uuid-1",
"cdek-order-uuid-1",
)
async with session_factory() as session:
result = await session.execute(
select(Order).where(Order.order_uuid == "order-uuid-1")
)
persisted_order = result.scalar_one()
assert order is not None
assert persisted_order.cdek_order_uuid == "cdek-order-uuid-1"
assert persisted_order.cdek_waybill_uuid is None
assert persisted_order.cdek_waybill_url is None
asyncio.run(_with_repository(run))
def test_mark_cdek_order_registered_is_idempotent_for_same_uuid() -> None:
async def run(
repository: OrderRepository,
session_factory: async_sessionmaker[AsyncSession],
) -> None:
async with repository.session() as session:
await repository.create_order(session, _make_order_data())
async with repository.session() as session:
await repository.mark_cdek_order_registered(
session,
"order-uuid-1",
"cdek-order-uuid-1",
)
async with repository.session() as session:
await repository.mark_cdek_order_registered(
session,
"order-uuid-1",
"cdek-order-uuid-1",
)
async with session_factory() as session:
result = await session.execute(select(Order))
orders = result.scalars().all()
assert len(orders) == 1
assert orders[0].cdek_order_uuid == "cdek-order-uuid-1"
asyncio.run(_with_repository(run))
def test_mark_cdek_order_registered_returns_none_for_missing_order() -> None:
async def run(
repository: OrderRepository,
_session_factory: async_sessionmaker[AsyncSession],
) -> None:
async with repository.session() as session:
order = await repository.mark_cdek_order_registered(
session,
"missing-order",
"cdek-order-uuid-1",
)
assert order is None
asyncio.run(_with_repository(run))
async def _seed_order(
repository: OrderRepository,
*,
order_uuid: str,
cdek_order_uuid: str | None,
cdek_order_status: str | None = None,
cdek_waybill_uuid: str | None = None,
cdek_waybill_url: str | None = None,
cdek_polled_at: datetime | None = None,
) -> None:
async with repository.session() as session:
await repository.create_order(
session, _make_order_data(order_uuid=order_uuid)
)
if cdek_order_uuid is not None:
await repository.mark_cdek_order_registered(
session, order_uuid, cdek_order_uuid
)
if (
cdek_order_status is not None
or cdek_waybill_uuid is not None
or cdek_polled_at is not None
):
order = await repository.get_order_by_order_uuid(session, order_uuid)
assert order is not None
if cdek_order_status is not None:
order.cdek_order_status = cdek_order_status
if cdek_waybill_uuid is not None:
order.cdek_waybill_uuid = cdek_waybill_uuid
if cdek_polled_at is not None:
order.cdek_polled_at = cdek_polled_at
if cdek_waybill_url is not None:
order = await repository.get_order_by_order_uuid(session, order_uuid)
assert order is not None
order.cdek_waybill_url = cdek_waybill_url
def test_list_orders_pending_waybill_returns_orders_without_url() -> None:
async def run(
repository: OrderRepository,
_session_factory: async_sessionmaker[AsyncSession],
) -> None:
await _seed_order(repository, order_uuid="pending", cdek_order_uuid="o1")
await _seed_order(
repository,
order_uuid="done",
cdek_order_uuid="o2",
cdek_waybill_uuid="w2",
cdek_waybill_url="https://cdek.test/2.pdf",
)
await _seed_order(
repository,
order_uuid="invalid",
cdek_order_uuid="o3",
cdek_order_status="INVALID",
)
await _seed_order(repository, order_uuid="no-cdek", cdek_order_uuid=None)
async with repository.session() as session:
orders = await repository.list_orders_pending_waybill(session, limit=10)
assert [order.order_uuid for order in orders] == ["pending"]
asyncio.run(_with_repository(run))
def test_list_orders_pending_waybill_orders_polled_at_nulls_first() -> None:
earlier = datetime(2026, 5, 24, 10, 0, tzinfo=timezone.utc)
later = datetime(2026, 5, 24, 11, 0, tzinfo=timezone.utc)
async def run(
repository: OrderRepository,
_session_factory: async_sessionmaker[AsyncSession],
) -> None:
await _seed_order(
repository,
order_uuid="late",
cdek_order_uuid="o-late",
cdek_polled_at=later,
)
await _seed_order(
repository,
order_uuid="early",
cdek_order_uuid="o-early",
cdek_polled_at=earlier,
)
await _seed_order(repository, order_uuid="never", cdek_order_uuid="o-never")
async with repository.session() as session:
orders = await repository.list_orders_pending_waybill(session, limit=10)
assert [order.order_uuid for order in orders] == ["never", "early", "late"]
asyncio.run(_with_repository(run))
def test_record_order_poll_sets_status_and_waybill_uuid() -> None:
polled = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc)
async def run(
repository: OrderRepository,
_session_factory: async_sessionmaker[AsyncSession],
) -> None:
await _seed_order(repository, order_uuid="o", cdek_order_uuid="cdek-o")
async with repository.session() as session:
order = await repository.record_order_poll(
session,
order_uuid="o",
order_status="ACCEPTED",
waybill_uuid="waybill-1",
polled_at=polled,
)
assert order is not None
assert order.cdek_order_status == "ACCEPTED"
assert order.cdek_waybill_uuid == "waybill-1"
assert order.cdek_polled_at == polled
asyncio.run(_with_repository(run))
def test_record_order_poll_does_not_overwrite_existing_waybill_uuid() -> None:
polled = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc)
async def run(
repository: OrderRepository,
_session_factory: async_sessionmaker[AsyncSession],
) -> None:
await _seed_order(
repository,
order_uuid="o",
cdek_order_uuid="cdek-o",
cdek_waybill_uuid="existing-waybill",
)
async with repository.session() as session:
order = await repository.record_order_poll(
session,
order_uuid="o",
order_status="ACCEPTED",
waybill_uuid="new-waybill",
polled_at=polled,
)
assert order is not None
assert order.cdek_waybill_uuid == "existing-waybill"
asyncio.run(_with_repository(run))
def test_record_waybill_poll_sets_url_only_when_previously_null() -> None:
polled = datetime(2026, 5, 24, 13, 0, tzinfo=timezone.utc)
async def run(
repository: OrderRepository,
_session_factory: async_sessionmaker[AsyncSession],
) -> None:
await _seed_order(
repository,
order_uuid="o",
cdek_order_uuid="cdek-o",
cdek_waybill_uuid="waybill-1",
)
async with repository.session() as session:
order = await repository.record_waybill_poll(
session,
order_uuid="o",
waybill_url="https://cdek.test/1.pdf",
polled_at=polled,
)
assert order is not None
assert order.cdek_waybill_url == "https://cdek.test/1.pdf"
assert order.cdek_polled_at == polled
async with repository.session() as session:
order = await repository.record_waybill_poll(
session,
order_uuid="o",
waybill_url="https://cdek.test/REPLACED.pdf",
polled_at=polled,
)
assert order is not None
assert order.cdek_waybill_url == "https://cdek.test/1.pdf"
asyncio.run(_with_repository(run))
def test_list_orders_pending_waybill_email_returns_orders_with_url_and_no_sent_at() -> None:
async def run(
repository: OrderRepository,
_session_factory: async_sessionmaker[AsyncSession],
) -> None:
await _seed_order(
repository,
order_uuid="ready",
cdek_order_uuid="o1",
cdek_waybill_uuid="w1",
cdek_waybill_url="https://cdek.test/1.pdf",
)
await _seed_order(
repository,
order_uuid="no-url",
cdek_order_uuid="o2",
cdek_waybill_uuid="w2",
)
await _seed_order(
repository,
order_uuid="already-sent",
cdek_order_uuid="o3",
cdek_waybill_uuid="w3",
cdek_waybill_url="https://cdek.test/3.pdf",
)
async with repository.session() as session:
sent = await repository.get_order_by_order_uuid(session, "already-sent")
assert sent is not None
sent.waybill_email_sent_at = datetime(
2026, 5, 24, 12, 0, tzinfo=timezone.utc
)
async with repository.session() as session:
orders = await repository.list_orders_pending_waybill_email(
session, limit=10
)
assert [order.order_uuid for order in orders] == ["ready"]
asyncio.run(_with_repository(run))
def test_list_orders_pending_waybill_email_orders_by_created_at_asc() -> None:
async def run(
repository: OrderRepository,
_session_factory: async_sessionmaker[AsyncSession],
) -> None:
await _seed_order(
repository,
order_uuid="first",
cdek_order_uuid="o1",
cdek_waybill_uuid="w1",
cdek_waybill_url="https://cdek.test/1.pdf",
)
await _seed_order(
repository,
order_uuid="second",
cdek_order_uuid="o2",
cdek_waybill_uuid="w2",
cdek_waybill_url="https://cdek.test/2.pdf",
)
async with repository.session() as session:
orders = await repository.list_orders_pending_waybill_email(
session, limit=10
)
assert [order.order_uuid for order in orders] == ["first", "second"]
asyncio.run(_with_repository(run))
def test_record_waybill_email_sent_sets_timestamp_once() -> None:
first_sent = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc)
second_sent = datetime(2026, 5, 24, 13, 0, tzinfo=timezone.utc)
async def run(
repository: OrderRepository,
_session_factory: async_sessionmaker[AsyncSession],
) -> None:
await _seed_order(
repository,
order_uuid="o",
cdek_order_uuid="cdek-o",
cdek_waybill_uuid="w",
cdek_waybill_url="https://cdek.test/1.pdf",
)
async with repository.session() as session:
order = await repository.record_waybill_email_sent(
session, order_uuid="o", sent_at=first_sent
)
assert order is not None
assert order.waybill_email_sent_at == first_sent
async with repository.session() as session:
order = await repository.record_waybill_email_sent(
session, order_uuid="o", sent_at=second_sent
)
assert order is not None
assert order.waybill_email_sent_at is not None
assert order.waybill_email_sent_at.replace(
tzinfo=None
) == first_sent.replace(tzinfo=None)
asyncio.run(_with_repository(run))
def test_record_waybill_email_sent_returns_none_for_missing_order() -> None:
async def run(
repository: OrderRepository,
_session_factory: async_sessionmaker[AsyncSession],
) -> None:
async with repository.session() as session:
order = await repository.record_waybill_email_sent(
session,
order_uuid="missing",
sent_at=datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc),
)
assert order is None
asyncio.run(_with_repository(run))
def test_record_waybill_poll_updates_polled_at_when_url_is_none() -> None:
polled = datetime(2026, 5, 24, 13, 0, tzinfo=timezone.utc)
async def run(
repository: OrderRepository,
_session_factory: async_sessionmaker[AsyncSession],
) -> None:
await _seed_order(
repository,
order_uuid="o",
cdek_order_uuid="cdek-o",
cdek_waybill_uuid="waybill-1",
)
async with repository.session() as session:
order = await repository.record_waybill_poll(
session,
order_uuid="o",
waybill_url=None,
polled_at=polled,
)
assert order is not None
assert order.cdek_waybill_url is None
assert order.cdek_polled_at == polled
asyncio.run(_with_repository(run))
+5 -5
View File
@@ -218,12 +218,12 @@ def test_get_all_prices_cache_hit_skips_provider_call() -> None:
StubCache( StubCache(
forced_get_value=[ forced_get_value=[
_make_price( _make_price(
"cdek", "other",
"100.40", "100.40",
service_name="DOCUMENT EXPRESS", service_name="DOCUMENT EXPRESS",
).model_dump(mode="json"), ).model_dump(mode="json"),
_make_price( _make_price(
"cdek", "other",
"200.40", "200.40",
service_name="Economy parcel", service_name="Economy parcel",
).model_dump(mode="json"), ).model_dump(mode="json"),
@@ -238,10 +238,10 @@ def test_get_all_prices_applies_same_parcel_type_filter_for_fresh_and_cached_res
expected_provider_calls: int, expected_provider_calls: int,
) -> None: ) -> None:
provider = StubProvider( provider = StubProvider(
name="cdek", name="other",
response=[ response=[
_make_price("cdek", "100.40", service_name="DOCUMENT EXPRESS"), _make_price("other", "100.40", service_name="DOCUMENT EXPRESS"),
_make_price("cdek", "200.40", service_name="Economy parcel"), _make_price("other", "200.40", service_name="Economy parcel"),
], ],
) )
service = AggregatorService( service = AggregatorService(
+282
View File
@@ -0,0 +1,282 @@
import asyncio
from decimal import Decimal
import pytest
from app.adapters.delivery_providers.base import ProviderClientError, ProviderRequestError
from app.adapters.tbank.base import (
TBankPaymentAdapterError,
TBankPaymentRequestError,
)
from app.repositories.order import OrderData
from app.schemas.payment import InitPaymentRequest, InitPaymentResponse
from app.schemas.response import DeliveryPrice
from app.services.aggregator import (
AggregatorService,
InitPaymentUnavailableError,
InvalidInitPaymentRequestError,
)
from tests.payment_fixtures import make_init_payment_payload, make_init_payment_request
class StubPaymentAdapter:
def __init__(
self,
*,
response: str | None = None,
error: Exception | None = None,
events: list[str] | None = None,
) -> None:
self._response = response
self._error = error
self._events = events
self.calls: list[tuple[str, int]] = []
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str:
self.calls.append((order_uuid, amount_kopecks))
if self._events is not None:
self._events.append("tbank")
if self._error is not None:
raise self._error
if self._response is None:
raise RuntimeError("Stub payment adapter has no response configured.")
return self._response
class StubPaymentPriceValidationAdapter:
def __init__(
self,
*,
response: DeliveryPrice | None = None,
error: Exception | None = None,
events: list[str] | None = None,
) -> None:
self._response = response
self._error = error
self._events = events
self.calls: list[InitPaymentRequest] = []
async def get_payment_price(
self,
request: InitPaymentRequest,
) -> DeliveryPrice | None:
self.calls.append(request)
if self._events is not None:
self._events.append("cdek")
if self._error is not None:
raise self._error
return self._response
class StubOrderSessionContext:
def __init__(self, session: object) -> None:
self._session = session
async def __aenter__(self) -> object:
return self._session
async def __aexit__(self, exc_type, exc, traceback) -> None:
return None
class StubOrderRepository:
def __init__(self, *, error: Exception | None = None) -> None:
self._error = error
self.session_value = object()
self.calls: list[tuple[object, OrderData]] = []
def session(self) -> StubOrderSessionContext:
return StubOrderSessionContext(self.session_value)
async def create_order(self, session: object, order_data: OrderData) -> object:
self.calls.append((session, order_data))
if self._error is not None:
raise self._error
return object()
def _make_cdek_price(**overrides: object) -> DeliveryPrice:
payload = {
"provider": "cdek",
"service_name": "CDEK tariff",
"price": Decimal("1250.00"),
"currency": "RUB",
"delivery_days_min": 1,
"delivery_days_max": 2,
}
payload.update(overrides)
return DeliveryPrice.model_construct(**payload)
def test_init_payment_validates_cdek_price_before_tbank_and_returns_payment_url() -> None:
request = make_init_payment_request()
events: list[str] = []
validation_adapter = StubPaymentPriceValidationAdapter(
response=_make_cdek_price(),
events=events,
)
adapter = StubPaymentAdapter(response="https://pay.test/payment/1", events=events)
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
order_uuid_factory=lambda: "order-uuid-1",
)
result = asyncio.run(service.init_payment(request))
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
assert validation_adapter.calls == [request]
assert adapter.calls == [("order-uuid-1", 125000)]
assert events == ["cdek", "tbank"]
def test_init_payment_persists_order_payload_after_successful_payment_link() -> None:
payload = make_init_payment_payload()
request = InitPaymentRequest.model_validate(payload)
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
order_repository = StubOrderRepository()
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
order_repository=order_repository,
order_uuid_factory=lambda: "order-uuid-1",
)
asyncio.run(service.init_payment(request))
assert len(order_repository.calls) == 1
session, order_data = order_repository.calls[0]
assert session is order_repository.session_value
assert order_data.order_uuid == "order-uuid-1"
assert order_data.payment_url == "https://pay.test/payment/1"
assert order_data.price == 125000
assert order_data.tariff_code == 535
assert order_data.account_email == "client@example.com"
assert order_data.payload == request.model_dump(mode="json", by_alias=True)
def test_init_payment_returns_payment_url_when_order_persistence_fails() -> None:
request = make_init_payment_request()
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
order_repository = StubOrderRepository(error=RuntimeError("database down"))
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
order_repository=order_repository,
order_uuid_factory=lambda: "order-uuid-1",
)
result = asyncio.run(service.init_payment(request))
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
assert adapter.calls == [("order-uuid-1", 125000)]
assert len(order_repository.calls) == 1
def test_init_payment_maps_provider_request_errors_to_invalid_payment_error() -> None:
request = make_init_payment_request()
adapter = StubPaymentAdapter(error=TBankPaymentRequestError("bad payload"))
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
)
with pytest.raises(InvalidInitPaymentRequestError):
asyncio.run(service.init_payment(request))
def test_init_payment_maps_client_failures_to_unavailable_error() -> None:
request = make_init_payment_request()
adapter = StubPaymentAdapter(error=TBankPaymentAdapterError("transport down"))
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
)
with pytest.raises(InitPaymentUnavailableError):
asyncio.run(service.init_payment(request))
def test_init_payment_without_configured_adapter_raises_unavailable_error() -> None:
service = AggregatorService(providers=[])
with pytest.raises(InitPaymentUnavailableError):
asyncio.run(service.init_payment(make_init_payment_request()))
def test_init_payment_rejects_cdek_price_mismatch_without_tbank_or_repository_calls() -> None:
payload = make_init_payment_payload()
payload["systemData"]["tariff"]["price"] = 124999
request = InitPaymentRequest.model_validate(payload)
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
order_repository = StubOrderRepository()
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
order_repository=order_repository,
)
with pytest.raises(InvalidInitPaymentRequestError):
asyncio.run(service.init_payment(request))
assert adapter.calls == []
assert order_repository.calls == []
def test_init_payment_rejects_when_cdek_does_not_return_requested_tariff() -> None:
request = make_init_payment_request()
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(response=None)
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
)
with pytest.raises(InvalidInitPaymentRequestError):
asyncio.run(service.init_payment(request))
assert adapter.calls == []
def test_init_payment_maps_cdek_request_error_to_invalid_payment_error() -> None:
request = make_init_payment_request()
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(
error=ProviderRequestError("bad CDEK payload")
)
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
)
with pytest.raises(InvalidInitPaymentRequestError):
asyncio.run(service.init_payment(request))
def test_init_payment_maps_cdek_client_error_to_unavailable_error() -> None:
request = make_init_payment_request()
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(
error=ProviderClientError("CDEK unavailable")
)
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
)
with pytest.raises(InitPaymentUnavailableError):
asyncio.run(service.init_payment(request))
-114
View File
@@ -1,114 +0,0 @@
import asyncio
import pytest
from app.adapters.delivery_providers.base import ProviderRequestError
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
from app.services.aggregator import (
AggregatorService,
InvalidOrderCreateRequestError,
OrderCreationUnavailableError,
)
class StubOrderAdapter:
def __init__(
self,
*,
response: OrderCreateResponse | None = None,
error: Exception | None = None,
) -> None:
self._response = response
self._error = error
self.calls: list[OrderCreateRequest] = []
async def register_order(self, request: OrderCreateRequest) -> OrderCreateResponse:
self.calls.append(request)
if self._error is not None:
raise self._error
if self._response is None:
raise RuntimeError("Stub order adapter has no response configured.")
return self._response
def _make_order_request(**overrides: object) -> OrderCreateRequest:
payload: dict[str, object] = {
"type": 2,
"tariff_code": 535,
"comment": "Test order",
"sender": {
"name": "Petr Petrov",
"email": "sender@example.com",
"phones": [{"number": "+79009876543"}],
},
"recipient": {
"name": "Ivan Ivanov",
"email": "ivan@example.com",
"phones": [{"number": "+79001234567"}],
},
"from_location": {
"address": "Lenina 1",
"city": "Moscow",
"country_code": "RU",
},
"to_location": {
"address": "Pushkina 10",
"city": "Novosibirsk",
"country_code": "RU",
},
"services": [{"code": "INSURANCE", "parameter": "1000"}],
"packages": [
{
"number": "1",
"weight": 1000,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
}
],
}
payload.update(overrides)
return OrderCreateRequest(**payload)
def test_create_order_delegates_to_adapter_and_returns_created_order() -> None:
request = _make_order_request()
adapter = StubOrderAdapter(
response=OrderCreateResponse(provider="cdek", order_uuid="order-uuid-1")
)
service = AggregatorService(providers=[], order_adapter=adapter)
result = asyncio.run(service.create_order(request))
assert result == OrderCreateResponse(provider="cdek", order_uuid="order-uuid-1")
assert adapter.calls == [request]
def test_create_order_maps_provider_request_errors_to_invalid_order_error() -> None:
request = _make_order_request()
adapter = StubOrderAdapter(error=ProviderRequestError("bad payload"))
service = AggregatorService(providers=[], order_adapter=adapter)
with pytest.raises(InvalidOrderCreateRequestError):
asyncio.run(service.create_order(request))
assert adapter.calls == [request]
def test_create_order_maps_transport_failures_to_unavailable_error() -> None:
request = _make_order_request()
adapter = StubOrderAdapter(error=RuntimeError("transport down"))
service = AggregatorService(providers=[], order_adapter=adapter)
with pytest.raises(OrderCreationUnavailableError):
asyncio.run(service.create_order(request))
assert adapter.calls == [request]
def test_create_order_without_configured_adapter_raises_unavailable_error() -> None:
service = AggregatorService(providers=[])
with pytest.raises(OrderCreationUnavailableError):
asyncio.run(service.create_order(_make_order_request()))
+453
View File
@@ -0,0 +1,453 @@
import asyncio
from dataclasses import dataclass, field
from typing import Any
import pytest
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderRegistrationResult,
)
from app.adapters.tbank.base import TBankPaymentNotificationTokenError
from app.schemas.payment import InitPaymentRequest, TBankPaymentNotification
from app.services.aggregator import (
AggregatorService,
InvalidTBankPaymentNotificationError,
TBankPaymentNotificationProcessingError,
)
from tests.payment_fixtures import make_init_payment_payload
def _default_payload() -> dict[str, Any]:
return make_init_payment_payload()
@dataclass
class StoredOrder:
order_uuid: str = "order-uuid-1"
payment_url: str = "https://pay.test/payment/1"
price: int = 125000
tariff_code: int = 535
account_email: str = "client@example.com"
payload: dict[str, Any] = field(default_factory=_default_payload)
payment_status: str | None = None
tbank_payment_id: int | None = None
cdek_order_uuid: str | None = None
cdek_waybill_uuid: str | None = None
cdek_waybill_url: str | None = None
payment_email_sent_at: object | None = None
class StubPaymentAdapter:
def __init__(self, *, verify_error: Exception | None = None) -> None:
self._verify_error = verify_error
self.notifications: list[TBankPaymentNotification] = []
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str:
raise NotImplementedError
def verify_payment_notification(
self,
notification: TBankPaymentNotification,
) -> None:
self.notifications.append(notification)
if self._verify_error is not None:
raise self._verify_error
class StubOrderSessionContext:
def __init__(self, session: object) -> None:
self._session = session
async def __aenter__(self) -> object:
return self._session
async def __aexit__(self, exc_type, exc, traceback) -> None:
return None
class StubOrderRepository:
def __init__(
self,
*,
orders: list[StoredOrder] | None = None,
mark_cdek_errors: list[Exception | None] | None = None,
) -> None:
self._orders = {order.order_uuid: order for order in orders or []}
self._mark_cdek_errors = mark_cdek_errors or []
self.session_value = object()
self.calls: list[tuple[str, tuple[object, ...]]] = []
def session(self) -> StubOrderSessionContext:
self.calls.append(("session", ()))
return StubOrderSessionContext(self.session_value)
async def create_order(self, session: object, order_data: object) -> object:
raise NotImplementedError
async def get_order_by_order_uuid(
self,
session: object,
order_uuid: str,
) -> StoredOrder | None:
self.calls.append(("get_order_by_order_uuid", (session, order_uuid)))
return self._orders.get(order_uuid)
async def mark_payment_status(
self,
session: object,
order_uuid: str,
status: str,
payment_id: int,
) -> StoredOrder | None:
self.calls.append(
("mark_payment_status", (session, order_uuid, status, payment_id))
)
order = self._orders.get(order_uuid)
if order is None:
return None
order.payment_status = status
order.tbank_payment_id = payment_id
return order
async def mark_cdek_order_registered(
self,
session: object,
order_uuid: str,
cdek_order_uuid: str,
) -> StoredOrder | None:
self.calls.append(
("mark_cdek_order_registered", (session, order_uuid, cdek_order_uuid))
)
if self._mark_cdek_errors:
error = self._mark_cdek_errors.pop(0)
if error is not None:
raise error
order = self._orders.get(order_uuid)
if order is None:
return None
order.cdek_order_uuid = cdek_order_uuid
return order
async def record_payment_email_sent(
self,
session: object,
*,
order_uuid: str,
sent_at: object,
) -> StoredOrder | None:
self.calls.append(
("record_payment_email_sent", (session, order_uuid, sent_at))
)
order = self._orders.get(order_uuid)
if order is not None and order.payment_email_sent_at is None:
order.payment_email_sent_at = sent_at
return order
class StubEmailSender:
def __init__(self, *, error: Exception | None = None) -> None:
self._error = error
self.calls: list[dict[str, str]] = []
async def send_email(
self, *, to: str, subject: str, body: str
) -> None:
self.calls.append({"to": to, "subject": subject, "body": body})
if self._error is not None:
raise self._error
class StubCDEKOrderAdapter:
def __init__(
self,
*,
responses: list[CDEKOrderRegistrationResult] | None = None,
error: Exception | None = None,
) -> None:
self._responses = responses or [
CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid-1",
waybill_uuid=None,
waybill_url=None,
)
]
self._error = error
self.calls: list[tuple[InitPaymentRequest, str]] = []
async def register_order(
self, request: InitPaymentRequest, order_uuid: str
) -> CDEKOrderRegistrationResult:
self.calls.append((request, order_uuid))
if self._error is not None:
raise self._error
return self._responses.pop(0)
def _make_notification(**overrides: object) -> TBankPaymentNotification:
payload: dict[str, object] = {
"TerminalKey": "TestTerminal",
"OrderId": "order-uuid-1",
"Success": True,
"Status": "CONFIRMED",
"PaymentId": 8347568144,
"ErrorCode": "0",
"Amount": 125000,
"Token": "signed-token",
}
payload.update(overrides)
return TBankPaymentNotification(**payload)
def test_confirmed_notification_registers_cdek_order_and_saves_uuid() -> None:
order = StoredOrder()
payment_adapter = StubPaymentAdapter()
order_repository = StubOrderRepository(orders=[order])
cdek_adapter = StubCDEKOrderAdapter(
responses=[
CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid-1",
waybill_uuid="waybill-uuid-1",
waybill_url="https://cdek.test/waybill/1.pdf",
)
]
)
service = AggregatorService(
providers=[],
payment_adapter=payment_adapter,
order_repository=order_repository,
order_registration_adapter=cdek_adapter,
)
notification = _make_notification()
result = asyncio.run(service.handle_tbank_payment_notification(notification))
assert result == "OK"
assert payment_adapter.notifications == [notification]
assert order.payment_status == "CONFIRMED"
assert order.tbank_payment_id == 8347568144
assert order.cdek_order_uuid == "cdek-order-uuid-1"
assert order.cdek_waybill_uuid is None
assert order.cdek_waybill_url is None
assert len(cdek_adapter.calls) == 1
assert cdek_adapter.calls[0][1] == "order-uuid-1"
def test_duplicate_confirmed_notification_does_not_call_cdek() -> None:
order = StoredOrder(cdek_order_uuid="existing-cdek-order-uuid")
cdek_adapter = StubCDEKOrderAdapter()
service = AggregatorService(
providers=[],
payment_adapter=StubPaymentAdapter(),
order_repository=StubOrderRepository(orders=[order]),
order_registration_adapter=cdek_adapter,
)
result = asyncio.run(
service.handle_tbank_payment_notification(_make_notification())
)
assert result == "OK"
assert cdek_adapter.calls == []
assert order.cdek_order_uuid == "existing-cdek-order-uuid"
def test_non_confirmed_notification_acknowledges_without_cdek() -> None:
order = StoredOrder()
cdek_adapter = StubCDEKOrderAdapter()
service = AggregatorService(
providers=[],
payment_adapter=StubPaymentAdapter(),
order_repository=StubOrderRepository(orders=[order]),
order_registration_adapter=cdek_adapter,
)
result = asyncio.run(
service.handle_tbank_payment_notification(
_make_notification(Status="AUTHORIZED")
)
)
assert result == "OK"
assert order.payment_status == "AUTHORIZED"
assert cdek_adapter.calls == []
def test_invalid_token_does_not_access_repository_or_cdek() -> None:
order_repository = StubOrderRepository(orders=[StoredOrder()])
cdek_adapter = StubCDEKOrderAdapter()
service = AggregatorService(
providers=[],
payment_adapter=StubPaymentAdapter(
verify_error=TBankPaymentNotificationTokenError("bad token")
),
order_repository=order_repository,
order_registration_adapter=cdek_adapter,
)
with pytest.raises(InvalidTBankPaymentNotificationError):
asyncio.run(service.handle_tbank_payment_notification(_make_notification()))
assert order_repository.calls == []
assert cdek_adapter.calls == []
def test_missing_order_returns_processing_error_without_cdek() -> None:
cdek_adapter = StubCDEKOrderAdapter()
service = AggregatorService(
providers=[],
payment_adapter=StubPaymentAdapter(),
order_repository=StubOrderRepository(),
order_registration_adapter=cdek_adapter,
)
with pytest.raises(TBankPaymentNotificationProcessingError):
asyncio.run(service.handle_tbank_payment_notification(_make_notification()))
assert cdek_adapter.calls == []
def test_cdek_registration_failure_returns_processing_error() -> None:
cdek_adapter = StubCDEKOrderAdapter(error=RuntimeError("cdek down"))
service = AggregatorService(
providers=[],
payment_adapter=StubPaymentAdapter(),
order_repository=StubOrderRepository(orders=[StoredOrder()]),
order_registration_adapter=cdek_adapter,
)
with pytest.raises(TBankPaymentNotificationProcessingError):
asyncio.run(service.handle_tbank_payment_notification(_make_notification()))
assert len(cdek_adapter.calls) == 1
def test_repeated_confirmed_after_cdek_uuid_save_failure_uses_same_external_id() -> None:
order = StoredOrder()
order_repository = StubOrderRepository(
orders=[order],
mark_cdek_errors=[RuntimeError("db down"), None],
)
cdek_adapter = StubCDEKOrderAdapter(
responses=[
CDEKOrderRegistrationResult(
order_uuid="same-cdek-order-uuid",
waybill_uuid=None,
waybill_url=None,
),
CDEKOrderRegistrationResult(
order_uuid="same-cdek-order-uuid",
waybill_uuid=None,
waybill_url=None,
),
]
)
service = AggregatorService(
providers=[],
payment_adapter=StubPaymentAdapter(),
order_repository=order_repository,
order_registration_adapter=cdek_adapter,
)
notification = _make_notification()
with pytest.raises(TBankPaymentNotificationProcessingError):
asyncio.run(service.handle_tbank_payment_notification(notification))
assert order.cdek_order_uuid is None
result = asyncio.run(service.handle_tbank_payment_notification(notification))
assert result == "OK"
assert order.cdek_order_uuid == "same-cdek-order-uuid"
assert [order_uuid for _, order_uuid in cdek_adapter.calls] == [
"order-uuid-1",
"order-uuid-1",
]
def test_confirmed_notification_sends_payment_confirmation_email() -> None:
order = StoredOrder()
email_sender = StubEmailSender()
service = AggregatorService(
providers=[],
payment_adapter=StubPaymentAdapter(),
order_repository=StubOrderRepository(orders=[order]),
order_registration_adapter=StubCDEKOrderAdapter(),
email_sender=email_sender,
)
result = asyncio.run(
service.handle_tbank_payment_notification(_make_notification())
)
assert result == "OK"
assert len(email_sender.calls) == 1
assert email_sender.calls[0]["to"] == "client@example.com"
assert "order-uuid-1" in email_sender.calls[0]["subject"]
assert order.payment_email_sent_at is not None
def test_non_confirmed_notification_does_not_send_email() -> None:
order = StoredOrder()
email_sender = StubEmailSender()
service = AggregatorService(
providers=[],
payment_adapter=StubPaymentAdapter(),
order_repository=StubOrderRepository(orders=[order]),
order_registration_adapter=StubCDEKOrderAdapter(),
email_sender=email_sender,
)
result = asyncio.run(
service.handle_tbank_payment_notification(
_make_notification(Status="AUTHORIZED")
)
)
assert result == "OK"
assert email_sender.calls == []
assert order.payment_email_sent_at is None
def test_duplicate_notification_does_not_resend_payment_email() -> None:
from datetime import datetime, timezone
order = StoredOrder(
cdek_order_uuid="existing-cdek-order-uuid",
payment_email_sent_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
email_sender = StubEmailSender()
service = AggregatorService(
providers=[],
payment_adapter=StubPaymentAdapter(),
order_repository=StubOrderRepository(orders=[order]),
order_registration_adapter=StubCDEKOrderAdapter(),
email_sender=email_sender,
)
result = asyncio.run(
service.handle_tbank_payment_notification(_make_notification())
)
assert result == "OK"
assert email_sender.calls == []
def test_email_failure_does_not_break_notification_handling() -> None:
order = StoredOrder()
email_sender = StubEmailSender(error=RuntimeError("smtp down"))
service = AggregatorService(
providers=[],
payment_adapter=StubPaymentAdapter(),
order_repository=StubOrderRepository(orders=[order]),
order_registration_adapter=StubCDEKOrderAdapter(),
email_sender=email_sender,
)
result = asyncio.run(
service.handle_tbank_payment_notification(_make_notification())
)
assert result == "OK"
assert len(email_sender.calls) == 1
assert order.cdek_order_uuid == "cdek-order-uuid-1"
+240
View File
@@ -0,0 +1,240 @@
import asyncio
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from app.services.waybill_email_sender import WaybillEmailSenderService
@dataclass
class StoredOrder:
order_uuid: str
account_email: str
cdek_waybill_url: str | None = None
waybill_email_sent_at: datetime | None = None
class StubSessionContext:
def __init__(self, session: object) -> None:
self._session = session
async def __aenter__(self) -> object:
return self._session
async def __aexit__(self, exc_type, exc, traceback) -> None:
return None
class StubRepository:
def __init__(self, orders: list[StoredOrder]) -> None:
self._orders = {order.order_uuid: order for order in orders}
self.session_value = object()
self.calls: list[tuple[str, dict[str, Any]]] = []
def session(self) -> StubSessionContext:
return StubSessionContext(self.session_value)
async def list_orders_pending_waybill_email(
self, session: object, *, limit: int
) -> list[StoredOrder]:
self.calls.append(("list", {"limit": limit}))
return [
order
for order in self._orders.values()
if order.cdek_waybill_url is not None
and order.waybill_email_sent_at is None
]
async def record_waybill_email_sent(
self,
session: object,
*,
order_uuid: str,
sent_at: datetime,
) -> StoredOrder | None:
self.calls.append(
("record", {"order_uuid": order_uuid, "sent_at": sent_at})
)
order = self._orders.get(order_uuid)
if order is None:
return None
if order.waybill_email_sent_at is None:
order.waybill_email_sent_at = sent_at
return order
class StubDownloader:
def __init__(self, results: dict[str, bytes | Exception]) -> None:
self._results = results
self.calls: list[str] = []
async def download_waybill_pdf(self, url: str) -> bytes:
self.calls.append(url)
result = self._results[url]
if isinstance(result, Exception):
raise result
return result
class StubEmailSender:
def __init__(self, raise_for: set[str] | None = None) -> None:
self.raise_for = raise_for or set()
self.calls: list[dict[str, Any]] = []
async def send_email(
self,
*,
to: str,
subject: str,
body: str,
attachment_bytes: bytes,
attachment_filename: str,
) -> None:
self.calls.append(
{
"to": to,
"subject": subject,
"body": body,
"attachment_bytes": attachment_bytes,
"attachment_filename": attachment_filename,
}
)
if to in self.raise_for:
raise RuntimeError(f"smtp blew up for {to}")
_SENT_AT = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc)
def _make_service(
*,
repository: StubRepository,
downloader: StubDownloader | None = None,
email_sender: StubEmailSender | None = None,
) -> WaybillEmailSenderService:
return WaybillEmailSenderService(
order_repository=repository,
waybill_downloader=downloader or StubDownloader({}),
email_sender=email_sender or StubEmailSender(),
batch_size=10,
datetime_now=lambda: _SENT_AT,
)
def test_poll_once_downloads_pdf_sends_email_and_marks_sent() -> None:
order = StoredOrder(
order_uuid="o-1",
account_email="client@example.com",
cdek_waybill_url="https://cdek.test/1.pdf",
)
repo = StubRepository([order])
downloader = StubDownloader({"https://cdek.test/1.pdf": b"%PDF"})
email_sender = StubEmailSender()
service = _make_service(
repository=repo, downloader=downloader, email_sender=email_sender
)
summary = asyncio.run(service.poll_once())
assert summary.processed == 1
assert summary.succeeded == 1
assert summary.failed == 0
assert downloader.calls == ["https://cdek.test/1.pdf"]
assert len(email_sender.calls) == 1
call = email_sender.calls[0]
assert call["to"] == "client@example.com"
assert call["subject"] == "Накладная по заказу o-1"
assert "o-1" in call["body"]
assert "https://cdek.test/1.pdf" in call["body"]
assert call["attachment_bytes"] == b"%PDF"
assert call["attachment_filename"] == "waybill_o-1.pdf"
assert order.waybill_email_sent_at == _SENT_AT
def test_poll_once_download_error_keeps_order_pending_and_skips_send() -> None:
order = StoredOrder(
order_uuid="o-1",
account_email="client@example.com",
cdek_waybill_url="https://cdek.test/1.pdf",
)
repo = StubRepository([order])
downloader = StubDownloader({"https://cdek.test/1.pdf": RuntimeError("cdek 500")})
email_sender = StubEmailSender()
service = _make_service(
repository=repo, downloader=downloader, email_sender=email_sender
)
summary = asyncio.run(service.poll_once())
assert summary.processed == 1
assert summary.succeeded == 0
assert summary.failed == 1
assert email_sender.calls == []
assert order.waybill_email_sent_at is None
def test_poll_once_smtp_error_keeps_order_pending() -> None:
order = StoredOrder(
order_uuid="o-1",
account_email="bad@example.com",
cdek_waybill_url="https://cdek.test/1.pdf",
)
repo = StubRepository([order])
downloader = StubDownloader({"https://cdek.test/1.pdf": b"%PDF"})
email_sender = StubEmailSender(raise_for={"bad@example.com"})
service = _make_service(
repository=repo, downloader=downloader, email_sender=email_sender
)
summary = asyncio.run(service.poll_once())
assert summary.failed == 1
assert order.waybill_email_sent_at is None
assert len(email_sender.calls) == 1
def test_poll_once_failure_on_one_order_does_not_break_batch() -> None:
bad = StoredOrder(
order_uuid="bad",
account_email="bad@example.com",
cdek_waybill_url="https://cdek.test/bad.pdf",
)
good = StoredOrder(
order_uuid="good",
account_email="good@example.com",
cdek_waybill_url="https://cdek.test/good.pdf",
)
repo = StubRepository([bad, good])
downloader = StubDownloader(
{
"https://cdek.test/bad.pdf": RuntimeError("cdek 500"),
"https://cdek.test/good.pdf": b"%PDF",
}
)
email_sender = StubEmailSender()
service = _make_service(
repository=repo, downloader=downloader, email_sender=email_sender
)
summary = asyncio.run(service.poll_once())
assert summary.processed == 2
assert summary.succeeded == 1
assert summary.failed == 1
assert good.waybill_email_sent_at == _SENT_AT
assert bad.waybill_email_sent_at is None
def test_run_forever_exits_when_stop_event_is_set() -> None:
repo = StubRepository([])
service = _make_service(repository=repo)
async def run() -> None:
stop_event = asyncio.Event()
stop_event.set()
await asyncio.wait_for(
service.run_forever(interval_seconds=0.01, stop_event=stop_event),
timeout=1.0,
)
asyncio.run(run())
+259
View File
@@ -0,0 +1,259 @@
import asyncio
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderInfo,
CDEKWaybillInfo,
)
from app.services.waybill_poller import WaybillPollerService
@dataclass
class StoredOrder:
order_uuid: str
cdek_order_uuid: str | None = None
cdek_order_status: str | None = None
cdek_waybill_uuid: str | None = None
cdek_waybill_url: str | None = None
cdek_polled_at: datetime | None = None
class StubSessionContext:
def __init__(self, session: object) -> None:
self._session = session
async def __aenter__(self) -> object:
return self._session
async def __aexit__(self, exc_type, exc, traceback) -> None:
return None
class StubRepository:
def __init__(self, orders: list[StoredOrder]) -> None:
self._orders = {order.order_uuid: order for order in orders}
self.session_value = object()
self.calls: list[tuple[str, dict[str, Any]]] = []
def session(self) -> StubSessionContext:
return StubSessionContext(self.session_value)
async def list_orders_pending_waybill(
self, session: object, *, limit: int
) -> list[StoredOrder]:
self.calls.append(("list", {"session": session, "limit": limit}))
return list(self._orders.values())
async def record_order_poll(
self,
session: object,
*,
order_uuid: str,
order_status: str | None,
waybill_uuid: str | None,
polled_at: datetime,
) -> StoredOrder | None:
self.calls.append(
(
"record_order_poll",
{
"order_uuid": order_uuid,
"order_status": order_status,
"waybill_uuid": waybill_uuid,
"polled_at": polled_at,
},
)
)
order = self._orders.get(order_uuid)
if order is None:
return None
order.cdek_order_status = order_status
if waybill_uuid is not None and order.cdek_waybill_uuid is None:
order.cdek_waybill_uuid = waybill_uuid
order.cdek_polled_at = polled_at
return order
async def record_waybill_poll(
self,
session: object,
*,
order_uuid: str,
waybill_url: str | None,
polled_at: datetime,
) -> StoredOrder | None:
self.calls.append(
(
"record_waybill_poll",
{
"order_uuid": order_uuid,
"waybill_url": waybill_url,
"polled_at": polled_at,
},
)
)
order = self._orders.get(order_uuid)
if order is None:
return None
if waybill_url is not None and order.cdek_waybill_url is None:
order.cdek_waybill_url = waybill_url
order.cdek_polled_at = polled_at
return order
class StubOrderInfoAdapter:
def __init__(self, results: dict[str, CDEKOrderInfo | Exception]) -> None:
self._results = results
self.calls: list[str] = []
async def get_order(self, cdek_order_uuid: str) -> CDEKOrderInfo:
self.calls.append(cdek_order_uuid)
result = self._results[cdek_order_uuid]
if isinstance(result, Exception):
raise result
return result
class StubWaybillInfoAdapter:
def __init__(self, results: dict[str, CDEKWaybillInfo | Exception]) -> None:
self._results = results
self.calls: list[str] = []
async def get_waybill(self, cdek_waybill_uuid: str) -> CDEKWaybillInfo:
self.calls.append(cdek_waybill_uuid)
result = self._results[cdek_waybill_uuid]
if isinstance(result, Exception):
raise result
return result
_POLLED_AT = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc)
def _make_service(
*,
repository: StubRepository,
order_info: StubOrderInfoAdapter | None = None,
waybill_info: StubWaybillInfoAdapter | None = None,
) -> WaybillPollerService:
return WaybillPollerService(
order_repository=repository,
order_info_adapter=order_info or StubOrderInfoAdapter({}),
waybill_info_adapter=waybill_info or StubWaybillInfoAdapter({}),
batch_size=10,
datetime_now=lambda: _POLLED_AT,
)
def test_poll_once_fetches_order_info_when_waybill_uuid_is_missing() -> None:
order = StoredOrder(order_uuid="o", cdek_order_uuid="cdek-o")
repo = StubRepository([order])
order_info = StubOrderInfoAdapter(
{
"cdek-o": CDEKOrderInfo(
order_uuid="cdek-o",
status_code="ACCEPTED",
waybill_uuid="waybill-1",
)
}
)
service = _make_service(repository=repo, order_info=order_info)
summary = asyncio.run(service.poll_once())
assert summary.processed == 1 and summary.succeeded == 1 and summary.failed == 0
assert order_info.calls == ["cdek-o"]
assert order.cdek_order_status == "ACCEPTED"
assert order.cdek_waybill_uuid == "waybill-1"
assert order.cdek_polled_at == _POLLED_AT
def test_poll_once_fetches_waybill_info_when_waybill_uuid_is_present() -> None:
order = StoredOrder(
order_uuid="o",
cdek_order_uuid="cdek-o",
cdek_waybill_uuid="waybill-1",
)
repo = StubRepository([order])
waybill_info = StubWaybillInfoAdapter(
{
"waybill-1": CDEKWaybillInfo(
waybill_uuid="waybill-1",
url="https://cdek.test/1.pdf",
)
}
)
service = _make_service(repository=repo, waybill_info=waybill_info)
summary = asyncio.run(service.poll_once())
assert summary.processed == 1 and summary.succeeded == 1 and summary.failed == 0
assert waybill_info.calls == ["waybill-1"]
assert order.cdek_waybill_url == "https://cdek.test/1.pdf"
assert order.cdek_polled_at == _POLLED_AT
def test_poll_once_records_terminal_status_without_waybill() -> None:
order = StoredOrder(order_uuid="o", cdek_order_uuid="cdek-o")
repo = StubRepository([order])
order_info = StubOrderInfoAdapter(
{
"cdek-o": CDEKOrderInfo(
order_uuid="cdek-o",
status_code="INVALID",
waybill_uuid=None,
)
}
)
service = _make_service(repository=repo, order_info=order_info)
summary = asyncio.run(service.poll_once())
assert summary.succeeded == 1
assert order.cdek_order_status == "INVALID"
assert order.cdek_waybill_uuid is None
def test_poll_once_failure_on_one_order_does_not_break_batch() -> None:
failing = StoredOrder(order_uuid="bad", cdek_order_uuid="cdek-bad")
good = StoredOrder(
order_uuid="good",
cdek_order_uuid="cdek-good",
cdek_waybill_uuid="waybill-good",
)
repo = StubRepository([failing, good])
order_info = StubOrderInfoAdapter({"cdek-bad": RuntimeError("cdek down")})
waybill_info = StubWaybillInfoAdapter(
{
"waybill-good": CDEKWaybillInfo(
waybill_uuid="waybill-good",
url="https://cdek.test/good.pdf",
)
}
)
service = _make_service(
repository=repo, order_info=order_info, waybill_info=waybill_info
)
summary = asyncio.run(service.poll_once())
assert summary.processed == 2
assert summary.succeeded == 1
assert summary.failed == 1
assert good.cdek_waybill_url == "https://cdek.test/good.pdf"
def test_run_forever_exits_when_stop_event_is_set() -> None:
repo = StubRepository([])
service = _make_service(repository=repo)
async def run() -> None:
stop_event = asyncio.Event()
stop_event.set()
await asyncio.wait_for(
service.run_forever(interval_seconds=0.01, stop_event=stop_event),
timeout=1.0,
)
asyncio.run(run())
+3
View File
@@ -35,6 +35,9 @@ def test_app_import_smoke(monkeypatch) -> None:
assert isinstance(main_module.app, FastAPI) assert isinstance(main_module.app, FastAPI)
assert isinstance(main_module.app.state.settings, Settings) assert isinstance(main_module.app.state.settings, Settings)
assert "/api/v1/delivery/tbank/notifications" in {
route.path for route in main_module.app.routes
}
assert logging_bootstrap_calls == [None] assert logging_bootstrap_calls == [None]
assert tracing_bootstrap_calls == [ assert tracing_bootstrap_calls == [
(main_module.app, main_module.app.state.settings.observability) (main_module.app, main_module.app.state.settings.observability)
+2 -1
View File
@@ -18,7 +18,8 @@ def test_compose_defines_required_services() -> None:
compose = _load_compose() compose = _load_compose()
services = compose.get("services") services = compose.get("services")
assert isinstance(services, dict) assert isinstance(services, dict)
assert {"app", "redis"}.issubset(services.keys()) assert {"app", "redis", "postgres"}.issubset(services.keys())
assert "postgres" in services["app"]["depends_on"]
def test_smoke_command_sequence_is_documented() -> None: def test_smoke_command_sequence_is_documented() -> None:

Some files were not shown because too many files have changed in this diff Show More