📨 feat(integrations): escalar entregas do Brevo com destinatario dinamico
Consolidar as rotas de email por evento em um modelo global dinamico, resolvendo o destinatario a partir do cadastro do usuario e registrando recipient_email e recipient_name em cada entrega do outbox para melhorar rastreabilidade e operacao. Permitir captura opcional de email no Telegram, salvar o endereco no cadastro do usuario e reaproveitar esse dado em revisao, pedido e aluguel, incluindo prompts de consentimento e reenvio imediato do resumo apos a confirmacao. Ampliar a configuracao do provider Brevo e dos scripts operacionais com sender por rota, reply-to, cc, bcc, tags, headers, listagem de rotas e entregas, alem de migracoes de bootstrap e cobertura automatizada validada com 100 testes OK.main
parent
6837f00609
commit
31d02a7daa
@ -0,0 +1,34 @@
|
|||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from app.services.integrations.events import SUPPORTED_EVENT_TYPES
|
||||||
|
from app.services.integrations.service import SUPPORTED_DELIVERY_STATUSES, list_integration_deliveries
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Lista entregas do outbox de integracoes com filtros operacionais.")
|
||||||
|
parser.add_argument("--status", action="append", choices=SUPPORTED_DELIVERY_STATUSES)
|
||||||
|
parser.add_argument("--event", choices=SUPPORTED_EVENT_TYPES)
|
||||||
|
parser.add_argument("--provider")
|
||||||
|
parser.add_argument("--route-id", type=int)
|
||||||
|
parser.add_argument("--limit", type=int, default=50)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
deliveries = list_integration_deliveries(
|
||||||
|
statuses=args.status,
|
||||||
|
event_type=args.event,
|
||||||
|
provider=args.provider,
|
||||||
|
route_id=args.route_id,
|
||||||
|
limit=args.limit,
|
||||||
|
)
|
||||||
|
print(json.dumps(deliveries, ensure_ascii=True, indent=2, sort_keys=True))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from app.services.integrations.events import SUPPORTED_EVENT_TYPES
|
||||||
|
from app.services.integrations.service import list_integration_routes
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Lista rotas de integracao configuradas.")
|
||||||
|
parser.add_argument("--event", choices=SUPPORTED_EVENT_TYPES)
|
||||||
|
parser.add_argument("--provider")
|
||||||
|
enabled_group = parser.add_mutually_exclusive_group()
|
||||||
|
enabled_group.add_argument("--enabled", action="store_true")
|
||||||
|
enabled_group.add_argument("--disabled", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
enabled = None
|
||||||
|
if args.enabled:
|
||||||
|
enabled = True
|
||||||
|
if args.disabled:
|
||||||
|
enabled = False
|
||||||
|
|
||||||
|
routes = list_integration_routes(
|
||||||
|
event_type=args.event,
|
||||||
|
provider=args.provider,
|
||||||
|
enabled=enabled,
|
||||||
|
)
|
||||||
|
print(json.dumps(routes, ensure_ascii=True, indent=2, sort_keys=True))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -1,21 +1,57 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from app.services.integrations.service import process_pending_deliveries
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from app.services.integrations.events import SUPPORTED_EVENT_TYPES
|
||||||
|
from app.services.integrations.service import SUPPORTED_DELIVERY_STATUSES, process_pending_deliveries
|
||||||
|
|
||||||
async def _main_async(limit: int) -> None:
|
|
||||||
deliveries = await process_pending_deliveries(limit=limit)
|
async def _main_async(
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
|
delivery_ids: list[int] | None,
|
||||||
|
statuses: list[str] | None,
|
||||||
|
event_type: str | None,
|
||||||
|
provider: str | None,
|
||||||
|
route_id: int | None,
|
||||||
|
) -> None:
|
||||||
|
deliveries = await process_pending_deliveries(
|
||||||
|
limit=limit,
|
||||||
|
delivery_ids=delivery_ids,
|
||||||
|
statuses=statuses,
|
||||||
|
event_type=event_type,
|
||||||
|
provider=provider,
|
||||||
|
route_id=route_id,
|
||||||
|
)
|
||||||
print(json.dumps(deliveries, ensure_ascii=True, indent=2, sort_keys=True))
|
print(json.dumps(deliveries, ensure_ascii=True, indent=2, sort_keys=True))
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(description="Processa entregas pendentes do outbox de integracoes.")
|
parser = argparse.ArgumentParser(description="Processa entregas pendentes do outbox de integracoes.")
|
||||||
parser.add_argument("--limit", type=int, default=20)
|
parser.add_argument("--limit", type=int, default=20)
|
||||||
|
parser.add_argument("--delivery-id", dest="delivery_ids", type=int, action="append")
|
||||||
|
parser.add_argument("--status", action="append", choices=SUPPORTED_DELIVERY_STATUSES)
|
||||||
|
parser.add_argument("--event", choices=SUPPORTED_EVENT_TYPES)
|
||||||
|
parser.add_argument("--provider")
|
||||||
|
parser.add_argument("--route-id", type=int)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
asyncio.run(_main_async(limit=args.limit))
|
asyncio.run(
|
||||||
|
_main_async(
|
||||||
|
limit=args.limit,
|
||||||
|
delivery_ids=args.delivery_ids,
|
||||||
|
statuses=args.status,
|
||||||
|
event_type=args.event,
|
||||||
|
provider=args.provider,
|
||||||
|
route_id=args.route_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
os.environ.setdefault("DEBUG", "false")
|
||||||
|
|
||||||
|
from app.core.settings import settings
|
||||||
|
from app.services.integrations.providers import BrevoEmailProvider
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(self, *, status_code: int = 201, payload: dict | None = None, text: str = "") -> None:
|
||||||
|
self.status_code = status_code
|
||||||
|
self._payload = payload or {}
|
||||||
|
self.text = text
|
||||||
|
|
||||||
|
def json(self) -> dict:
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
class BrevoEmailProviderTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_send_email_applies_provider_config_to_payload(self):
|
||||||
|
response = _FakeResponse(payload={"messageId": "brevo-123"}, text='{"messageId":"brevo-123"}')
|
||||||
|
post_mock = AsyncMock(return_value=response)
|
||||||
|
fake_client = type("FakeClient", (), {"post": post_mock})()
|
||||||
|
|
||||||
|
with patch.object(settings, "brevo_api_key", "brevo-key"), patch.object(
|
||||||
|
settings,
|
||||||
|
"brevo_sender_email",
|
||||||
|
"sender@empresa.com",
|
||||||
|
), patch.object(settings, "brevo_sender_name", "Orquestrador"), patch(
|
||||||
|
"app.services.integrations.providers.httpx.AsyncClient"
|
||||||
|
) as client_cls:
|
||||||
|
client_cls.return_value.__aenter__ = AsyncMock(return_value=fake_client)
|
||||||
|
client_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
provider = BrevoEmailProvider()
|
||||||
|
result = await provider.send_email(
|
||||||
|
to_email="destinatario@empresa.com",
|
||||||
|
to_name="Operacoes",
|
||||||
|
subject="Pedido criado",
|
||||||
|
body="Pedido PED-1 criado com sucesso.",
|
||||||
|
tags=["order.created"],
|
||||||
|
provider_config={
|
||||||
|
"sender": {"email": "noreply@empresa.com", "name": "Operacoes"},
|
||||||
|
"reply_to": {"email": "atendimento@empresa.com", "name": "Atendimento"},
|
||||||
|
"cc": ["financeiro@empresa.com", {"email": "gestor@empresa.com", "name": "Gestor"}],
|
||||||
|
"bcc": ["auditoria@empresa.com"],
|
||||||
|
"tags": ["ops", "order.created"],
|
||||||
|
"headers": {"X-Canal": "orquestrador"},
|
||||||
|
"html_content": "<strong>Pedido PED-1 criado com sucesso.</strong>",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = post_mock.await_args.kwargs["json"]
|
||||||
|
self.assertEqual(payload["sender"], {"email": "noreply@empresa.com", "name": "Operacoes"})
|
||||||
|
self.assertEqual(payload["replyTo"], {"email": "atendimento@empresa.com", "name": "Atendimento"})
|
||||||
|
self.assertEqual(
|
||||||
|
payload["cc"],
|
||||||
|
[
|
||||||
|
{"email": "financeiro@empresa.com"},
|
||||||
|
{"email": "gestor@empresa.com", "name": "Gestor"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(payload["bcc"], [{"email": "auditoria@empresa.com"}])
|
||||||
|
self.assertEqual(payload["tags"], ["order.created", "ops"])
|
||||||
|
self.assertEqual(payload["headers"], {"X-Canal": "orquestrador"})
|
||||||
|
self.assertEqual(payload["htmlContent"], "<strong>Pedido PED-1 criado com sucesso.</strong>")
|
||||||
|
self.assertEqual(result["message_id"], "brevo-123")
|
||||||
|
|
||||||
|
async def test_send_email_accepts_route_sender_without_global_sender_email(self):
|
||||||
|
response = _FakeResponse(payload={"messageId": "brevo-456"}, text='{"messageId":"brevo-456"}')
|
||||||
|
post_mock = AsyncMock(return_value=response)
|
||||||
|
fake_client = type("FakeClient", (), {"post": post_mock})()
|
||||||
|
|
||||||
|
with patch.object(settings, "brevo_api_key", "brevo-key"), patch.object(
|
||||||
|
settings,
|
||||||
|
"brevo_sender_email",
|
||||||
|
"",
|
||||||
|
), patch.object(settings, "brevo_sender_name", "Orquestrador"), patch(
|
||||||
|
"app.services.integrations.providers.httpx.AsyncClient"
|
||||||
|
) as client_cls:
|
||||||
|
client_cls.return_value.__aenter__ = AsyncMock(return_value=fake_client)
|
||||||
|
client_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
provider = BrevoEmailProvider()
|
||||||
|
await provider.send_email(
|
||||||
|
to_email="destinatario@empresa.com",
|
||||||
|
to_name=None,
|
||||||
|
subject="Pedido criado",
|
||||||
|
body="Pedido PED-1 criado com sucesso.",
|
||||||
|
provider_config={
|
||||||
|
"sender": {"email": "noreply@empresa.com", "name": "Operacoes"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = post_mock.await_args.kwargs["json"]
|
||||||
|
self.assertEqual(payload["sender"], {"email": "noreply@empresa.com", "name": "Operacoes"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Reference in New Issue