"""Integration outbox routes and actions. Moved from app.admin_dashboard in v4.7.2. v4.7.4 adds a dedicated HTMX table partial so filters and operator actions can refresh the outbox without replacing the full page. """ from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse, PlainTextResponse import app.admin_dashboard as legacy from app.admin_dashboard import * # noqa: F401,F403 from app.admin_ui.guidance import outbox_operator_message router = APIRouter() def _outbox_tab_url(status: str = "all", target_system: str = "all") -> str: parts = [] if status and status != "all": parts.append(f"status={esc(status)}") if target_system and target_system != "all": parts.append(f"target_system={esc(target_system)}") return "/outbox" + ("?" + "&".join(parts) if parts else "") def _outbox_partial_url(status: str = "all", target_system: str = "all") -> str: href = _outbox_tab_url(status=status, target_system=target_system) return "/outbox/partials/table" + (href[href.find("?"):] if "?" in href else "") def _outbox_items(status: str = "all", target_system: str = "all", limit: int = 100) -> list[dict]: query_status = None if status == "all" else status query_target = None if target_system == "all" else target_system return list_outbox(status=query_status, target_system=query_target, limit=limit) def render_outbox_table_partial(items: list[dict], status: str = "all", target_system: str = "all") -> str: rows = "" for item in items: payload = item.get("payload") or {} if isinstance(payload, str): try: payload = json.loads(payload) except Exception: payload = {} opp_id = payload.get("opportunity_id") or "" conv = payload.get("conversation_id") or "" err = str(item.get("last_error") or "") human_error = outbox_operator_message(item) short_err = err[:220] + "…" if len(err) > 220 else err origin = f'Oportunidade' if opp_id else '—' conv_html = f'Conversa #{esc(conv)}' if conv else '' item_id = str(item.get("id") or "") oid = esc(item_id) rows += f'''
{esc(fmt_dt(item.get('created_at')))}
{oid} {operation_status_badge(str(item.get('status') or 'pending'))}
{esc(fmt_dt(item.get('locked_at')) if item.get('locked_at') else '')}
{esc(item.get('lock_owner') or '')}
{esc(item.get('target_system'))}
{esc(item.get('action_type'))}
{origin}
{conv_html}
{esc(human_error.get('title'))}
Motivo provável: {esc(human_error.get('probable'))}
{f'
Técnico: {esc(short_err)}
' if short_err else ''}
Ver
''' if not rows: rows = 'Sem itens de outbox para os filtros selecionados.' return f'''
{len(items)} resultado(s) A atualizar…
{rows}
Criado / IDEstadoSistema / AçãoOrigemErroAções
''' @router.get("/outbox/partials/table", response_class=HTMLResponse) async def outbox_table_partial(status: Optional[str] = None, target_system: Optional[str] = None, limit: int = 100): status = status or "all" target_system = target_system or "all" items = _outbox_items(status=status, target_system=target_system, limit=limit) return HTMLResponse(render_outbox_table_partial(items, status=status, target_system=target_system)) @router.get("/outbox", response_class=HTMLResponse) async def outbox_page( status: Optional[str] = None, target_system: Optional[str] = None, limit: int = 100, ): status = status or "all" target_system = target_system or "all" items = _outbox_items(status=status, target_system=target_system, limit=limit) status_filters = "".join( f'{esc(label)}' for value, label in [("all", "Todas"), ("pending", "Pendentes"), ("processing", "A processar"), ("sent", "Processadas"), ("failed", "Falhadas"), ("blocked", "Bloqueadas"), ("stale", "Stale"), ("dry_run", "Dry-run"), ("ignored", "Ignoradas"), ("cancelled", "Canceladas")] ) system_filters = "".join( f'{esc(label)}' for value, label in [("all", "Todos"), ("jasmin", "Jasmin"), ("packlink", "Packlink"), ("chatwoot", "Chatwoot"), ("mautic", "Mautic"), ("odoo", "Odoo")] ) body = f'''
Estado:{status_filters}
Sistema:{system_filters}

Outbox operacional

Reprocessa ou ignora ações sem usar SQL no terminal. Filtros e ações atualizam por HTMX.
Integrações
{render_outbox_table_partial(items, status=status, target_system=target_system)}
''' return layout("Outbox", "Ações pendentes e histórico de integrações externas", body, "outbox") @router.get("/outbox/{outbox_id}", response_class=HTMLResponse) async def outbox_detail(outbox_id: str): if not is_uuid_text(outbox_id): return PlainTextResponse("Identificador de outbox inválido.", status_code=422) item = get_outbox_item(outbox_id) if not item: return layout("Outbox item", "Item não encontrado.", "

Não encontrado.

", "outbox") payload = item.get("payload") or {} if isinstance(payload, str): try: payload = json.loads(payload) except Exception: payload = {} opp_id = payload.get("opportunity_id") or "" origin_href = ("/opportunities/" + esc(opp_id)) if opp_id else "/outbox" origin_label = "Oportunidade" if opp_id else "—" human_error = outbox_operator_message(item) fix_href = origin_href if opp_id else "/outbox" body = f'''

← Voltar à outbox

{kpi_card('Estado', esc(item.get('status')), None, 'atual', 'bi-info-circle')} {kpi_card('Sistema', esc(item.get('target_system')), None, esc(item.get('action_type')), 'bi-puzzle')} {kpi_card('Retries', esc(item.get('retry_count')), None, 'tentativas', 'bi-arrow-clockwise')} {kpi_card('Origem', origin_label, origin_href, esc(opp_id), 'bi-link-45deg')}

Leitura operacional

{esc(human_error.get('title'))}
Motivo provável: {esc(human_error.get('probable'))}
{esc(human_error.get('fix_label'))}

Payload

{esc(pretty_json(payload))}

Erro técnico

{esc(item.get('last_error') or '')}
''' return layout("Outbox item", f"Detalhe {outbox_id}", body, "outbox") @router.post("/outbox/{outbox_id}/retry") async def outbox_retry(outbox_id: str, request: Request): if not is_uuid_text(outbox_id): return PlainTextResponse("Identificador de outbox inválido.", status_code=422) form = await request.form() opportunity_id = str(form.get("opportunity_id") or "").strip() set_outbox_status(outbox_id=outbox_id, status="pending") if opportunity_id and is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, notice="Item de outbox reposto para pending.")) if opportunity_id: return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Item%20de%20outbox%20reposto%20para%20pending", status_code=303) return RedirectResponse("/outbox", status_code=303) def _outbox_htmx_or_redirect(request: Request, status: str = "all", target_system: str = "all"): if is_htmx(request): return HTMLResponse(render_outbox_table_partial(_outbox_items(status=status, target_system=target_system))) return RedirectResponse("/outbox", status_code=303) @router.post("/outbox/{outbox_id}/pending") async def outbox_pending(outbox_id: str, request: Request): if not is_uuid_text(outbox_id): return PlainTextResponse("Identificador de outbox inválido.", status_code=422) set_outbox_status(outbox_id=outbox_id, status="pending") from app.operator_audit_service import record_operator_action_best_effort record_operator_action_best_effort(action="outbox_reprocess_requested", entity_type="outbox", entity_id=outbox_id, actor="operator", after={"status": "pending"}) return _outbox_htmx_or_redirect(request) @router.post("/outbox/{outbox_id}/sent") async def outbox_sent(outbox_id: str, request: Request): if not is_uuid_text(outbox_id): return PlainTextResponse("Identificador de outbox inválido.", status_code=422) set_outbox_status(outbox_id=outbox_id, status="sent") from app.operator_audit_service import record_operator_action_best_effort record_operator_action_best_effort(action="outbox_marked_sent", entity_type="outbox", entity_id=outbox_id, actor="operator", after={"status": "sent"}) return _outbox_htmx_or_redirect(request) @router.post("/outbox/{outbox_id}/failed") async def outbox_failed(outbox_id: str, request: Request): if not is_uuid_text(outbox_id): return PlainTextResponse("Identificador de outbox inválido.", status_code=422) set_outbox_status(outbox_id=outbox_id, status="failed", error="Marcado manualmente como failed.") from app.operator_audit_service import record_operator_action_best_effort record_operator_action_best_effort(action="outbox_marked_failed", entity_type="outbox", entity_id=outbox_id, actor="operator", after={"status": "failed"}) return _outbox_htmx_or_redirect(request) @router.post("/outbox/{outbox_id}/ignored") async def outbox_ignored(outbox_id: str, request: Request): if not is_uuid_text(outbox_id): return PlainTextResponse("Identificador de outbox inválido.", status_code=422) set_outbox_status(outbox_id=outbox_id, status="ignored", error="Ignorado manualmente pelo operador.") from app.operator_audit_service import record_operator_action_best_effort record_operator_action_best_effort(action="outbox_ignored", entity_type="outbox", entity_id=outbox_id, actor="operator", after={"status": "ignored"}) return _outbox_htmx_or_redirect(request)