Добавлена отправка накладных на почту

This commit is contained in:
Раис Юсупалиев
2026-05-24 00:55:09 +03:00
parent 5526f90cb3
commit 50124fb2c9
36 changed files with 1615 additions and 22 deletions
@@ -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")
@@ -253,6 +253,55 @@ class CDEKClient:
"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:
+5
View File
@@ -0,0 +1,5 @@
"""E-mail adapters."""
from app.adapters.email.smtp_client import SMTPEmailSender, SMTPEmailSenderError
__all__ = ["SMTPEmailSender", "SMTPEmailSenderError"]
+135
View File
@@ -0,0 +1,135 @@
"""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,
attachment_filename: str,
) -> 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,
attachment_filename: str,
) -> EmailMessage:
message = EmailMessage()
message["From"] = self._from_address
message["To"] = to
message["Subject"] = subject
message.set_content(body)
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,
)
+20
View File
@@ -121,6 +121,21 @@ class WaybillPollerConfig(BaseModel):
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):
model_config = SettingsConfigDict(
extra="ignore",
@@ -138,6 +153,10 @@ class Settings(BaseSettings):
)
observability: ObservabilityConfig
waybill_poller: WaybillPollerConfig = Field(default_factory=WaybillPollerConfig)
email: EmailAdapterConfig
waybill_email_sender: WaybillEmailSenderConfig = Field(
default_factory=WaybillEmailSenderConfig
)
@classmethod
def settings_customise_sources(
@@ -169,6 +188,7 @@ class _RequiredYamlSections(BaseModel):
postgres: dict[str, Any]
address_suggestions: dict[str, Any]
observability: dict[str, Any]
email: dict[str, Any]
def _resolve_runtime_config_file() -> str:
+4
View File
@@ -48,6 +48,10 @@ class Order(Base):
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(),
+35
View File
@@ -144,3 +144,38 @@ class OrderRepository:
order.cdek_polled_at = polled_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
+9 -2
View File
@@ -4,7 +4,14 @@ from datetime import datetime
from decimal import Decimal, InvalidOperation
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic import (
BaseModel,
ConfigDict,
EmailStr,
Field,
field_validator,
model_validator,
)
from pydantic.alias_generators import to_camel
@@ -115,7 +122,7 @@ class InitPaymentRequest(_CamelModel):
content: Content
pickup_date: datetime
delivery_date: datetime | None = None
account_email: str = Field(min_length=1)
account_email: EmailStr
system_data: SystemData
+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,
)
+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())
+13
View File
@@ -67,3 +67,16 @@ observability:
waybill_poller:
interval_seconds: 30
batch_size: 50
email:
smtp_host: "smtp.example.com"
smtp_port: 587
username: ""
password: ""
from_address: "no-reply@example.com"
use_tls: true
timeout_seconds: 10.0
waybill_email_sender:
interval_seconds: 30
batch_size: 50
+5
View File
@@ -97,3 +97,8 @@ observability:
service_name: "g2s-aggregator-test"
otlp_endpoint: "http://localhost:4317"
otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
+16 -3
View File
@@ -1,6 +1,6 @@
services:
app:
image: yusupal1ev/g2s-aggregator:0.0.2
image: yusupal1ev/g2s-aggregator:0.0.7
container_name: g2s-aggregator
ports:
- "8000:8000"
@@ -36,7 +36,7 @@ services:
timeout: 3s
retries: 10
migrations:
image: yusupal1ev/g2s-aggregator:0.0.2
image: yusupal1ev/g2s-aggregator:0.0.7
container_name: g2s-aggregator-migrations
depends_on:
postgres:
@@ -46,7 +46,7 @@ services:
command: ["poetry", "run", "alembic", "upgrade", "head"]
restart: "no"
waybill-poller:
image: yusupal1ev/g2s-aggregator:0.0.2
image: yusupal1ev/g2s-aggregator:0.0.7
container_name: g2s-aggregator-waybill-poller
depends_on:
postgres:
@@ -57,5 +57,18 @@ services:
- ./config.yaml:/config.yaml
command: ["poetry", "run", "python", "-m", "app.workers.waybill_poller"]
restart: unless-stopped
waybill-email-sender:
image: yusupal1ev/g2s-aggregator:0.0.7
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
volumes:
postgres:
Generated
+135 -15
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]]
name = "aioredis"
@@ -6,6 +6,7 @@ version = "2.0.1"
description = "asyncio (PEP 3156) Redis support"
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "aioredis-2.0.1-py3-none-any.whl", hash = "sha256:9ac0d0b3b485d293b8ca1987e6de8658d7dafcca1cddfcd1d506cae8cdebfdd6"},
{file = "aioredis-2.0.1.tar.gz", hash = "sha256:eaa51aaf993f2d71f54b70527c440437ba65340588afeb786cd87c55c89cd98e"},
@@ -16,7 +17,23 @@ async-timeout = "*"
typing-extensions = "*"
[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"
@@ -24,6 +41,7 @@ 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"},
@@ -39,6 +57,7 @@ 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"},
@@ -58,6 +77,7 @@ version = "0.0.4"
description = "Document parameters, class attributes, return types, and variables inline, with Annotated."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"},
{file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"},
@@ -69,6 +89,7 @@ version = "0.7.0"
description = "Reusable constraint types to use with typing.Annotated"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
@@ -80,6 +101,7 @@ version = "4.12.1"
description = "High-level concurrency and networking framework on top of asyncio or Trio"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"},
{file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"},
@@ -89,7 +111,7 @@ files = [
idna = ">=2.8"
[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]]
name = "asgiref"
@@ -97,6 +119,7 @@ version = "3.11.1"
description = "ASGI specs, helper code, and adapters"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133"},
{file = "asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce"},
@@ -111,6 +134,7 @@ version = "5.0.1"
description = "Timeout context manager for asyncio programs"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"},
{file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"},
@@ -122,6 +146,7 @@ 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"},
@@ -183,7 +208,7 @@ files = [
]
[package.extras]
gssauth = ["gssapi", "sspilib"]
gssauth = ["gssapi ; platform_system != \"Windows\"", "sspilib ; platform_system == \"Windows\""]
[[package]]
name = "certifi"
@@ -191,6 +216,7 @@ version = "2026.2.25"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"},
{file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"},
@@ -202,6 +228,7 @@ version = "3.4.5"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7"
groups = ["main"]
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-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990"},
@@ -324,6 +351,7 @@ version = "8.3.1"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"},
{file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"},
@@ -338,17 +366,57 @@ version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
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 = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{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]]
name = "fastapi"
version = "0.135.1"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e"},
{file = "fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd"},
@@ -372,6 +440,7 @@ version = "1.73.0"
description = "Common protobufs used in Google APIs"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{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"},
@@ -389,6 +458,8 @@ 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"},
@@ -461,6 +532,7 @@ version = "1.78.0"
description = "HTTP/2-based RPC framework"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{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"},
@@ -537,6 +609,7 @@ version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
@@ -548,6 +621,7 @@ version = "1.0.9"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
@@ -569,6 +643,7 @@ version = "0.7.1"
description = "A collection of framework independent HTTP protocol utils."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{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"},
@@ -621,6 +696,7 @@ version = "0.28.1"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
@@ -633,7 +709,7 @@ httpcore = "==1.*"
idna = "*"
[package.extras]
brotli = ["brotli", "brotlicffi"]
brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
@@ -645,6 +721,7 @@ version = "3.11"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"},
{file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"},
@@ -659,6 +736,7 @@ version = "8.7.1"
description = "Read metadata from Python packages"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"},
{file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"},
@@ -668,13 +746,13 @@ files = [
zipp = ">=3.20"
[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"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
enabler = ["pytest-enabler (>=3.4)"]
perf = ["ipython"]
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]]
name = "iniconfig"
@@ -682,6 +760,7 @@ version = "2.3.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
@@ -693,6 +772,7 @@ 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"},
@@ -712,6 +792,7 @@ 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"},
@@ -810,6 +891,7 @@ version = "1.40.0"
description = "OpenTelemetry Python API"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9"},
{file = "opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f"},
@@ -825,6 +907,7 @@ version = "1.40.0"
description = "OpenTelemetry Collector Exporters"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{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"},
@@ -840,6 +923,7 @@ version = "1.40.0"
description = "OpenTelemetry Protobuf encoding"
optional = false
python-versions = ">=3.9"
groups = ["main"]
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.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa"},
@@ -854,6 +938,7 @@ version = "1.40.0"
description = "OpenTelemetry Collector Protobuf over gRPC Exporter"
optional = false
python-versions = ">=3.9"
groups = ["main"]
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.tar.gz", hash = "sha256:bd4015183e40b635b3dab8da528b27161ba83bf4ef545776b196f0fb4ec47740"},
@@ -877,6 +962,7 @@ version = "1.40.0"
description = "OpenTelemetry Collector Protobuf over HTTP Exporter"
optional = false
python-versions = ">=3.9"
groups = ["main"]
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.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c"},
@@ -900,6 +986,7 @@ version = "0.61b0"
description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_instrumentation-0.61b0-py3-none-any.whl", hash = "sha256:92a93a280e69788e8f88391247cc530fd81f16f2b011979d4d6398f805cfbc63"},
{file = "opentelemetry_instrumentation-0.61b0.tar.gz", hash = "sha256:cb21b48db738c9de196eba6b805b4ff9de3b7f187e4bbf9a466fa170514f1fc7"},
@@ -917,6 +1004,7 @@ version = "0.61b0"
description = "ASGI instrumentation for OpenTelemetry"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_instrumentation_asgi-0.61b0-py3-none-any.whl", hash = "sha256:e4b3ce6b66074e525e717efff20745434e5efd5d9df6557710856fba356da7a4"},
{file = "opentelemetry_instrumentation_asgi-0.61b0.tar.gz", hash = "sha256:9d08e127244361dc33976d39dd4ca8f128b5aa5a7ae425208400a80a095019b5"},
@@ -938,6 +1026,7 @@ version = "0.61b0"
description = "OpenTelemetry FastAPI Instrumentation"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_instrumentation_fastapi-0.61b0-py3-none-any.whl", hash = "sha256:a1a844d846540d687d377516b2ff698b51d87c781b59f47c214359c4a241047c"},
{file = "opentelemetry_instrumentation_fastapi-0.61b0.tar.gz", hash = "sha256:3a24f35b07c557ae1bbc483bf8412221f25d79a405f8b047de8b670722e2fa9f"},
@@ -959,6 +1048,7 @@ version = "0.61b0"
description = "OpenTelemetry HTTPX Instrumentation"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_instrumentation_httpx-0.61b0-py3-none-any.whl", hash = "sha256:dee05c93a6593a5dc3ae5d9d5c01df8b4e2c5d02e49275e5558534ee46343d5e"},
{file = "opentelemetry_instrumentation_httpx-0.61b0.tar.gz", hash = "sha256:6569ec097946c5551c2a4252f74c98666addd1bf047c1dde6b4ef426719ff8dd"},
@@ -980,6 +1070,7 @@ version = "0.61b0"
description = "OpenTelemetry Redis instrumentation"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_instrumentation_redis-0.61b0-py3-none-any.whl", hash = "sha256:8d4e850bbb5f8eeafa44c0eac3a007990c7125de187bc9c3659e29ff7e091172"},
{file = "opentelemetry_instrumentation_redis-0.61b0.tar.gz", hash = "sha256:ae0fbb56be9a641e621d55b02a7d62977a2c77c5ee760addd79b9b266e46e523"},
@@ -1000,6 +1091,7 @@ version = "1.40.0"
description = "OpenTelemetry Python Proto"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f"},
{file = "opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd"},
@@ -1014,6 +1106,7 @@ version = "1.40.0"
description = "OpenTelemetry Python SDK"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1"},
{file = "opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2"},
@@ -1030,6 +1123,7 @@ version = "0.61b0"
description = "OpenTelemetry Semantic Conventions"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2"},
{file = "opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a"},
@@ -1045,6 +1139,7 @@ version = "0.61b0"
description = "Web util for OpenTelemetry"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_util_http-0.61b0-py3-none-any.whl", hash = "sha256:8e715e848233e9527ea47e275659ea60a57a75edf5206a3b937e236a6da5fc33"},
{file = "opentelemetry_util_http-0.61b0.tar.gz", hash = "sha256:1039cb891334ad2731affdf034d8fb8b48c239af9b6dd295e5fabd07f1c95572"},
@@ -1056,6 +1151,7 @@ version = "26.0"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"},
{file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"},
@@ -1067,6 +1163,7 @@ version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
@@ -1082,6 +1179,7 @@ version = "6.33.5"
description = ""
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{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"},
@@ -1101,6 +1199,7 @@ version = "2.12.5"
description = "Data validation using Python type hints"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"},
{file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"},
@@ -1114,7 +1213,7 @@ typing-inspection = ">=0.4.2"
[package.extras]
email = ["email-validator (>=2.0.0)"]
timezone = ["tzdata"]
timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""]
[[package]]
name = "pydantic-core"
@@ -1122,6 +1221,7 @@ version = "2.41.5"
description = "Core functionality for Pydantic validation and serialization"
optional = false
python-versions = ">=3.9"
groups = ["main"]
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_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"},
@@ -1255,6 +1355,7 @@ version = "2.13.1"
description = "Settings management using Pydantic"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237"},
{file = "pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025"},
@@ -1278,6 +1379,7 @@ version = "2.19.2"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
@@ -1292,6 +1394,7 @@ version = "9.0.2"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"},
{file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"},
@@ -1313,6 +1416,7 @@ version = "1.2.2"
description = "Read key-value pairs from a .env file and set them as environment variables"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"},
{file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"},
@@ -1327,6 +1431,7 @@ version = "6.0.3"
description = "YAML parser and emitter for Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{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"},
@@ -1409,6 +1514,7 @@ version = "7.3.0"
description = "Python client for Redis database and key-value store"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "redis-7.3.0-py3-none-any.whl", hash = "sha256:9d4fcb002a12a5e3c3fbe005d59c48a2cc231f87fbb2f6b70c2d89bb64fec364"},
{file = "redis-7.3.0.tar.gz", hash = "sha256:4d1b768aafcf41b01022410b3cc4f15a07d9b3d6fe0c66fc967da2c88e551034"},
@@ -1428,6 +1534,7 @@ version = "2.32.5"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
{file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
@@ -1449,6 +1556,7 @@ 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"},
@@ -1550,6 +1658,7 @@ version = "0.52.1"
description = "The little ASGI library that shines."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74"},
{file = "starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933"},
@@ -1567,6 +1676,7 @@ version = "25.5.0"
description = "Structured Logging for Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f"},
{file = "structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98"},
@@ -1578,6 +1688,7 @@ version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
@@ -1589,6 +1700,7 @@ version = "0.4.2"
description = "Runtime typing introspection tools"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"},
{file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"},
@@ -1603,16 +1715,17 @@ version = "2.6.3"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
{file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
]
[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)"]
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]]
name = "uvicorn"
@@ -1620,6 +1733,7 @@ version = "0.41.0"
description = "The lightning-fast ASGI server."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187"},
{file = "uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a"},
@@ -1632,12 +1746,12 @@ h11 = ">=0.8"
httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standard\""}
python-dotenv = {version = ">=0.13", 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\""}
websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""}
[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]]
name = "uvloop"
@@ -1645,6 +1759,8 @@ version = "0.22.1"
description = "Fast implementation of asyncio event loop on top of libuv"
optional = false
python-versions = ">=3.8.1"
groups = ["main"]
markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\""
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_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792"},
@@ -1708,6 +1824,7 @@ version = "1.1.1"
description = "Simple, modern and high performance file watching and code reload in python."
optional = false
python-versions = ">=3.9"
groups = ["main"]
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_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43"},
@@ -1829,6 +1946,7 @@ version = "16.0"
description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{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"},
@@ -1899,6 +2017,7 @@ version = "1.17.3"
description = "Module for decorators, wrappers and monkey patching."
optional = false
python-versions = ">=3.8"
groups = ["main"]
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_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"},
@@ -1989,13 +2108,14 @@ version = "3.23.0"
description = "Backport of pathlib-compatible object wrapper for zip files"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"},
{file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"},
]
[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"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
enabler = ["pytest-enabler (>=2.2)"]
@@ -2003,6 +2123,6 @@ test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_it
type = ["pytest-mypy"]
[metadata]
lock-version = "2.0"
lock-version = "2.1"
python-versions = "^3.14"
content-hash = "d3da307072ea8d39a768a338440f91ceaf72b09eab699bd06875714de74c8c90"
content-hash = "37dfb9818087d72976a203956d4f1ef6df05377e66dc521b7884b718df44138e"
+2
View File
@@ -27,6 +27,8 @@ 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]
pythonpath = ["."]
+3 -2
View File
@@ -40,9 +40,10 @@
| 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
- Total: **34**
- Total: **35**
- TODO: **2**
- DONE: **32**
- DONE: **33**
@@ -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`
@@ -312,6 +312,11 @@ observability:
service_name: "cdek-adapter-test-service"
otlp_endpoint: "http://collector:4317"
otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
""".strip(),
encoding="utf-8",
)
@@ -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
+166
View File
@@ -0,0 +1,166 @@
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
@@ -48,3 +48,8 @@ observability:
service_name: "default-config-service"
otlp_endpoint: "http://default-collector:4317"
otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
@@ -46,3 +46,8 @@ observability:
service_name: "invalid-price-multiplier-service"
otlp_endpoint: "http://collector:4317"
otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
@@ -35,3 +35,8 @@ observability:
service_name: "missing-adapter-service"
otlp_endpoint: "http://collector:4317"
otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
@@ -39,3 +39,8 @@ observability:
service_name: "missing-address-suggestions-service"
otlp_endpoint: "http://collector:4317"
otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
@@ -40,3 +40,8 @@ address_suggestions:
dadata:
url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
api_key: "missing-observability-dadata-key"
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
@@ -45,3 +45,8 @@ observability:
enabled: true
service_name: "g2s-aggregator"
otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
@@ -45,3 +45,8 @@ observability:
service_name: "missing-price-multiplier-service"
otlp_endpoint: "http://collector:4317"
otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
@@ -52,3 +52,8 @@ observability:
service_name: "override-config-service"
otlp_endpoint: "http://override-collector:4317"
otlp_insecure: false
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
+5
View File
@@ -148,6 +148,11 @@ 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",
)
+13
View File
@@ -84,6 +84,19 @@ def test_post_init_payment_rejects_snake_case_top_level_field() -> None:
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()
+5
View File
@@ -195,6 +195,11 @@ observability:
service_name: "repository-test-service"
otlp_endpoint: "http://collector:4317"
otlp_insecure: true
email:
smtp_host: "smtp.test"
smtp_port: 587
from_address: "no-reply@test"
""".strip(),
encoding="utf-8",
)
+127
View File
@@ -442,6 +442,133 @@ def test_record_waybill_poll_sets_url_only_when_previously_null() -> None:
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)
+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())
@@ -0,0 +1,90 @@
import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from app.config import (
AdapterConfig,
EmailAdapterConfig,
ObservabilityConfig,
PostgresConfig,
Settings,
TBankPaymentAuthConfig,
TBankPaymentConfig,
WaybillEmailSenderConfig,
)
from app.workers.waybill_email_sender import _run
def _make_settings() -> Settings:
return Settings(
adapter=AdapterConfig(
cdek_base_url="https://api.cdek.test/v2",
cdek_client_id="id",
cdek_client_secret="secret",
),
tbank_payment=TBankPaymentConfig(
init_url="https://pay.test/init",
notification_url="https://pay.test/notify",
success_url="https://pay.test/success",
auth=TBankPaymentAuthConfig(terminal_key="t", password="p"),
),
postgres=PostgresConfig(dsn="postgresql+asyncpg://u:p@h/db"),
observability=ObservabilityConfig(
service_name="svc", otlp_endpoint="http://otlp"
),
email=EmailAdapterConfig(
smtp_host="smtp.test",
smtp_port=587,
from_address="no-reply@test",
),
waybill_email_sender=WaybillEmailSenderConfig(
interval_seconds=0.01, batch_size=5
),
business_logic={"provider_price_multiplier": "1.0"},
)
def test_run_exits_when_stop_event_is_set() -> None:
settings = _make_settings()
http_client_instance = MagicMock()
http_client_instance.aclose = AsyncMock()
engine_instance = MagicMock()
engine_instance.dispose = AsyncMock()
run_forever_mock = AsyncMock()
with (
patch(
"app.workers.waybill_email_sender.httpx.AsyncClient",
return_value=http_client_instance,
),
patch("app.workers.waybill_email_sender.CDEKAuthClient"),
patch("app.workers.waybill_email_sender.CDEKClient"),
patch("app.workers.waybill_email_sender.SMTPEmailSender"),
patch(
"app.workers.waybill_email_sender.create_postgres_engine",
return_value=engine_instance,
),
patch("app.workers.waybill_email_sender.create_postgres_session_factory"),
patch("app.workers.waybill_email_sender.OrderRepository"),
patch(
"app.workers.waybill_email_sender.WaybillEmailSenderService"
) as service_cls_mock,
):
service_instance = MagicMock()
service_instance.run_forever = run_forever_mock
service_cls_mock.return_value = service_instance
async def runner() -> None:
stop_event = asyncio.Event()
stop_event.set()
await asyncio.wait_for(_run(settings, stop_event), timeout=1.0)
asyncio.run(runner())
assert run_forever_mock.await_count == 1
call_kwargs: dict[str, Any] = run_forever_mock.await_args.kwargs
assert call_kwargs["interval_seconds"] == 0.01
assert http_client_instance.aclose.await_count == 1
assert engine_instance.dispose.await_count == 1
@@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from app.config import (
AdapterConfig,
EmailAdapterConfig,
ObservabilityConfig,
PostgresConfig,
Settings,
@@ -39,6 +40,11 @@ def _make_settings() -> Settings:
service_name="svc", otlp_endpoint="http://otlp"
),
waybill_poller=WaybillPollerConfig(interval_seconds=0.01, batch_size=5),
email=EmailAdapterConfig(
smtp_host="smtp.test",
smtp_port=587,
from_address="no-reply@test",
),
business_logic={"provider_price_multiplier": "1.0"},
)