Files

255 lines
14 KiB
Python

"""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'<a href="/opportunities/{esc(opp_id)}">Oportunidade</a>' 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'''
<tr>
<td><div class="small text-secondary">{esc(fmt_dt(item.get('created_at')))}</div><code>{oid}</code></td>
<td>{operation_status_badge(str(item.get('status') or 'pending'))}<div class="small text-secondary">{esc(fmt_dt(item.get('locked_at')) if item.get('locked_at') else '')}</div><div class="small text-secondary text-break">{esc(item.get('lock_owner') or '')}</div></td>
<td><strong>{esc(item.get('target_system'))}</strong><div class="small text-secondary"><code>{esc(item.get('action_type'))}</code></div></td>
<td>{origin}<div class="small text-secondary">{conv_html}</div></td>
<td class="small text-break">
<div class="cf-outbox-human-error">
<strong>{esc(human_error.get('title'))}</strong>
<div class="small">Motivo provável: {esc(human_error.get('probable'))}</div>
{f'<div class="small text-danger mt-1">Técnico: {esc(short_err)}</div>' if short_err else ''}
</div>
</td>
<td class="text-end">
<div class="d-flex flex-wrap justify-content-end gap-1">
<a class="btn btn-sm btn-outline-secondary" href="/outbox/{oid}">Ver</a>
<form method="post" action="/outbox/{oid}/pending" hx-post="/outbox/{oid}/pending" hx-target="#outbox-table" hx-swap="outerHTML" hx-indicator="#outbox-loading" hx-confirm="Reprocessar este item da outbox?"><button class="btn btn-sm btn-outline-primary" type="submit">Reprocessar</button></form>
<form method="post" action="/outbox/{oid}/ignored" hx-post="/outbox/{oid}/ignored" hx-target="#outbox-table" hx-swap="outerHTML" hx-indicator="#outbox-loading" hx-confirm="Ignorar este item da outbox?"><button class="btn btn-sm btn-outline-secondary" type="submit">Ignorar</button></form>
</div>
</td>
</tr>
'''
if not rows:
rows = '<tr><td colspan="6" class="text-center text-secondary py-5">Sem itens de outbox para os filtros selecionados.</td></tr>'
return f'''
<div id="outbox-table" class="cf-live-panel" aria-live="polite">
<div class="d-flex justify-content-between align-items-center gap-2 mb-2">
<span class="small text-secondary">{len(items)} resultado(s)</span>
<span class="small text-secondary htmx-indicator" id="outbox-loading">A atualizar…</span>
</div>
<div class="cf-table-wrap border-0 rounded-0">
<table class="table cf-table">
<thead><tr><th>Criado / ID</th><th>Estado</th><th>Sistema / Ação</th><th>Origem</th><th>Erro</th><th class="text-end">Ações</th></tr></thead>
<tbody>{rows}</tbody>
</table>
</div>
</div>
'''
@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'<a class="btn btn-sm {"btn-primary" if status == value else "btn-outline-secondary"}" href="{_outbox_tab_url(status=value, target_system=target_system)}" hx-get="{_outbox_partial_url(status=value, target_system=target_system)}" hx-target="#outbox-table" hx-swap="outerHTML" hx-push-url="{_outbox_tab_url(status=value, target_system=target_system)}" hx-indicator="#outbox-loading">{esc(label)}</a>'
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'<a class="btn btn-sm {"btn-primary" if target_system == value else "btn-outline-secondary"}" href="{_outbox_tab_url(status=status, target_system=value)}" hx-get="{_outbox_partial_url(status=status, target_system=value)}" hx-target="#outbox-table" hx-swap="outerHTML" hx-push-url="{_outbox_tab_url(status=status, target_system=value)}" hx-indicator="#outbox-loading">{esc(label)}</a>'
for value, label in [("all", "Todos"), ("jasmin", "Jasmin"), ("packlink", "Packlink"), ("chatwoot", "Chatwoot"), ("mautic", "Mautic"), ("odoo", "Odoo")]
)
body = f'''
<section class="card cf-card cf-filter-card">
<div class="card-body p-3 d-grid gap-3">
<div><strong class="me-2">Estado:</strong><span class="d-inline-flex flex-wrap gap-2">{status_filters}</span></div>
<div><strong class="me-2">Sistema:</strong><span class="d-inline-flex flex-wrap gap-2">{system_filters}</span></div>
</div>
</section>
<section class="card cf-card">
<div class="card-body p-0">
<div class="p-3 border-bottom d-flex flex-wrap justify-content-between align-items-center gap-2">
<div><h2 class="cf-section-title">Outbox operacional</h2><div class="small text-secondary">Reprocessa ou ignora ações sem usar SQL no terminal. Filtros e ações atualizam por HTMX.</div></div>
<a class="btn btn-sm btn-outline-secondary" href="/integrations">Integrações</a>
</div>
{render_outbox_table_partial(items, status=status, target_system=target_system)}
</div>
</section>
'''
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.", "<p>Não encontrado.</p>", "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'''
<p><a class="cf-row-link" href="/outbox">← Voltar à outbox</a></p>
<section class="cf-kpi-grid">
{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')}
</section>
<section class="card cf-card mb-3"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Leitura operacional</h2>
<div class="cf-outbox-human-error">
<strong>{esc(human_error.get('title'))}</strong>
<div class="small mt-1">Motivo provável: {esc(human_error.get('probable'))}</div>
<a class="btn btn-sm btn-outline-primary mt-3" href="{esc(fix_href)}">{esc(human_error.get('fix_label'))}</a>
</div>
</div></section>
<section class="card cf-card"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Payload</h2>
<pre>{esc(pretty_json(payload))}</pre>
<h2 class="cf-section-title mb-3 mt-4">Erro técnico</h2>
<pre>{esc(item.get('last_error') or '')}</pre>
<div class="d-flex flex-wrap gap-2 mt-3">
<form method="post" action="/outbox/{esc(item['id'])}/pending" hx-confirm="Reprocessar este item da outbox?"><button class="btn btn-outline-primary" type="submit">Reprocessar</button></form>
<form method="post" action="/outbox/{esc(item['id'])}/ignored" hx-confirm="Ignorar este item da outbox?"><button class="btn btn-outline-secondary" type="submit">Ignorar</button></form>
<form method="post" action="/outbox/{esc(item['id'])}/failed" hx-confirm="Marcar este item como failed?"><button class="btn btn-outline-danger" type="submit">Marcar failed</button></form>
</div>
</div></section>
'''
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)