Specs stay at repo root (cross-VM). Move deploy and code into logical projects with README per domain, updated manifest.yaml, and symlinks at legacy paths for VM122 backward compatibility.
184 lines
6.2 KiB
Python
184 lines
6.2 KiB
Python
"""Catálogo de acções Desk + links externos + ranking técnicos — Spec 010 Fase C/F."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sqlite3
|
|
from collections import defaultdict
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any
|
|
|
|
FUNNEL_RANK_BY_STAGE = {
|
|
"started": 1,
|
|
"domain_validated": 2,
|
|
"dns_applied": 3,
|
|
"account_created": 4,
|
|
"infra_synced": 5,
|
|
"completed": 6,
|
|
"failed": 99,
|
|
}
|
|
|
|
DESK_ACTIONS = {
|
|
"dns.revalidate": {
|
|
"label": "Revalidar DNS",
|
|
"min_rank": 3,
|
|
"method": "GET",
|
|
"path": "/onboarding/dns/verify/{domain}",
|
|
},
|
|
"dns.reapply": {
|
|
"label": "Reaplicar DNS Cloudflare",
|
|
"min_rank": 3,
|
|
"method": "POST",
|
|
"path": "/onboarding/dns/cloudflare/apply",
|
|
"body": lambda domain, _payload: {"domain": domain},
|
|
},
|
|
"account.retry_sync": {
|
|
"label": "Reverificar infra/conta",
|
|
"min_rank": 4,
|
|
"method": "GET",
|
|
"path": "/onboarding/infrastructure/status/{domain}",
|
|
},
|
|
"infra.resync": {
|
|
"label": "Resync infra (Traefik/cert)",
|
|
"min_rank": 5,
|
|
"method": "POST",
|
|
"path": "/onboarding/infrastructure/provision",
|
|
"body": lambda domain, _payload: {"domain": domain},
|
|
},
|
|
}
|
|
|
|
ABORT_ACTION = "onboarding.abort"
|
|
MARK_STEP_ACTION = "onboarding.mark_step_complete"
|
|
|
|
PROXMOX_URL = os.getenv("DESK_LINK_PROXMOX", "https://proxmox.itecnologys.com")
|
|
TRAEFIK_URL = os.getenv("DESK_LINK_TRAEFIK", "https://traefik.itecnologys.com/dashboard/")
|
|
CARBONIO_ADMIN = os.getenv("DESK_LINK_CARBONIO", "https://mail.ibytera.com/admin")
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def funnel_rank(stage: str | None) -> int:
|
|
return FUNNEL_RANK_BY_STAGE.get(stage or "", 0)
|
|
|
|
|
|
def list_actions_for_session(funnel_stage: str | None, is_assisting: bool, role: str) -> list[dict]:
|
|
rank = funnel_rank(funnel_stage)
|
|
out = []
|
|
for action_id, spec in DESK_ACTIONS.items():
|
|
if rank < spec["min_rank"]:
|
|
continue
|
|
if not is_assisting and action_id not in ("dns.revalidate", "account.retry_sync"):
|
|
continue
|
|
out.append({"id": action_id, "label": spec["label"], "requires_assisting": action_id not in ("dns.revalidate", "account.retry_sync")})
|
|
if is_assisting and role in ("super_admin", "ops_lead", "technician"):
|
|
out.append({"id": MARK_STEP_ACTION, "label": "Marcar passo concluído", "requires_assisting": True})
|
|
if role in ("super_admin", "ops_lead"):
|
|
out.append({"id": ABORT_ACTION, "label": "Abortar onboarding", "requires_assisting": False, "danger": True})
|
|
return out
|
|
|
|
|
|
def build_vm112_request(action_id: str, domain: str, ticket_payload: dict | None) -> tuple[str, str, dict | None]:
|
|
if action_id not in DESK_ACTIONS:
|
|
raise ValueError(f"acção desconhecida: {action_id}")
|
|
spec = DESK_ACTIONS[action_id]
|
|
path = spec["path"].format(domain=domain)
|
|
body = None
|
|
if "body" in spec:
|
|
body = spec["body"](domain, ticket_payload or {})
|
|
return spec["method"], path, body
|
|
|
|
|
|
def external_links(domain: str | None) -> list[dict[str, str]]:
|
|
dom = (domain or "").strip().lower()
|
|
links = [
|
|
{"id": "proxmox", "label": "Proxmox", "url": PROXMOX_URL, "system": "proxmox"},
|
|
{"id": "traefik", "label": "Traefik", "url": TRAEFIK_URL, "system": "traefik"},
|
|
{"id": "carbonio", "label": "Carbonio Admin", "url": CARBONIO_ADMIN, "system": "carbonio"},
|
|
]
|
|
if dom:
|
|
links.append({
|
|
"id": "webmail",
|
|
"label": f"Webmail {dom}",
|
|
"url": f"https://mail.{dom}/",
|
|
"system": "carbonio",
|
|
})
|
|
links.append({
|
|
"id": "cloudflare",
|
|
"label": f"Cloudflare DNS ({dom})",
|
|
"url": f"https://dash.cloudflare.com/?to=/:{dom}/dns",
|
|
"system": "cloudflare",
|
|
})
|
|
return links
|
|
|
|
|
|
def technician_ranking(conn: sqlite3.Connection, window_days: int = 30) -> list[dict[str, Any]]:
|
|
cutoff = (datetime.now(timezone.utc) - timedelta(days=window_days)).isoformat()
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT aa.actor, aa.action, aa.created_at, s.initiated_by_user
|
|
FROM assist_actions aa
|
|
LEFT JOIN assist_sessions s ON s.id = aa.assist_session_id
|
|
WHERE aa.created_at >= ?
|
|
ORDER BY aa.id DESC
|
|
""",
|
|
(cutoff,),
|
|
).fetchall()
|
|
|
|
skip_actors = frozenset({"client", "system", "worker", ""})
|
|
stats: dict[str, dict[str, int]] = defaultdict(
|
|
lambda: {"escalados": 0, "assumidos": 0, "handoffs": 0, "acoes": 0, "movimentos": 0}
|
|
)
|
|
|
|
for row in rows:
|
|
actor = (row["actor"] or "").strip()
|
|
if actor in skip_actors:
|
|
continue
|
|
action = row["action"] or ""
|
|
bucket = stats[actor]
|
|
bucket["movimentos"] += 1
|
|
if action == "escalate":
|
|
bucket["escalados"] += 1
|
|
elif action == "takeover":
|
|
bucket["assumidos"] += 1
|
|
elif action == "handoff":
|
|
bucket["handoffs"] += 1
|
|
elif action.startswith("action."):
|
|
bucket["acoes"] += 1
|
|
|
|
assigned = conn.execute(
|
|
"""
|
|
SELECT assigned_to, COUNT(*) c
|
|
FROM tickets
|
|
WHERE assigned_to IS NOT NULL AND assigned_at >= ?
|
|
GROUP BY assigned_to
|
|
""",
|
|
(cutoff,),
|
|
).fetchall()
|
|
for row in assigned:
|
|
user = (row["assigned_to"] or "").strip()
|
|
if user:
|
|
stats[user]["atribuidos"] = int(row["c"])
|
|
|
|
ranking = []
|
|
for username, data in stats.items():
|
|
assumidos = data["assumidos"]
|
|
escalados = data["escalados"]
|
|
handoffs = data["handoffs"]
|
|
acoes = data["acoes"]
|
|
movimentos = data["movimentos"]
|
|
atribuidos = data.get("atribuidos", 0)
|
|
score = assumidos * 5 + escalados * 2 + acoes * 3 + handoffs + atribuidos
|
|
ranking.append({
|
|
"username": username,
|
|
"assumidos": assumidos,
|
|
"escalados": escalados,
|
|
"handoffs": handoffs,
|
|
"acoes": acoes,
|
|
"movimentos": movimentos,
|
|
"atribuidos": atribuidos,
|
|
"score": score,
|
|
})
|
|
ranking.sort(key=lambda x: (x["score"], x["assumidos"], x["movimentos"]), reverse=True)
|
|
return ranking
|