"""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 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): 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): 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): 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): 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): 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): 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)