255 lines
14 KiB
Python
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)
|