feat(web): dashboard ergonomic cu tab-uri, stepper import si microcopy uman (3.4)
Reorganizeaza interfata web pe trei principii, fara a atinge backend-ul de trimitere (worker, mapping, idempotency, masina de stari neatinse): - US-001 app/web/labels.py: modul pur stari tehnice -> text uman + clasa CSS - US-002 bara status /_fragments/status: microcopy uman, defalcare blocate, scoped cont - US-003 shell 6 tab-uri (Acasa/Import/Coada/Mapari/Cont/Nomenclator): deep-link ?tab=, panou activ randat server-side, fragmente inactive lazy, ARIA real - US-004 stepper import 4 pasi (pur vizual; hx-target + csrf pastrate) - US-005 Acasa onboarding checklist auto-bifat + colaps + empty states prietenoase Reparat in cursul VERIFY/CLOSE: izolare teste (reset ratelimit._hits in fixturi), regresie avertisment "cont in asteptare de activare" (re-introdus in bara status), culori hardcodate -> variabile paleta. 434 teste pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,12 @@ from fastapi.templating import Jinja2Templates
|
||||
from .. import __version__
|
||||
from ..auth import rotate_api_key
|
||||
from ..web.csrf import get_csrf_token, verify_csrf
|
||||
from .labels import (
|
||||
ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
|
||||
eticheta_rar,
|
||||
eticheta_stare,
|
||||
eticheta_worker,
|
||||
)
|
||||
from ..web.session import require_login
|
||||
from ..api.v1.import_router import (
|
||||
_already_sent_lookup,
|
||||
@@ -115,25 +121,142 @@ def _rar_state(hb, worker_alive: bool) -> str:
|
||||
return "indisponibil?" if age > 108000 else "ok"
|
||||
|
||||
|
||||
_TABS_VALIDE = {"acasa", "import", "coada", "mapari", "cont", "nomenclator"}
|
||||
|
||||
|
||||
def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
|
||||
"""Calculeaza contextul pentru panoul Acasa (US-005).
|
||||
|
||||
Scoped pe contul sesiunii — identic cu pattern-ul din restul rutelor (NULL->1).
|
||||
"""
|
||||
from ..mapping import account_or_default
|
||||
acct = account_or_default(account_id)
|
||||
|
||||
# Pas 1: are credentiale RAR configurate?
|
||||
row = conn.execute(
|
||||
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
||||
).fetchone()
|
||||
are_creds = bool(row and row["rar_creds_enc"])
|
||||
|
||||
# Pas 3: are cel putin un submission (trimis sau in coada)?
|
||||
row_sub = conn.execute(
|
||||
"SELECT 1 FROM submissions "
|
||||
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) LIMIT 1",
|
||||
(acct, acct),
|
||||
).fetchone()
|
||||
are_trimiteri = row_sub is not None
|
||||
|
||||
# Pas 2 (optional): are cheie API activa?
|
||||
row_key = conn.execute(
|
||||
"SELECT 1 FROM api_keys WHERE account_id=? AND active=1 LIMIT 1",
|
||||
(acct,),
|
||||
).fetchone()
|
||||
are_cheie_folosita = row_key is not None
|
||||
|
||||
return {
|
||||
"request": request,
|
||||
"are_creds": are_creds,
|
||||
"are_trimiteri": are_trimiteri,
|
||||
"are_cheie_folosita": are_cheie_folosita,
|
||||
}
|
||||
|
||||
|
||||
def _render_panel_acasa(request: Request, conn=None, account_id: int = 1) -> str:
|
||||
"""Randeaza panoul Acasa ca string HTML."""
|
||||
if conn is None:
|
||||
return templates.get_template("_acasa.html").render({"request": request})
|
||||
ctx = _get_acasa_context(request, conn, account_id)
|
||||
return templates.get_template("_acasa.html").render(ctx)
|
||||
|
||||
|
||||
def _render_panel_import(request: Request) -> str:
|
||||
"""Randeaza panoul Import ca string HTML (include _upload.html)."""
|
||||
return templates.get_template("_upload.html").render({
|
||||
"request": request,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
})
|
||||
|
||||
|
||||
def _render_panel_coada(request: Request) -> str:
|
||||
"""Randeaza panoul Coada ca string HTML."""
|
||||
return templates.get_template("_coada.html").render({"request": request})
|
||||
|
||||
|
||||
def _render_panel_mapari(request: Request, conn, account_id: int) -> str:
|
||||
"""Randeaza panoul Mapari ca string HTML."""
|
||||
return templates.get_template("_mapari.html").render({
|
||||
"request": request,
|
||||
"pending": pending_unmapped(conn, account_id),
|
||||
"nomenclator": load_nomenclator(conn),
|
||||
"message": None,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
})
|
||||
|
||||
|
||||
def _render_panel_cont(request: Request, conn, account_id: int) -> str:
|
||||
"""Randeaza panoul Cont ca string HTML."""
|
||||
from ..mapping import account_or_default
|
||||
acct = account_or_default(account_id)
|
||||
row = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
|
||||
are_creds = bool(row and row["rar_creds_enc"])
|
||||
return templates.get_template("_cont.html").render({
|
||||
"request": request,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
"api_key": None,
|
||||
"are_creds": are_creds,
|
||||
"creds_mesaj": None,
|
||||
"creds_eroare": None,
|
||||
"rot_eroare": None,
|
||||
})
|
||||
|
||||
|
||||
def _render_panel_nomenclator(request: Request, conn) -> str:
|
||||
"""Randeaza panoul Nomenclator ca string HTML."""
|
||||
rows = conn.execute(
|
||||
"SELECT cod_prestatie, nume_prestatie, updated_at FROM nomenclator_rar ORDER BY cod_prestatie"
|
||||
).fetchall()
|
||||
return templates.get_template("_nomenclator.html").render({
|
||||
"request": request,
|
||||
"rows": rows,
|
||||
})
|
||||
|
||||
|
||||
def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str) -> str:
|
||||
"""Randeaza panoul corespunzator unui tab ca string HTML."""
|
||||
if tab == "acasa":
|
||||
return _render_panel_acasa(request, conn, account_id)
|
||||
if tab == "import":
|
||||
return _render_panel_import(request)
|
||||
if tab == "coada":
|
||||
return _render_panel_coada(request)
|
||||
if tab == "mapari":
|
||||
return _render_panel_mapari(request, conn, account_id)
|
||||
if tab == "cont":
|
||||
return _render_panel_cont(request, conn, account_id)
|
||||
if tab == "nomenclator":
|
||||
return _render_panel_nomenclator(request, conn)
|
||||
return _render_panel_acasa(request)
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def dashboard(request: Request) -> HTMLResponse:
|
||||
def dashboard(request: Request, tab: str = "acasa") -> HTMLResponse:
|
||||
"""Dashboard principal cu tab-uri (US-003).
|
||||
|
||||
Parametrul ?tab= permite deep-link pe orice sectiune; panoul activ e randat
|
||||
server-side la full load (fara palpaiere la refresh, degradare gratiosa fara JS).
|
||||
Tab invalid -> fallback la 'acasa'.
|
||||
"""
|
||||
account_id = require_login(request)
|
||||
active_tab = tab if tab in _TABS_VALIDE else "acasa"
|
||||
conn = get_connection()
|
||||
try:
|
||||
counts = _status_counts(conn, account_id)
|
||||
hb = read_heartbeat(conn)
|
||||
blocked = sum(counts.get(s, 0) for s in _BLOCKED)
|
||||
worker_alive = _worker_alive(hb)
|
||||
panel_html = _render_panel_for_tab(request, conn, account_id, active_tab)
|
||||
ctx = {
|
||||
"request": request,
|
||||
"rar_env": get_settings().rar_env,
|
||||
"version": __version__,
|
||||
"counts": counts,
|
||||
"blocked": blocked,
|
||||
"worker_alive": worker_alive,
|
||||
"last_login": hb["last_rar_login_ok"] if hb else None,
|
||||
"rar_state": _rar_state(hb, worker_alive),
|
||||
"account_active": _account_active(conn, account_id),
|
||||
"active_tab": active_tab,
|
||||
"panel_html": panel_html,
|
||||
"is_admin": is_account_admin(conn, account_id),
|
||||
"csrf_token": get_csrf_token(request),
|
||||
}
|
||||
@@ -142,6 +265,32 @@ def dashboard(request: Request) -> HTMLResponse:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/_fragments/acasa", response_class=HTMLResponse)
|
||||
def fragment_acasa(request: Request) -> HTMLResponse:
|
||||
"""Fragment HTMX pentru tab-ul Acasa (US-003, US-005)."""
|
||||
account_id = require_login(request)
|
||||
conn = get_connection()
|
||||
try:
|
||||
ctx = _get_acasa_context(request, conn, account_id)
|
||||
return templates.TemplateResponse("_acasa.html", ctx)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/_fragments/import", response_class=HTMLResponse)
|
||||
def fragment_import(request: Request) -> HTMLResponse:
|
||||
"""Fragment HTMX pentru tab-ul Import — include zona de upload (US-003)."""
|
||||
require_login(request)
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request))
|
||||
|
||||
|
||||
@router.get("/_fragments/coada", response_class=HTMLResponse)
|
||||
def fragment_coada(request: Request) -> HTMLResponse:
|
||||
"""Fragment HTMX pentru tab-ul Coada — include coada submissions (US-003)."""
|
||||
require_login(request)
|
||||
return templates.TemplateResponse("_coada.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/_fragments/nomenclator", response_class=HTMLResponse)
|
||||
def fragment_nomenclator(request: Request) -> HTMLResponse:
|
||||
"""Browser nomenclator RAR (cache local upsert-at de worker la fiecare login)."""
|
||||
@@ -173,6 +322,56 @@ def fragment_banner(request: Request) -> HTMLResponse:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]:
|
||||
"""Construieste lista [(eticheta, n), ...] pentru starile blocate cu n > 0.
|
||||
|
||||
Ordinea: needs_mapping, needs_data, error — aceeasi ca in PRD.
|
||||
Returneaza lista goala daca nu exista nicio stare blocata.
|
||||
"""
|
||||
rezultat = []
|
||||
for status in ("needs_mapping", "needs_data", "error"):
|
||||
n = counts.get(status, 0)
|
||||
if n > 0:
|
||||
rezultat.append((eticheta_stare(status), n))
|
||||
return rezultat
|
||||
|
||||
|
||||
@router.get("/_fragments/status", response_class=HTMLResponse)
|
||||
def fragment_status(request: Request) -> HTMLResponse:
|
||||
"""Bara de status persistenta cu etichete umane (US-002, PRD 3.4).
|
||||
|
||||
Scoped pe contul sesiunii. Expune starea worker, legatura RAR, ultima
|
||||
autentificare, contorii de coada si defalcarea blocatelor pe motiv.
|
||||
Logica in routes.py (nu in template) pentru testabilitate.
|
||||
"""
|
||||
account_id = require_login(request)
|
||||
conn = get_connection()
|
||||
try:
|
||||
counts = _status_counts(conn, account_id)
|
||||
hb = read_heartbeat(conn)
|
||||
worker_alive = _worker_alive(hb)
|
||||
rar_state = _rar_state(hb, worker_alive)
|
||||
|
||||
# Etichete umane pre-calculate (nu logica in template)
|
||||
worker_lbl = eticheta_worker(worker_alive)
|
||||
# eticheta_rar accepta "ok" sau orice alt string -> indisponibil/necunoscut
|
||||
rar_lbl = eticheta_rar("ok" if rar_state == "ok" else rar_state)
|
||||
|
||||
return templates.TemplateResponse("_status.html", {
|
||||
"request": request,
|
||||
"worker_lbl": worker_lbl,
|
||||
"rar_lbl": rar_lbl,
|
||||
"eticheta_ultima_auth": ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
|
||||
"last_login": hb["last_rar_login_ok"] if hb else None,
|
||||
"counts_queued": counts.get("queued", 0),
|
||||
"counts_sent": counts.get("sent", 0),
|
||||
"blocate_defalcat": _blocate_defalcat(counts),
|
||||
"account_active": _account_active(conn, account_id),
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/_fragments/submissions", response_class=HTMLResponse)
|
||||
def fragment_submissions(request: Request) -> HTMLResponse:
|
||||
account_id = require_login(request)
|
||||
|
||||
Reference in New Issue
Block a user