Добавлена отправка накладных на почту
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""E-mail adapters."""
|
||||
|
||||
from app.adapters.email.smtp_client import SMTPEmailSender, SMTPEmailSenderError
|
||||
|
||||
__all__ = ["SMTPEmailSender", "SMTPEmailSenderError"]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user